accountSecurityEnroll.tsx 14 KB

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