accountSecurityEnroll.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. import {Fragment} from 'react';
  2. import {WithRouterProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {QRCodeCanvas} from 'qrcode.react';
  5. import {
  6. addErrorMessage,
  7. addLoadingMessage,
  8. addSuccessMessage,
  9. } from 'sentry/actionCreators/indicator';
  10. import {openRecoveryOptions} from 'sentry/actionCreators/modal';
  11. import {fetchOrganizationByMember} from 'sentry/actionCreators/organizations';
  12. import {Alert} from 'sentry/components/alert';
  13. import {Button} from 'sentry/components/button';
  14. import ButtonBar from 'sentry/components/buttonBar';
  15. import CircleIndicator from 'sentry/components/circleIndicator';
  16. import FieldGroup from 'sentry/components/forms/fieldGroup';
  17. import Form, {FormProps} from 'sentry/components/forms/form';
  18. import JsonForm from 'sentry/components/forms/jsonForm';
  19. import FormModel from 'sentry/components/forms/model';
  20. import {FieldObject} from 'sentry/components/forms/types';
  21. import PanelItem from 'sentry/components/panels/panelItem';
  22. import TextCopyInput from 'sentry/components/textCopyInput';
  23. import U2fsign from 'sentry/components/u2f/u2fsign';
  24. import {t} from 'sentry/locale';
  25. import {space} from 'sentry/styles/space';
  26. import {Authenticator} from 'sentry/types';
  27. import getPendingInvite from 'sentry/utils/getPendingInvite';
  28. // eslint-disable-next-line no-restricted-imports
  29. import withSentryRouter from 'sentry/utils/withSentryRouter';
  30. import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
  31. import RemoveConfirm from 'sentry/views/settings/account/accountSecurity/components/removeConfirm';
  32. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  33. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  34. type GetFieldsOpts = {
  35. authenticator: Authenticator;
  36. /**
  37. * Flag to track if totp has been sent
  38. */
  39. hasSentCode: boolean;
  40. /**
  41. * Callback to reset SMS 2fa enrollment
  42. */
  43. onSmsReset: () => void;
  44. /**
  45. * Callback when u2f device is activated
  46. */
  47. onU2fTap: React.ComponentProps<typeof U2fsign>['onTap'];
  48. /**
  49. * Flag to track if we are currently sending the otp code
  50. */
  51. sendingCode: boolean;
  52. };
  53. /**
  54. * Retrieve additional form fields (or modify ones) based on 2fa method
  55. */
  56. const getFields = ({
  57. authenticator,
  58. hasSentCode,
  59. sendingCode,
  60. onSmsReset,
  61. onU2fTap,
  62. }: GetFieldsOpts): null | FieldObject[] => {
  63. const {form} = authenticator;
  64. if (!form) {
  65. return null;
  66. }
  67. if (authenticator.id === 'totp') {
  68. return [
  69. () => (
  70. <CodeContainer key="qrcode">
  71. <StyledQRCode
  72. aria-label={t('Enrollment QR Code')}
  73. value={authenticator.qrcode}
  74. size={228}
  75. />
  76. </CodeContainer>
  77. ),
  78. () => (
  79. <FieldGroup key="secret" label={t('Authenticator secret')}>
  80. <TextCopyInput>{authenticator.secret ?? ''}</TextCopyInput>
  81. </FieldGroup>
  82. ),
  83. ...form,
  84. () => (
  85. <Actions key="confirm">
  86. <Button priority="primary" type="submit">
  87. {t('Confirm')}
  88. </Button>
  89. </Actions>
  90. ),
  91. ];
  92. }
  93. // Sms Form needs a start over button + confirm button
  94. // Also inputs being disabled vary based on hasSentCode
  95. if (authenticator.id === 'sms') {
  96. // Ideally we would have greater flexibility when rendering footer
  97. return [
  98. {...form[0], disabled: sendingCode || hasSentCode},
  99. ...(hasSentCode ? [{...form[1], required: true}] : []),
  100. () => (
  101. <Actions key="sms-footer">
  102. <ButtonBar gap={1}>
  103. {hasSentCode && <Button onClick={onSmsReset}>{t('Start Over')}</Button>}
  104. <Button priority="primary" type="submit">
  105. {hasSentCode ? t('Confirm') : t('Send Code')}
  106. </Button>
  107. </ButtonBar>
  108. </Actions>
  109. ),
  110. ];
  111. }
  112. // Need to render device name field + U2f component
  113. if (authenticator.id === 'u2f') {
  114. const deviceNameField = form.find(({name}) => name === 'deviceName')!;
  115. return [
  116. deviceNameField,
  117. () => (
  118. <U2fsign
  119. key="u2f-enroll"
  120. style={{marginBottom: 0}}
  121. challengeData={authenticator.challenge}
  122. displayMode="enroll"
  123. onTap={onU2fTap}
  124. />
  125. ),
  126. ];
  127. }
  128. return null;
  129. };
  130. type Props = DeprecatedAsyncView['props'] & WithRouterProps<{authId: string}, {}> & {};
  131. type State = DeprecatedAsyncView['state'] & {
  132. authenticator: Authenticator | null;
  133. hasSentCode: boolean;
  134. sendingCode: boolean;
  135. };
  136. type PendingInvite = ReturnType<typeof getPendingInvite>;
  137. /**
  138. * Renders necessary forms in order to enroll user in 2fa
  139. */
  140. class AccountSecurityEnroll extends DeprecatedAsyncView<Props, State> {
  141. formModel = new FormModel();
  142. getTitle() {
  143. return t('Security');
  144. }
  145. getDefaultState() {
  146. return {...super.getDefaultState(), hasSentCode: false};
  147. }
  148. get authenticatorEndpoint() {
  149. return `/users/me/authenticators/${this.props.params.authId}/`;
  150. }
  151. get enrollEndpoint() {
  152. return `${this.authenticatorEndpoint}enroll/`;
  153. }
  154. getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
  155. const errorHandler = (err: any) => {
  156. const alreadyEnrolled =
  157. err &&
  158. err.status === 400 &&
  159. err.responseJSON &&
  160. err.responseJSON.details === 'Already enrolled';
  161. if (alreadyEnrolled) {
  162. this.props.router.push('/settings/account/security/');
  163. addErrorMessage(t('Already enrolled'));
  164. }
  165. // Allow the endpoint to fail if the user is already enrolled
  166. return alreadyEnrolled;
  167. };
  168. return [['authenticator', this.enrollEndpoint, {}, {allowError: errorHandler}]];
  169. }
  170. componentDidMount() {
  171. super.componentDidMount();
  172. this.pendingInvitation = getPendingInvite();
  173. }
  174. pendingInvitation: PendingInvite = null;
  175. get authenticatorName() {
  176. return this.state.authenticator?.name ?? 'Authenticator';
  177. }
  178. // This resets state so that user can re-enter their phone number again
  179. handleSmsReset = () => this.setState({hasSentCode: false}, this.remountComponent);
  180. // Handles SMS authenticators
  181. handleSmsSubmit = async (dataModel: any) => {
  182. const {authenticator, hasSentCode} = this.state;
  183. const {phone, otp} = dataModel;
  184. // Don't submit if empty
  185. if (!phone || !authenticator) {
  186. return;
  187. }
  188. const data = {
  189. phone,
  190. // Make sure `otp` is undefined if we are submitting OTP verification
  191. // Otherwise API will think that we are on verification step (e.g. after submitting phone)
  192. otp: hasSentCode ? otp : undefined,
  193. secret: authenticator.secret,
  194. };
  195. // Only show loading when submitting OTP
  196. this.setState({sendingCode: !hasSentCode});
  197. if (!hasSentCode) {
  198. addLoadingMessage(t('Sending code to %s...', data.phone));
  199. } else {
  200. addLoadingMessage(t('Verifying OTP...'));
  201. }
  202. try {
  203. await this.api.requestPromise(this.enrollEndpoint, {data});
  204. } catch (error) {
  205. this.formModel.resetForm();
  206. addErrorMessage(
  207. this.state.hasSentCode ? t('Incorrect OTP') : t('Error sending SMS')
  208. );
  209. this.setState({
  210. hasSentCode: false,
  211. sendingCode: false,
  212. });
  213. // Re-mount because we want to fetch a fresh secret
  214. this.remountComponent();
  215. return;
  216. }
  217. if (!hasSentCode) {
  218. // Just successfully finished sending OTP to user
  219. this.setState({hasSentCode: true, sendingCode: false});
  220. addSuccessMessage(t('Sent code to %s', data.phone));
  221. } else {
  222. // OTP was accepted and SMS was added as a 2fa method
  223. this.handleEnrollSuccess();
  224. }
  225. };
  226. // Handle u2f device tap
  227. handleU2fTap = async (tapData: any) => {
  228. const data = {deviceName: this.formModel.getValue('deviceName'), ...tapData};
  229. this.setState({loading: true});
  230. try {
  231. await this.api.requestPromise(this.enrollEndpoint, {data});
  232. } catch (err) {
  233. this.handleEnrollError();
  234. return;
  235. }
  236. this.handleEnrollSuccess();
  237. };
  238. // Currently only TOTP uses this
  239. handleTotpSubmit = async (dataModel: any) => {
  240. if (!this.state.authenticator) {
  241. return;
  242. }
  243. const data = {
  244. ...(dataModel ?? {}),
  245. secret: this.state.authenticator.secret,
  246. };
  247. this.setState({loading: true});
  248. try {
  249. await this.api.requestPromise(this.enrollEndpoint, {method: 'POST', data});
  250. } catch (err) {
  251. this.handleEnrollError();
  252. return;
  253. }
  254. this.handleEnrollSuccess();
  255. };
  256. handleSubmit: FormProps['onSubmit'] = data => {
  257. const id = this.state.authenticator?.id;
  258. if (id === 'totp') {
  259. this.handleTotpSubmit(data);
  260. return;
  261. }
  262. if (id === 'sms') {
  263. this.handleSmsSubmit(data);
  264. return;
  265. }
  266. };
  267. // Handler when we successfully add a 2fa device
  268. async handleEnrollSuccess() {
  269. // If we're pending approval of an invite, the user will have just joined
  270. // the organization when completing 2fa enrollment. We should reload the
  271. // organization context in that case to assign them to the org.
  272. if (this.pendingInvitation) {
  273. await fetchOrganizationByMember(
  274. this.api,
  275. this.pendingInvitation.memberId.toString(),
  276. {
  277. addOrg: true,
  278. fetchOrgDetails: true,
  279. }
  280. );
  281. }
  282. this.props.router.push('/settings/account/security/');
  283. openRecoveryOptions({authenticatorName: this.authenticatorName});
  284. }
  285. // Handler when we failed to add a 2fa device
  286. handleEnrollError() {
  287. this.setState({loading: false});
  288. addErrorMessage(t('Error adding %s authenticator', this.authenticatorName));
  289. }
  290. // Removes an authenticator
  291. handleRemove = async () => {
  292. const {authenticator} = this.state;
  293. if (!authenticator || !authenticator.authId) {
  294. return;
  295. }
  296. // `authenticator.authId` is NOT the same as `props.params.authId` This is
  297. // for backwards compatibility with API endpoint
  298. try {
  299. await this.api.requestPromise(this.authenticatorEndpoint, {method: 'DELETE'});
  300. } catch (err) {
  301. addErrorMessage(t('Error removing authenticator'));
  302. return;
  303. }
  304. this.props.router.push('/settings/account/security/');
  305. addSuccessMessage(t('Authenticator has been removed'));
  306. };
  307. renderBody() {
  308. const {authenticator, hasSentCode, sendingCode} = this.state;
  309. if (!authenticator) {
  310. return null;
  311. }
  312. const fields = getFields({
  313. authenticator,
  314. hasSentCode,
  315. sendingCode,
  316. onSmsReset: this.handleSmsReset,
  317. onU2fTap: this.handleU2fTap,
  318. });
  319. // Attempt to extract `defaultValue` from server generated form fields
  320. const defaultValues = fields
  321. ? fields
  322. .filter(
  323. field =>
  324. typeof field !== 'function' && typeof field.defaultValue !== 'undefined'
  325. )
  326. .map(field => [
  327. field.name,
  328. typeof field !== 'function' ? field.defaultValue : '',
  329. ])
  330. .reduce((acc, [name, value]) => {
  331. acc[name] = value;
  332. return acc;
  333. }, {})
  334. : {};
  335. const isActive = authenticator.isEnrolled || authenticator.status === 'rotation';
  336. return (
  337. <Fragment>
  338. <SettingsPageHeader
  339. title={
  340. <Fragment>
  341. <span>{authenticator.name}</span>
  342. <CircleIndicator
  343. role="status"
  344. aria-label={
  345. isActive
  346. ? t('Authentication Method Active')
  347. : t('Authentication Method Inactive')
  348. }
  349. enabled={isActive}
  350. css={{marginLeft: 6}}
  351. />
  352. </Fragment>
  353. }
  354. action={
  355. authenticator.isEnrolled &&
  356. authenticator.removeButton && (
  357. <RemoveConfirm onConfirm={this.handleRemove}>
  358. <Button priority="danger">{authenticator.removeButton}</Button>
  359. </RemoveConfirm>
  360. )
  361. }
  362. />
  363. <TextBlock>{authenticator.description}</TextBlock>
  364. {authenticator.rotationWarning && authenticator.status === 'rotation' && (
  365. <Alert type="warning" showIcon>
  366. {authenticator.rotationWarning}
  367. </Alert>
  368. )}
  369. {!!authenticator.form?.length && (
  370. <Form
  371. model={this.formModel}
  372. apiMethod="POST"
  373. apiEndpoint={this.authenticatorEndpoint}
  374. onSubmit={this.handleSubmit}
  375. initialData={{...defaultValues, ...authenticator}}
  376. hideFooter
  377. >
  378. <JsonForm forms={[{title: 'Configuration', fields: fields ?? []}]} />
  379. </Form>
  380. )}
  381. </Fragment>
  382. );
  383. }
  384. }
  385. const CodeContainer = styled(PanelItem)`
  386. justify-content: center;
  387. `;
  388. const Actions = styled(PanelItem)`
  389. justify-content: flex-end;
  390. `;
  391. const StyledQRCode = styled(QRCodeCanvas)`
  392. background: white;
  393. padding: ${space(2)};
  394. `;
  395. export default withSentryRouter(AccountSecurityEnroll);