accountSecurityEnroll.tsx 12 KB

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