Browse Source

feat(ui): Add react UI for two factor auth (#7158)

* refactor account security components + add comments
* change json form to check for fields that are render functions
* fix new account settings -> old account settings links
* add routes.jsx to codecov ignore
* add test for settings layout
* fix remove defaultValues when rendering fields in jsonForm
* Add error message in AsyncComponent for 401s (e.g. closing sudo modal)
* Add "disabled" prop to Tooltip to allow rendering children without tooltip
Billy Vong 7 years ago
parent
commit
5f09d75650

+ 1 - 0
codecov.yml

@@ -22,4 +22,5 @@ coverage:
   - src/debug_toolbar/.*
   - src/social_auth/.*
   - src/south/.*
+  - src/sentry/static/sentry/app/routes.jsx
 comment: false

+ 25 - 3
src/sentry/api/endpoints/user_authenticator_details.py

@@ -80,13 +80,14 @@ class UserAuthenticatorDetailsEndpoint(UserEndpoint):
         return Response(serialize(interface))
 
     @sudo_required
-    def delete(self, request, user, auth_id):
+    def delete(self, request, user, auth_id, interface_device_id=None):
         """
         Remove authenticator
         ````````````````````
 
         :pparam string user_id: user id or 'me' for current user
         :pparam string auth_id: authenticator model id
+        :pparam string interface_device_id: some interfaces (u2f) allow multiple devices
 
         :auth required:
         """
@@ -99,13 +100,34 @@ class UserAuthenticatorDetailsEndpoint(UserEndpoint):
         except (ValueError, Authenticator.DoesNotExist):
             return Response(status=status.HTTP_404_NOT_FOUND)
 
+        interface = authenticator.interface
+
+        # Remove a single device and not entire authentication method
+        if interface.interface_id == 'u2f' and interface_device_id is not None:
+            # Can't remove if this is the last device, will return False if so
+            if not interface.remove_u2f_device(interface_device_id):
+                return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+            interface.authenticator.save()
+            capture_security_activity(
+                account=user,
+                type='mfa-removed',
+                actor=request.user,
+                ip_address=request.META['REMOTE_ADDR'],
+                context={
+                    'authenticator': authenticator,
+                },
+                send_email=False
+            )
+            return Response(status=status.HTTP_204_NO_CONTENT)
+
         with transaction.atomic():
             authenticator.delete()
 
             # if we delete an actual authenticator and all that
             # remainds are backup interfaces, then we kill them in the
             # process.
-            if not authenticator.interface.is_backup_interface:
+            if not interface.is_backup_interface:
                 interfaces = Authenticator.objects.all_interfaces_for_user(user)
                 backup_interfaces = [x for x in interfaces if x.is_backup_interface]
                 if len(backup_interfaces) == len(interfaces):
@@ -134,7 +156,7 @@ class UserAuthenticatorDetailsEndpoint(UserEndpoint):
                 context={
                     'authenticator': authenticator,
                 },
-                send_email=not authenticator.interface.is_backup_interface,
+                send_email=not interface.is_backup_interface,
             )
 
         return Response(status=status.HTTP_204_NO_CONTENT)

+ 5 - 0
src/sentry/api/urls.py

@@ -216,6 +216,11 @@ urlpatterns = patterns(
         UserAuthenticatorEnrollEndpoint.as_view(),
         name='sentry-api-0-user-authenticator-enroll'
     ),
+    url(
+        r'^users/(?P<user_id>[^\/]+)/authenticators/(?P<auth_id>[^\/]+)/(?P<interface_device_id>[^\/]+)/$',
+        UserAuthenticatorDetailsEndpoint.as_view(),
+        name='sentry-api-0-user-authenticator-device-details'
+    ),
     url(
         r'^users/(?P<user_id>[^\/]+)/authenticators/(?P<auth_id>[^\/]+)/$',
         UserAuthenticatorDetailsEndpoint.as_view(),

+ 3 - 1
src/sentry/static/sentry/app/api.jsx

@@ -89,7 +89,8 @@ export class Client {
         },
         onClose: () => {
           if (typeof requestOptions.error !== 'function') return;
-          requestOptions.error();
+          // If modal was closed, then forward the original response
+          requestOptions.error(response);
         },
       });
       return;
@@ -156,6 +157,7 @@ export class Client {
       this.request(path, {
         ...options,
         success: (data, ...args) => {
+          // This fails if we need jqXhr :(
           resolve(data);
         },
         error: (error, ...args) => {

+ 17 - 5
src/sentry/static/sentry/app/components/asyncComponent.jsx

@@ -4,7 +4,7 @@ import Raven from 'raven-js';
 import React from 'react';
 
 import {Client} from '../api';
-import {tct} from '../locale';
+import {t, tct} from '../locale';
 import ExternalLink from './externalLink';
 import LoadingError from './loadingError';
 import LoadingIndicator from '../components/loadingIndicator';
@@ -106,7 +106,8 @@ class AsyncComponent extends React.Component {
           this.setState(prevState => {
             return {
               [stateKey]: data,
-              [`${stateKey}PageLinks`]: jqXHR.getResponseHeader('Link'),
+              // TODO(billy): This currently fails if this request is retried by SudoModal
+              [`${stateKey}PageLinks`]: jqXHR && jqXHR.getResponseHeader('Link'),
               remainingRequests: prevState.remainingRequests - 1,
               loading: prevState.remainingRequests > 1,
             };
@@ -167,20 +168,31 @@ class AsyncComponent extends React.Component {
   }
 
   renderError(error) {
-    // Look through endpoint results to see if we had any 403s
-    let permissionErrors = Object.keys(this.state.errors).find(endpointName => {
+    let unauthorizedErrors = Object.keys(this.state.errors).find(endpointName => {
       let result = this.state.errors[endpointName];
+      // 401s are captured by SudaModal, but may be passed back to AsyncComponent if they close the modal without identifying
+      return result && result.status === 401;
+    });
 
+    // Look through endpoint results to see if we had any 403s, means their role can not access resource
+    let permissionErrors = Object.keys(this.state.errors).find(endpointName => {
+      let result = this.state.errors[endpointName];
       return result && result.status === 403;
     });
 
+    if (unauthorizedErrors) {
+      return (
+        <LoadingError message={t('You are not authorized to access this resource.')} />
+      );
+    }
+
     if (permissionErrors) {
       // TODO(billy): Refactor this into a new PermissionDenied component
       Raven.captureException(new Error('Permission Denied'), {});
       return (
         <LoadingError
           message={tct(
-            'You do not have permission to access this, please read more about [link:organizational roles]',
+            'Your role does not have the necessary permissions to access this resource, please read more about [link:organizational roles]',
             {
               link: <ExternalLink href="https://docs.sentry.io/learn/membership/" />,
             }

+ 8 - 0
src/sentry/static/sentry/app/components/tooltip.jsx

@@ -9,6 +9,7 @@ import 'bootstrap/js/tooltip';
 class Tooltip extends React.Component {
   static propTypes = {
     children: PropTypes.node.isRequired,
+    disabled: PropTypes.bool,
     tooltipOptions: PropTypes.object,
     title: PropTypes.node,
   };
@@ -51,11 +52,18 @@ class Tooltip extends React.Component {
       className,
       title,
       children,
+      disabled,
       // eslint-disable-next-line no-unused-vars
       tooltipOptions,
       ...props
     } = this.props;
 
+    // Return children as normal if Tooltip is disabled
+    // (this lets us do <Tooltip disabled={isDisabled}><Button>Foo</Button></Tooltip>)
+    if (disabled) {
+      return children;
+    }
+
     return React.cloneElement(children, {
       ...props,
       ref: this.handleMount,

+ 25 - 0
src/sentry/static/sentry/app/routes.jsx

@@ -164,6 +164,31 @@ const accountSettingsRoutes = [
       import(/*webpackChunkName: "AccountAuthorizations"*/ './views/settings/account/accountAuthorizations')}
     component={errorHandler(LazyLoad)}
   />,
+
+  <Route key="security/" name="Security" path="security/">
+    <IndexRoute
+      componentPromise={() =>
+        import(/*webpackChunkName: "AccountSecurity"*/ './views/settings/account/accountSecurity/index')}
+      component={errorHandler(LazyLoad)}
+    />
+
+    <Route
+      path=":authId/enroll/"
+      name="Enroll"
+      componentPromise={() =>
+        import(/*webpackChunkName: "AccountSecurityEnroll"*/ './views/settings/account/accountSecurity/accountSecurityEnroll')}
+      component={errorHandler(LazyLoad)}
+    />
+
+    <Route
+      path=":authId/"
+      name="Details"
+      componentPromise={() =>
+        import(/*webpackChunkName: "AccountSecurityDetails"*/ './views/settings/account/accountSecurity/accountSecurityDetails')}
+      component={errorHandler(LazyLoad)}
+    />
+  </Route>,
+
   <Route
     key="subscriptions/"
     path="subscriptions/"

+ 175 - 0
src/sentry/static/sentry/app/views/settings/account/accountSecurity/accountSecurityDetails.jsx

@@ -0,0 +1,175 @@
+/**
+ * AccountSecurityDetails is only displayed when user is enrolled in the 2fa method.
+ * It displays created + last used time of the 2fa method.
+ *
+ * Also displays 2fa method specific details.
+ */
+import {Box, Flex} from 'grid-emotion';
+import {withRouter} from 'react-router';
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'react-emotion';
+
+import {
+  addErrorMessage,
+  addSuccessMessage,
+} from '../../../../actionCreators/settingsIndicator';
+import {t} from '../../../../locale';
+import AsyncView from '../../../asyncView';
+import Button from '../../../../components/buttons/button';
+import CircleIndicator from '../../../../components/circleIndicator';
+import DateTime from '../../../../components/dateTime';
+import RecoveryCodes from './components/recoveryCodes';
+import RemoveConfirm from './components/removeConfirm';
+import SettingsPageHeader from '../../components/settingsPageHeader';
+import TextBlock from '../../components/text/textBlock';
+import U2fEnrolledDetails from './components/u2fEnrolledDetails';
+
+const ENDPOINT = '/users/me/authenticators/';
+
+const DateLabel = styled.span`
+  font-weight: bold;
+  margin-right: 6px;
+  width: 100px;
+`;
+
+const Phone = styled.span`
+  font-weight: bold;
+  margin-left: 6px;
+`;
+
+class AuthenticatorDate extends React.Component {
+  static propTypes = {
+    label: PropTypes.string,
+    /**
+     * Can be null or a Date object.
+     * Component will have value "never" if it is null
+     */
+    date: PropTypes.string,
+  };
+  render() {
+    let {label, date} = this.props;
+
+    return (
+      <Flex mb={1}>
+        <DateLabel>{label}</DateLabel>
+        <Box flex="1">{date ? <DateTime date={date} /> : t('never')}</Box>
+      </Flex>
+    );
+  }
+}
+
+class AccountSecurityDetails extends AsyncView {
+  constructor(...args) {
+    super(...args);
+    this._form = {};
+  }
+
+  getTitle() {
+    return t('Security');
+  }
+
+  getEndpoints() {
+    return [['authenticator', `${ENDPOINT}${this.props.params.authId}/`]];
+  }
+
+  addError(message) {
+    this.setState({loading: false});
+    addErrorMessage(message);
+  }
+
+  handleRemove = device => {
+    let {authenticator} = this.state;
+
+    if (!authenticator || !authenticator.authId) return;
+    let isRemovingU2fDevice = !!device;
+    let deviceId = isRemovingU2fDevice ? `${device.key_handle}/` : '';
+
+    this.setState(
+      {
+        loading: true,
+      },
+      () =>
+        this.api
+          .requestPromise(`${ENDPOINT}${authenticator.authId}/${deviceId}`, {
+            method: 'DELETE',
+          })
+          .then(
+            () => {
+              this.props.router.push('/settings/account/security');
+              let deviceName = isRemovingU2fDevice ? device.name : 'Authenticator';
+              addSuccessMessage(t('%s has been removed', deviceName));
+            },
+            () => {
+              // Error deleting authenticator
+              let deviceName = isRemovingU2fDevice ? device.name : 'authenticator';
+              this.addError(t('Error removing %s', deviceName));
+            }
+          )
+    );
+  };
+
+  handleRegenerateBackupCodes = () => {
+    this.setState({loading: true}, () =>
+      this.api
+        .requestPromise(`${ENDPOINT}${this.props.params.authId}/`, {
+          method: 'PUT',
+        })
+        .then(this.remountComponent, () =>
+          this.addError(t('Error regenerating backup codes'))
+        )
+    );
+  };
+
+  renderBody() {
+    let {authenticator} = this.state;
+
+    return (
+      <div>
+        <SettingsPageHeader
+          title={
+            <React.Fragment>
+              <span>{authenticator.name}</span>
+              <CircleIndicator css={{marginLeft: 6}} enabled={authenticator.isEnrolled} />
+            </React.Fragment>
+          }
+          action={
+            authenticator.isEnrolled &&
+            authenticator.removeButton && (
+              <RemoveConfirm onConfirm={this.handleRemove}>
+                <Button priority="danger">{authenticator.removeButton}</Button>
+              </RemoveConfirm>
+            )
+          }
+        />
+
+        <TextBlock>{authenticator.description}</TextBlock>
+        <AuthenticatorDate label={t('Created at')} date={authenticator.createdAt} />
+        <AuthenticatorDate label={t('Last used')} date={authenticator.lastUsedAt} />
+
+        <U2fEnrolledDetails
+          isEnrolled={authenticator.isEnrolled}
+          id={authenticator.id}
+          devices={authenticator.devices}
+          onRemoveU2fDevice={this.handleRemove}
+        />
+
+        {authenticator.isEnrolled &&
+          authenticator.phone && (
+            <div css={{marginTop: 30}}>
+              {t('Confirmation codes are sent to the following phone number')}:
+              <Phone>{authenticator.phone}</Phone>
+            </div>
+          )}
+
+        <RecoveryCodes
+          onRegenerateBackupCodes={this.handleRegenerateBackupCodes}
+          isEnrolled={authenticator.isEnrolled}
+          codes={authenticator.codes}
+        />
+      </div>
+    );
+  }
+}
+
+export default withRouter(AccountSecurityDetails);

+ 337 - 0
src/sentry/static/sentry/app/views/settings/account/accountSecurity/accountSecurityEnroll.jsx

@@ -0,0 +1,337 @@
+/**
+ * Renders necessary forms in order to enroll user in 2fa
+ */
+import {withRouter} from 'react-router';
+import React from 'react';
+
+import {
+  addErrorMessage,
+  addMessage,
+  addSuccessMessage,
+} from '../../../../actionCreators/settingsIndicator';
+import {t} from '../../../../locale';
+import AsyncView from '../../../asyncView';
+import Button from '../../../../components/buttons/button';
+import CircleIndicator from '../../../../components/circleIndicator';
+import Form from '../../components/forms/form';
+import JsonForm from '../../components/forms/jsonForm';
+import PanelItem from '../../components/panelItem';
+import Qrcode from '../../../../components/qrcode';
+import RemoveConfirm from './components/removeConfirm';
+import SettingsPageHeader from '../../components/settingsPageHeader';
+import TextBlock from '../../components/text/textBlock';
+import U2fsign from '../../../../components/u2fsign';
+
+const ENDPOINT = '/users/me/authenticators/';
+
+/**
+ * Retrieve additional form fields (or modify ones) based on 2fa method
+ *
+ * @param {object} params Params object
+ * @param {object} authenticator Authenticator model
+ * @param {boolean} hasSentCode Flag to track if totp has been sent
+ * @param {function} onSmsReset Callback to reset SMS 2fa enrollment
+ * @param {function} onSmsSubmit Callback to handle sending code or submit OTP
+ * @param {function} onU2fTap Callback when u2f device is activated
+ */
+const getFields = ({authenticator, hasSentCode, onSmsReset, onSmsSubmit, onU2fTap}) => {
+  let {form, qrcode, challenge, id} = authenticator || {};
+
+  if (!form) return null;
+
+  if (qrcode) {
+    return [
+      () => (
+        <PanelItem key="qrcode" justify="center" p={2}>
+          <Qrcode code={authenticator.qrcode} />
+        </PanelItem>
+      ),
+      ...form,
+      () => (
+        <PanelItem key="confirm" justify="flex-end" p={2}>
+          <Button priority="primary" type="submit">
+            {t('Confirm')}
+          </Button>
+        </PanelItem>
+      ),
+    ];
+  }
+
+  // Sms Form needs a start over button + confirm button
+  // Also inputs being disabled vary based on hasSentCode
+  if (id === 'sms') {
+    // Ideally we would have greater flexibility when rendering footer
+    return [
+      {
+        ...form[0],
+        disabled: () => hasSentCode,
+      },
+      {
+        ...form[1],
+        required: true,
+        visible: () => hasSentCode,
+      },
+      () => (
+        <PanelItem key="sms-footer" justify="flex-end" p={2} pr={'36px'}>
+          {hasSentCode && (
+            <Button css={{marginRight: 6}} onClick={onSmsReset}>
+              {t('Start Over')}
+            </Button>
+          )}
+          <Button priority="primary" type="button" onClick={onSmsSubmit}>
+            {hasSentCode ? t('Confirm') : t('Send Code')}
+          </Button>
+        </PanelItem>
+      ),
+    ];
+  }
+
+  // Need to render device name field + U2f component
+  if (id === 'u2f') {
+    let deviceNameField = form.find(({name}) => name === 'deviceName');
+    return [
+      deviceNameField,
+      () => (
+        <U2fsign
+          key="u2f-enroll"
+          style={{marginBottom: 0}}
+          challengeData={challenge}
+          displayMode="enroll"
+          flowMode="enroll"
+          onTap={onU2fTap}
+        />
+      ),
+    ];
+  }
+
+  return null;
+};
+
+class AccountSecurityEnroll extends AsyncView {
+  constructor(...args) {
+    super(...args);
+    this._form = {};
+  }
+
+  getTitle() {
+    return t('Security');
+  }
+
+  getEndpoints() {
+    return [['authenticator', `${ENDPOINT}${this.props.params.authId}/enroll/`]];
+  }
+
+  handleFieldChange = (name, value) => {
+    // This should not be used for rendering, that's why it's not in state
+    this._form[name] = value;
+  };
+
+  // This resets state so that user can re-enter their phone number again
+  handleSmsReset = () => {
+    this.setState(
+      {
+        hasSentCode: false,
+      },
+      this.remountComponent
+    );
+  };
+
+  // Handles
+  handleSmsSubmit = dataModel => {
+    let {authenticator, hasSentCode} = this.state;
+
+    // Don't submit if empty
+    if (!this._form.phone) return;
+
+    let data = {
+      phone: this._form.phone,
+      // Make sure `otp` is undefined if we are submitting OTP verification
+      // Otherwise API will think that we are on verification step (e.g. after submitting phone)
+      otp: hasSentCode ? this._form.otp || '' : undefined,
+      secret: authenticator.secret,
+    };
+
+    // Only show loading when submitting OTP
+    this.setState({
+      loading: hasSentCode,
+    });
+
+    if (!hasSentCode) {
+      addMessage(t('Sending code to %s...', data.phone));
+    }
+
+    this.api
+      .requestPromise(`${ENDPOINT}${this.props.params.authId}/enroll/`, {
+        data,
+      })
+      .then(
+        () => {
+          if (!hasSentCode) {
+            // Just successfully finished sending OTP to user
+            this.setState({
+              hasSentCode: true,
+              loading: false,
+              // authenticator: data,
+            });
+            addMessage(t('Sent code to %s', data.phone));
+          } else {
+            // OTP was accepted and SMS was added as a 2fa method
+            this.props.router.push('/settings/account/security/');
+            addSuccessMessage(t('Added authenticator %s', authenticator.name));
+          }
+        },
+        error => {
+          this._form = {};
+          let isSmsInterface = authenticator.id === 'sms';
+
+          this.setState({
+            hasSentCode: !isSmsInterface,
+          });
+
+          // Re-mount because we want to fetch a fresh secret
+          this.remountComponent();
+
+          let errorMessage = this.state.hasSentCode
+            ? t('Incorrect OTP')
+            : t('Error sending SMS');
+          addErrorMessage(errorMessage);
+        }
+      );
+  };
+
+  // Handle u2f device tap
+  handleU2fTap = data => {
+    return this.api
+      .requestPromise(`${ENDPOINT}${this.props.params.authId}/enroll/`, {
+        data: {
+          ...data,
+          ...this._form,
+        },
+      })
+      .then(this.handleEnrollSuccess, this.handleEnrollError);
+  };
+
+  // Currently only TOTP uses this
+  handleSubmit = dataModel => {
+    let {authenticator} = this.state;
+
+    let data = {
+      ...this._form,
+      ...((dataModel && dataModel.toJSON()) || {}),
+      secret: authenticator.secret,
+    };
+
+    this.setState({
+      loading: true,
+    });
+    this.api
+      .requestPromise(`${ENDPOINT}${this.props.params.authId}/enroll/`, {
+        method: 'POST',
+        data,
+      })
+      .then(this.handleEnrollSuccess, this.handleEnrollError);
+  };
+
+  // Handler when we successfully add a 2fa device
+  handleEnrollSuccess = () => {
+    let authenticatorName =
+      (this.state.authenticator && this.state.authenticator.name) || 'Authenticator';
+    this.props.router.push('/settings/account/security');
+    addSuccessMessage(t('%s has been added', authenticatorName));
+  };
+
+  // Handler when we failed to add a 2fa device
+  handleEnrollError = () => {
+    let authenticatorName =
+      (this.state.authenticator && this.state.authenticator.name) || 'Authenticator';
+    this.setState({loading: false});
+    addErrorMessage(t('Error adding %s authenticator', authenticatorName));
+  };
+
+  // Removes an authenticator
+  handleRemove = () => {
+    let {authenticator} = this.state;
+
+    if (!authenticator || !authenticator.authId) return;
+
+    // `authenticator.authId` is NOT the same as `props.params.authId`
+    // This is for backwards compatbility with API endpoint
+    this.api
+      .requestPromise(`${ENDPOINT}${authenticator.authId}/`, {
+        method: 'DELETE',
+      })
+      .then(
+        () => {
+          this.props.router.push('/settings/account/security/');
+          addSuccessMessage(t('Authenticator has been removed'));
+        },
+        () => {
+          // Error deleting authenticator
+          addErrorMessage(t('Error removing authenticator'));
+        }
+      );
+  };
+
+  renderBody() {
+    let {authenticator} = this.state;
+    let endpoint = `${ENDPOINT}${this.props.params.authId}/`;
+
+    let fields = getFields({
+      authenticator,
+      hasSentCode: this.state.hasSentCode,
+      onSmsReset: this.handleSmsReset,
+      onSmsSubmit: this.handleSmsSubmit,
+      onU2fTap: this.handleU2fTap,
+    });
+
+    // Attempt to extract `defaultValue` from server generated form fields
+    const defaultValues = fields
+      ? fields
+          .filter(field => typeof field.defaultValue !== 'undefined')
+          .map(field => [field.name, field.defaultValue])
+          .reduce((acc, [name, value]) => {
+            acc[name] = value;
+            return acc;
+          }, {})
+      : {};
+
+    return (
+      <div>
+        <SettingsPageHeader
+          title={
+            <React.Fragment>
+              <span>{authenticator.name}</span>
+              <CircleIndicator css={{marginLeft: 6}} enabled={authenticator.isEnrolled} />
+            </React.Fragment>
+          }
+          action={
+            authenticator.isEnrolled &&
+            authenticator.removeButton && (
+              <RemoveConfirm onConfirm={this.handleRemove}>
+                <Button priority="danger">{authenticator.removeButton}</Button>
+              </RemoveConfirm>
+            )
+          }
+        />
+
+        <TextBlock>{authenticator.description}</TextBlock>
+
+        {authenticator.form &&
+          !!authenticator.form.length && (
+            <Form
+              apiMethod="POST"
+              onFieldChange={this.handleFieldChange}
+              apiEndpoint={endpoint}
+              onSubmit={this.handleSubmit}
+              initialData={{...defaultValues, ...authenticator}}
+              hideFooter
+            >
+              <JsonForm {...this.props} forms={[{title: 'Configuration', fields}]} />
+            </Form>
+          )}
+      </div>
+    );
+  }
+}
+
+export default withRouter(AccountSecurityEnroll);

+ 8 - 0
src/sentry/static/sentry/app/views/settings/account/accountSecurity/components/confirmHeader.jsx

@@ -0,0 +1,8 @@
+import styled from 'react-emotion';
+
+const ConfirmHeader = styled.div`
+  font-size: 1.2em;
+  margin-bottom: 10px;
+`;
+
+export default ConfirmHeader;

Some files were not shown because too many files changed in this diff