Files
2024-02-15 17:01:50 +00:00

463 lines
13 KiB
TypeScript

import {Fragment} from 'react';
import type {WithRouterProps} from 'react-router';
import styled from '@emotion/styled';
import {QRCodeCanvas} from 'qrcode.react';
import {
addErrorMessage,
addLoadingMessage,
addSuccessMessage,
} from 'sentry/actionCreators/indicator';
import {openRecoveryOptions} from 'sentry/actionCreators/modal';
import {fetchOrganizationByMember} from 'sentry/actionCreators/organizations';
import {Alert} from 'sentry/components/alert';
import {Button} from 'sentry/components/button';
import ButtonBar from 'sentry/components/buttonBar';
import CircleIndicator from 'sentry/components/circleIndicator';
import FieldGroup from 'sentry/components/forms/fieldGroup';
import type {FormProps} from 'sentry/components/forms/form';
import Form from 'sentry/components/forms/form';
import JsonForm from 'sentry/components/forms/jsonForm';
import FormModel from 'sentry/components/forms/model';
import type {FieldObject} from 'sentry/components/forms/types';
import PanelItem from 'sentry/components/panels/panelItem';
import TextCopyInput from 'sentry/components/textCopyInput';
import U2fsign from 'sentry/components/u2f/u2fsign';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Authenticator} from 'sentry/types';
import getPendingInvite from 'sentry/utils/getPendingInvite';
// eslint-disable-next-line no-restricted-imports
import withSentryRouter from 'sentry/utils/withSentryRouter';
import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
import RemoveConfirm from 'sentry/views/settings/account/accountSecurity/components/removeConfirm';
import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
import TextBlock from 'sentry/views/settings/components/text/textBlock';
type GetFieldsOpts = {
authenticator: Authenticator;
/**
* Flag to track if totp has been sent
*/
hasSentCode: boolean;
/**
* Callback to reset SMS 2fa enrollment
*/
onSmsReset: () => void;
/**
* Callback when u2f device is activated
*/
onU2fTap: React.ComponentProps<typeof U2fsign>['onTap'];
/**
* Flag to track if we are currently sending the otp code
*/
sendingCode: boolean;
};
/**
* Retrieve additional form fields (or modify ones) based on 2fa method
*/
const getFields = ({
authenticator,
hasSentCode,
sendingCode,
onSmsReset,
onU2fTap,
}: GetFieldsOpts): null | FieldObject[] => {
const {form} = authenticator;
if (!form) {
return null;
}
if (authenticator.id === 'totp') {
return [
() => (
<CodeContainer key="qrcode">
<StyledQRCode
aria-label={t('Enrollment QR Code')}
value={authenticator.qrcode}
size={228}
/>
</CodeContainer>
),
() => (
<FieldGroup key="secret" label={t('Authenticator secret')}>
<TextCopyInput>{authenticator.secret ?? ''}</TextCopyInput>
</FieldGroup>
),
...form,
() => (
<Actions key="confirm">
<Button priority="primary" type="submit">
{t('Confirm')}
</Button>
</Actions>
),
];
}
// Sms Form needs a start over button + confirm button
// Also inputs being disabled vary based on hasSentCode
if (authenticator.id === 'sms') {
// Ideally we would have greater flexibility when rendering footer
return [
{...form[0], disabled: sendingCode || hasSentCode},
...(hasSentCode ? [{...form[1], required: true}] : []),
() => (
<Actions key="sms-footer">
<ButtonBar gap={1}>
{hasSentCode && <Button onClick={onSmsReset}>{t('Start Over')}</Button>}
<Button priority="primary" type="submit">
{hasSentCode ? t('Confirm') : t('Send Code')}
</Button>
</ButtonBar>
</Actions>
),
];
}
// Need to render device name field + U2f component
if (authenticator.id === 'u2f') {
const deviceNameField = form.find(({name}) => name === 'deviceName')!;
return [
deviceNameField,
() => (
<U2fsign
key="u2f-enroll"
style={{marginBottom: 0}}
challengeData={authenticator.challenge}
displayMode="enroll"
onTap={onU2fTap}
/>
),
];
}
return null;
};
type Props = DeprecatedAsyncView['props'] & WithRouterProps<{authId: string}, {}> & {};
type State = DeprecatedAsyncView['state'] & {
authenticator: Authenticator | null;
hasSentCode: boolean;
sendingCode: boolean;
};
type PendingInvite = ReturnType<typeof getPendingInvite>;
/**
* Renders necessary forms in order to enroll user in 2fa
*/
class AccountSecurityEnroll extends DeprecatedAsyncView<Props, State> {
formModel = new FormModel();
getTitle() {
return t('Security');
}
getDefaultState() {
return {...super.getDefaultState(), hasSentCode: false};
}
get authenticatorEndpoint() {
return `/users/me/authenticators/${this.props.params.authId}/`;
}
get enrollEndpoint() {
return `${this.authenticatorEndpoint}enroll/`;
}
getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
const errorHandler = (err: any) => {
const alreadyEnrolled =
err &&
err.status === 400 &&
err.responseJSON &&
err.responseJSON.details === 'Already enrolled';
if (alreadyEnrolled) {
this.props.router.push('/settings/account/security/');
addErrorMessage(t('Already enrolled'));
}
// Allow the endpoint to fail if the user is already enrolled
return alreadyEnrolled;
};
return [['authenticator', this.enrollEndpoint, {}, {allowError: errorHandler}]];
}
componentDidMount() {
super.componentDidMount();
this.pendingInvitation = getPendingInvite();
}
pendingInvitation: PendingInvite = null;
get authenticatorName() {
return this.state.authenticator?.name ?? 'Authenticator';
}
// This resets state so that user can re-enter their phone number again
handleSmsReset = () => this.setState({hasSentCode: false}, this.remountComponent);
// Handles SMS authenticators
handleSmsSubmit = async (dataModel: any) => {
const {authenticator, hasSentCode} = this.state;
const {phone, otp} = dataModel;
// Don't submit if empty
if (!phone || !authenticator) {
return;
}
const data = {
phone,
// Make sure `otp` is undefined if we are submitting OTP verification
// Otherwise API will think that we are on verification step (e.g. after submitting phone)
otp: hasSentCode ? otp : undefined,
secret: authenticator.secret,
};
// Only show loading when submitting OTP
this.setState({sendingCode: !hasSentCode});
if (!hasSentCode) {
addLoadingMessage(t('Sending code to %s...', data.phone));
} else {
addLoadingMessage(t('Verifying OTP...'));
}
try {
await this.api.requestPromise(this.enrollEndpoint, {data});
} catch (error) {
this.formModel.resetForm();
addErrorMessage(
this.state.hasSentCode ? t('Incorrect OTP') : t('Error sending SMS')
);
this.setState({
hasSentCode: false,
sendingCode: false,
});
// Re-mount because we want to fetch a fresh secret
this.remountComponent();
return;
}
if (!hasSentCode) {
// Just successfully finished sending OTP to user
this.setState({hasSentCode: true, sendingCode: false});
addSuccessMessage(t('Sent code to %s', data.phone));
} else {
// OTP was accepted and SMS was added as a 2fa method
this.handleEnrollSuccess();
}
};
// Handle u2f device tap
handleU2fTap = async (tapData: any) => {
const data = {deviceName: this.formModel.getValue('deviceName'), ...tapData};
this.setState({loading: true});
try {
await this.api.requestPromise(this.enrollEndpoint, {data});
} catch (err) {
this.handleEnrollError();
return;
}
this.handleEnrollSuccess();
};
// Currently only TOTP uses this
handleTotpSubmit = async (dataModel: any) => {
if (!this.state.authenticator) {
return;
}
const data = {
...(dataModel ?? {}),
secret: this.state.authenticator.secret,
};
this.setState({loading: true});
try {
await this.api.requestPromise(this.enrollEndpoint, {method: 'POST', data});
} catch (err) {
this.handleEnrollError();
return;
}
this.handleEnrollSuccess();
};
handleSubmit: FormProps['onSubmit'] = data => {
const id = this.state.authenticator?.id;
if (id === 'totp') {
this.handleTotpSubmit(data);
return;
}
if (id === 'sms') {
this.handleSmsSubmit(data);
return;
}
};
// Handler when we successfully add a 2fa device
async handleEnrollSuccess() {
// If we're pending approval of an invite, the user will have just joined
// the organization when completing 2fa enrollment. We should reload the
// organization context in that case to assign them to the org.
if (this.pendingInvitation) {
await fetchOrganizationByMember(
this.api,
this.pendingInvitation.memberId.toString(),
{
addOrg: true,
fetchOrgDetails: true,
}
);
}
this.props.router.push('/settings/account/security/');
openRecoveryOptions({authenticatorName: this.authenticatorName});
}
// Handler when we failed to add a 2fa device
handleEnrollError() {
this.setState({loading: false});
addErrorMessage(t('Error adding %s authenticator', this.authenticatorName));
}
// Removes an authenticator
handleRemove = async () => {
const {authenticator} = this.state;
if (!authenticator || !authenticator.authId) {
return;
}
// `authenticator.authId` is NOT the same as `props.params.authId` This is
// for backwards compatibility with API endpoint
try {
await this.api.requestPromise(this.authenticatorEndpoint, {method: 'DELETE'});
} catch (err) {
addErrorMessage(t('Error removing authenticator'));
return;
}
this.props.router.push('/settings/account/security/');
addSuccessMessage(t('Authenticator has been removed'));
};
renderBody() {
const {authenticator, hasSentCode, sendingCode} = this.state;
if (!authenticator) {
return null;
}
const fields = getFields({
authenticator,
hasSentCode,
sendingCode,
onSmsReset: this.handleSmsReset,
onU2fTap: this.handleU2fTap,
});
// Attempt to extract `defaultValue` from server generated form fields
const defaultValues = fields
? fields
.filter(
field =>
typeof field !== 'function' && typeof field.defaultValue !== 'undefined'
)
.map(field => [
field.name,
typeof field !== 'function' ? field.defaultValue : '',
])
.reduce((acc, [name, value]) => {
acc[name] = value;
return acc;
}, {})
: {};
const isActive = authenticator.isEnrolled || authenticator.status === 'rotation';
return (
<Fragment>
<SettingsPageHeader
title={
<Fragment>
<span>{authenticator.name}</span>
<CircleIndicator
role="status"
aria-label={
isActive
? t('Authentication Method Active')
: t('Authentication Method Inactive')
}
enabled={isActive}
css={{marginLeft: 6}}
/>
</Fragment>
}
action={
authenticator.isEnrolled &&
authenticator.removeButton && (
<RemoveConfirm onConfirm={this.handleRemove}>
<Button priority="danger">{authenticator.removeButton}</Button>
</RemoveConfirm>
)
}
/>
<TextBlock>{authenticator.description}</TextBlock>
{authenticator.rotationWarning && authenticator.status === 'rotation' && (
<Alert type="warning" showIcon>
{authenticator.rotationWarning}
</Alert>
)}
{!!authenticator.form?.length && (
<Form
model={this.formModel}
apiMethod="POST"
apiEndpoint={this.authenticatorEndpoint}
onSubmit={this.handleSubmit}
initialData={{...defaultValues, ...authenticator}}
hideFooter
>
<JsonForm forms={[{title: 'Configuration', fields: fields ?? []}]} />
</Form>
)}
</Fragment>
);
}
}
const CodeContainer = styled(PanelItem)`
justify-content: center;
`;
const Actions = styled(PanelItem)`
justify-content: flex-end;
`;
const StyledQRCode = styled(QRCodeCanvas)`
background: white;
padding: ${space(2)};
`;
export default withSentryRouter(AccountSecurityEnroll);