accountSecurityEnroll.tsx 15 KB

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