accountSecurityEnroll.tsx 13 KB

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