mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
feat(auth): make passkey two-factor authentication opt-in (#2857)
This commit is contained in:
1 parent
3256af8d92
commit
ba7d8781cf
94 files changed
+7322
-2747
No files matched your search
@@ -64,6 +64,7 @@ interface MfaLoginResponse {
|
||||
ticket: string;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes?: boolean;
|
||||
allowed_methods?: Array<string>;
|
||||
}
|
||||
|
||||
@@ -679,11 +680,12 @@ interface SetMfaTicketPayload {
|
||||
ticket: string;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backupCodes: boolean;
|
||||
}
|
||||
|
||||
export function setMfaTicket({ticket, totp, webauthn}: SetMfaTicketPayload): void {
|
||||
export function setMfaTicket({ticket, totp, webauthn, backupCodes}: SetMfaTicketPayload): void {
|
||||
logger.debug('Setting MFA ticket');
|
||||
Authentication.handleMfaTicketSet({ticket, totp, webauthn});
|
||||
Authentication.handleMfaTicketSet({ticket, totp, webauthn, backupCodes});
|
||||
}
|
||||
|
||||
export function clearMfaTicket(): void {
|
||||
|
||||
@@ -38,6 +38,11 @@ const ENTER_THE_CODE_FROM_YOUR_AUTHENTICATOR_APP_DESCRIPTOR = msg({
|
||||
message: 'Enter the code from your authenticator app.',
|
||||
comment: 'Body text in the authentication sudo verification modal. Keep the tone plain and specific.',
|
||||
});
|
||||
const ENTER_ONE_OF_YOUR_BACKUP_CODES_DESCRIPTOR = msg({
|
||||
message: 'Enter one of your backup codes.',
|
||||
comment:
|
||||
'Body text in the authentication sudo verification modal, shown when the only code the account can enter is a backup code. Keep the tone plain and specific.',
|
||||
});
|
||||
const VERIFY_IDENTITY_FORM_DESCRIPTOR = msg({
|
||||
message: 'Verify identity form',
|
||||
comment: 'Accessible form label in the authentication sudo verification modal. Keep the tone plain and specific.',
|
||||
@@ -54,6 +59,11 @@ const MESSAGE_6_DIGIT_CODE_DESCRIPTOR = msg({
|
||||
message: '6-digit code',
|
||||
comment: 'Short label in the authentication sudo verification modal. Keep the tone plain and specific.',
|
||||
});
|
||||
const BACKUP_CODE_DESCRIPTOR = msg({
|
||||
message: 'Backup code',
|
||||
comment:
|
||||
'Label and placeholder for the code field in the authentication sudo verification modal when the only code the account can use is a backup code.',
|
||||
});
|
||||
const VERIFICATION_FAILED_DESCRIPTOR = msg({message: 'Verification failed'});
|
||||
const logger = new Logger('SudoVerificationModal');
|
||||
|
||||
@@ -75,8 +85,10 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
const autoTriggeredRef = useRef(false);
|
||||
const showPasskey = availableMethods.webauthn;
|
||||
const showTotp = availableMethods.totp;
|
||||
const backupCodeOnly = !showTotp && availableMethods.backupCodes;
|
||||
const showCode = showTotp || availableMethods.backupCodes;
|
||||
const showPassword = availableMethods.password;
|
||||
const noMethodsAvailable = !showPasskey && !showTotp && !showPassword;
|
||||
const noMethodsAvailable = !showPasskey && !showCode && !showPassword;
|
||||
useEffect(() => {
|
||||
form.reset({password: '', totp: ''});
|
||||
setWebAuthnError(null);
|
||||
@@ -85,14 +97,14 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
}, [form]);
|
||||
useEffect(() => {
|
||||
if (!verificationFailed && !rawError) return;
|
||||
const fallback: keyof FormInputs = showTotp ? 'totp' : showPassword ? 'password' : 'password';
|
||||
const fallback: keyof FormInputs = showCode ? 'totp' : 'password';
|
||||
if (rawError) {
|
||||
FormUtils.handleError(i18n, form, rawError, fallback);
|
||||
} else if (verificationFailed) {
|
||||
form.setError(fallback, {type: 'server', message: i18n._(VERIFICATION_FAILED_DESCRIPTOR)});
|
||||
}
|
||||
setWebAuthnInFlight(false);
|
||||
}, [form, verificationFailed, rawError, i18n, i18n.locale, showPassword, showTotp]);
|
||||
}, [form, verificationFailed, rawError, i18n, i18n.locale, showPassword, showCode]);
|
||||
const handleWebAuthn = async () => {
|
||||
if (webAuthnInFlight || isVerifying) return;
|
||||
setWebAuthnError(null);
|
||||
@@ -119,17 +131,17 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
};
|
||||
useEffect(() => {
|
||||
if (autoTriggeredRef.current) return;
|
||||
if (!showPasskey || showPassword || showTotp) return;
|
||||
if (!showPasskey || showPassword || showCode) return;
|
||||
if (lastUsedMfaMethod && lastUsedMfaMethod !== 'webauthn') return;
|
||||
autoTriggeredRef.current = true;
|
||||
void handleWebAuthn();
|
||||
}, [showPasskey, showPassword, showTotp]);
|
||||
}, [showPasskey, showPassword, showCode]);
|
||||
const handleClose = () => {
|
||||
SudoPrompt.reject(new DOMException('User cancelled verification', 'AbortError'));
|
||||
};
|
||||
const onSubmit = (values: FormInputs) => {
|
||||
form.clearErrors();
|
||||
if (showTotp && values.totp) {
|
||||
if (showCode && values.totp) {
|
||||
SudoPrompt.submit({mfa_method: SudoVerificationMethod.TOTP, mfa_code: values.totp});
|
||||
return;
|
||||
}
|
||||
@@ -137,17 +149,19 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
SudoPrompt.submit({password: values.password});
|
||||
return;
|
||||
}
|
||||
if (showPasskey && !showTotp && !showPassword) {
|
||||
if (showPasskey && !showCode && !showPassword) {
|
||||
void handleWebAuthn();
|
||||
return;
|
||||
}
|
||||
const target: keyof FormInputs = showTotp ? 'totp' : 'password';
|
||||
const target: keyof FormInputs = showCode ? 'totp' : 'password';
|
||||
form.setError(target, {
|
||||
type: 'manual',
|
||||
message:
|
||||
target === 'password'
|
||||
? i18n._(ENTER_YOUR_PASSWORD_DESCRIPTOR)
|
||||
: i18n._(ENTER_THE_CODE_FROM_YOUR_AUTHENTICATOR_APP_DESCRIPTOR),
|
||||
: backupCodeOnly
|
||||
? i18n._(ENTER_ONE_OF_YOUR_BACKUP_CODES_DESCRIPTOR)
|
||||
: i18n._(ENTER_THE_CODE_FROM_YOUR_AUTHENTICATOR_APP_DESCRIPTOR),
|
||||
});
|
||||
};
|
||||
return (
|
||||
@@ -186,19 +200,19 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showTotp && (
|
||||
{showCode && (
|
||||
<Input
|
||||
id="totp"
|
||||
data-flx="auth.sudo-verification-modal.input"
|
||||
{...form.register('totp')}
|
||||
label={i18n._(AUTHENTICATOR_CODE_DESCRIPTOR)}
|
||||
placeholder={i18n._(MESSAGE_6_DIGIT_CODE_DESCRIPTOR)}
|
||||
label={i18n._(backupCodeOnly ? BACKUP_CODE_DESCRIPTOR : AUTHENTICATOR_CODE_DESCRIPTOR)}
|
||||
placeholder={i18n._(backupCodeOnly ? BACKUP_CODE_DESCRIPTOR : MESSAGE_6_DIGIT_CODE_DESCRIPTOR)}
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
enterKeyHint="done"
|
||||
inputMode="numeric"
|
||||
inputMode={backupCodeOnly ? 'text' : 'numeric'}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
error={form.formState.errors.totp?.message}
|
||||
@@ -224,8 +238,8 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
onClick={handleWebAuthn}
|
||||
disabled={isVerifying}
|
||||
fitContainer
|
||||
autoFocus={!showTotp && !showPassword}
|
||||
variant={showTotp || showPassword ? 'secondary' : 'primary'}
|
||||
autoFocus={!showCode && !showPassword}
|
||||
variant={showCode || showPassword ? 'secondary' : 'primary'}
|
||||
data-flx="auth.sudo-verification-modal.button.web-authn"
|
||||
>
|
||||
<Trans>Continue with passkey</Trans>
|
||||
@@ -245,7 +259,7 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
{...form.register('password')}
|
||||
label={i18n._(PASSWORD_DESCRIPTOR)}
|
||||
type="password"
|
||||
autoFocus={!showPasskey && !showTotp}
|
||||
autoFocus={!showPasskey && !showCode}
|
||||
error={form.formState.errors.password?.message}
|
||||
/>
|
||||
)}
|
||||
@@ -263,7 +277,7 @@ const SudoVerificationModal: React.FC = observer(() => {
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
{!noMethodsAvailable && (showTotp || showPassword) && (
|
||||
{!noMethodsAvailable && (showCode || showPassword) && (
|
||||
<Button
|
||||
type="submit"
|
||||
submitting={isVerifying}
|
||||
|
||||
@@ -81,6 +81,7 @@ const ResetPasswordPage = observer(function ResetPasswordPage() {
|
||||
ticket: response.challenge.ticket,
|
||||
totp: response.challenge.totp,
|
||||
webauthn: response.challenge.webauthn,
|
||||
backupCodes: response.challenge.backupCodes,
|
||||
});
|
||||
RouterUtils.replaceWith('/login');
|
||||
return;
|
||||
|
||||
@@ -35,6 +35,14 @@ const TRY_SECURITY_KEY_INSTEAD_DESCRIPTOR = msg({
|
||||
message: 'Try security key / passkey instead',
|
||||
comment: 'Secondary MFA action that switches from code entry to passkey or security-key authentication.',
|
||||
});
|
||||
const BACKUP_CODE_DESCRIPTOR = msg({
|
||||
message: 'Backup code',
|
||||
comment: 'Label and placeholder for the MFA login field when the only code the account can use is a backup code.',
|
||||
});
|
||||
const MFA_BACKUP_CODE_INSTRUCTIONS_DESCRIPTOR = msg({
|
||||
message: "Enter one of your backup codes if you can't use your passkey.",
|
||||
comment: 'MFA code entry instructions shown when the account uses passkeys as its second factor.',
|
||||
});
|
||||
|
||||
interface MfaScreenProps {
|
||||
challenge: MfaChallenge;
|
||||
@@ -47,13 +55,15 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps)
|
||||
const {i18n} = useLingui();
|
||||
const {form, isLoading, fieldErrors, handleWebAuthn, isWebAuthnLoading, supports} = useMfaController({
|
||||
ticket: challenge.ticket,
|
||||
methods: {totp: challenge.totp, webauthn: challenge.webauthn},
|
||||
methods: {totp: challenge.totp, webauthn: challenge.webauthn, backupCodes: challenge.backupCodes},
|
||||
inviteCode,
|
||||
onLoginSuccess: onSuccess,
|
||||
});
|
||||
useAuthCardPresentation({showLogoSide: false, variant: 'compact'});
|
||||
const showCodeForm = supports.totp;
|
||||
const showCodeForm = supports.totp || supports.backupCodes;
|
||||
const showWebAuthn = supports.webauthn;
|
||||
const backupCodeOnly = !supports.totp;
|
||||
const isCodePrimary = supports.totp || !showWebAuthn;
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.flow.mfa-screen.container--2">
|
||||
<h1 className={styles.title} data-flx="auth.flow.mfa-screen.title">
|
||||
@@ -61,7 +71,9 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps)
|
||||
</h1>
|
||||
{showCodeForm && (
|
||||
<p className={styles.description} data-flx="auth.flow.mfa-screen.description">
|
||||
{i18n._(MFA_CODE_INSTRUCTIONS_DESCRIPTOR, {digitCount: MFA_CODE_DIGIT_COUNT})}
|
||||
{backupCodeOnly
|
||||
? i18n._(MFA_BACKUP_CODE_INSTRUCTIONS_DESCRIPTOR)
|
||||
: i18n._(MFA_CODE_INSTRUCTIONS_DESCRIPTOR, {digitCount: MFA_CODE_DIGIT_COUNT})}
|
||||
</p>
|
||||
)}
|
||||
{showCodeForm && (
|
||||
@@ -82,11 +94,11 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps)
|
||||
enterKeyHint="done"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
data-step-focus="true"
|
||||
autoFocus={isCodePrimary}
|
||||
data-step-focus={isCodePrimary ? 'true' : undefined}
|
||||
required
|
||||
placeholder={i18n._(AUTHENTICATION_CODE_PLACEHOLDER_DESCRIPTOR)}
|
||||
label={i18n._(CODE_DESCRIPTOR)}
|
||||
placeholder={i18n._(backupCodeOnly ? BACKUP_CODE_DESCRIPTOR : AUTHENTICATION_CODE_PLACEHOLDER_DESCRIPTOR)}
|
||||
label={i18n._(backupCodeOnly ? BACKUP_CODE_DESCRIPTOR : CODE_DESCRIPTOR)}
|
||||
value={form.getValue('code')}
|
||||
onChange={(value) => form.setValue('code', value)}
|
||||
error={form.getError('code') || fieldErrors?.get('code') || fieldErrors?.get('ticket')}
|
||||
@@ -107,14 +119,14 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps)
|
||||
<Button
|
||||
type="button"
|
||||
fitContainer
|
||||
variant={showCodeForm ? 'secondary' : 'primary'}
|
||||
variant={isCodePrimary ? 'secondary' : 'primary'}
|
||||
onClick={handleWebAuthn}
|
||||
disabled={isWebAuthnLoading}
|
||||
autoFocus={!showCodeForm}
|
||||
data-step-focus={showCodeForm ? undefined : 'true'}
|
||||
autoFocus={!isCodePrimary}
|
||||
data-step-focus={isCodePrimary ? undefined : 'true'}
|
||||
data-flx="auth.flow.mfa-screen.button.web-authn"
|
||||
>
|
||||
{i18n._(showCodeForm ? TRY_SECURITY_KEY_INSTEAD_DESCRIPTOR : SECURITY_KEY_OR_PASSKEY_DESCRIPTOR)}
|
||||
{i18n._(isCodePrimary ? TRY_SECURITY_KEY_INSTEAD_DESCRIPTOR : SECURITY_KEY_OR_PASSKEY_DESCRIPTOR)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -154,6 +154,7 @@ interface MfaControllerOptions {
|
||||
methods: {
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backupCodes: boolean;
|
||||
};
|
||||
inviteCode?: string;
|
||||
onLoginSuccess?: (payload: LoginSuccessPayload) => Promise<void> | void;
|
||||
@@ -164,7 +165,7 @@ export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}:
|
||||
const {form, isLoading, fieldErrors} = useAuthForm({
|
||||
initialValues: {code: ''},
|
||||
onSubmit: async (values) => {
|
||||
if (!methods.totp) {
|
||||
if (!methods.totp && !methods.backupCodes) {
|
||||
return;
|
||||
}
|
||||
const normalizedCode = values.code.replace(/[\s-]/g, '');
|
||||
@@ -196,7 +197,10 @@ export function useMfaController({ticket, methods, inviteCode, onLoginSuccess}:
|
||||
setIsWebAuthnLoading(false);
|
||||
}
|
||||
}, [inviteCode, onLoginSuccess, ticket]);
|
||||
const supports = useMemo(() => ({totp: methods.totp, webauthn: methods.webauthn}), [methods.totp, methods.webauthn]);
|
||||
const supports = useMemo(
|
||||
() => ({totp: methods.totp, webauthn: methods.webauthn, backupCodes: methods.backupCodes}),
|
||||
[methods.totp, methods.webauthn, methods.backupCodes],
|
||||
);
|
||||
return {
|
||||
form,
|
||||
isLoading,
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface MfaChallenge {
|
||||
sms?: boolean;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backupCodes: boolean;
|
||||
}
|
||||
|
||||
export interface IpAuthorizationChallenge {
|
||||
@@ -78,6 +79,7 @@ export async function loginWithPassword({
|
||||
sms: (response as {sms?: boolean}).sms ?? false,
|
||||
totp: response.totp,
|
||||
webauthn: response.webauthn,
|
||||
backupCodes: response.backup_codes ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -267,6 +269,7 @@ export async function resetPassword(token: string, password: string): Promise<Pa
|
||||
sms: (response as {sms?: boolean}).sms ?? false,
|
||||
totp: response.totp,
|
||||
webauthn: response.webauthn,
|
||||
backupCodes: response.backup_codes ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export type LoginState = ValueOf<typeof LoginState>;
|
||||
export interface MfaMethods {
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backupCodes: boolean;
|
||||
}
|
||||
|
||||
class Authentication {
|
||||
@@ -102,12 +103,13 @@ class Authentication {
|
||||
ticket,
|
||||
totp,
|
||||
webauthn,
|
||||
backupCodes,
|
||||
}: {
|
||||
ticket: string;
|
||||
} & MfaMethods): void {
|
||||
this.loginState = LoginState.Mfa;
|
||||
this.mfaTicket = ticket;
|
||||
this.mfaMethods = {totp, webauthn};
|
||||
this.mfaMethods = {totp, webauthn, backupCodes};
|
||||
}
|
||||
|
||||
handleMfaTicketClear(): void {
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {makeSyncedField} from '@app/features/user/state/SyncedField';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import WebAuthnCredentials from '@app/features/user/state/WebAuthnCredentials';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {MfaMethod, SudoPromptStateSchema} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/preferences_pb';
|
||||
import {makeAutoObservable, runInAction} from 'mobx';
|
||||
@@ -19,6 +20,7 @@ interface SudoErrorPayload {
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +58,7 @@ export interface AvailableMethods {
|
||||
password: boolean;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backupCodes: boolean;
|
||||
hasMfa: boolean;
|
||||
}
|
||||
|
||||
@@ -74,6 +77,7 @@ const EMPTY_METHODS: AvailableMethods = {
|
||||
password: false,
|
||||
totp: false,
|
||||
webauthn: false,
|
||||
backupCodes: false,
|
||||
hasMfa: false,
|
||||
};
|
||||
|
||||
@@ -82,12 +86,14 @@ function deriveMethodsFromCurrentUser(): AvailableMethods {
|
||||
if (!user) return {...EMPTY_METHODS};
|
||||
const types = user.authenticatorTypes;
|
||||
const totp = types?.includes(UserAuthenticatorTypes.TOTP) ?? false;
|
||||
const webauthn = types?.includes(UserAuthenticatorTypes.WEBAUTHN) ?? false;
|
||||
const hasMfa = user.mfaEnabled ?? (totp || webauthn);
|
||||
const webauthnIsSecondFactor = types?.includes(UserAuthenticatorTypes.WEBAUTHN) ?? false;
|
||||
const webauthn = WebAuthnCredentials.credentials.length > 0;
|
||||
const hasMfa = user.mfaEnabled ?? (totp || webauthnIsSecondFactor);
|
||||
return {
|
||||
password: !hasMfa,
|
||||
password: !(totp || webauthnIsSecondFactor),
|
||||
totp,
|
||||
webauthn,
|
||||
backupCodes: false,
|
||||
hasMfa,
|
||||
};
|
||||
}
|
||||
@@ -185,10 +191,12 @@ class SudoPrompt {
|
||||
const methods = payload.methods ?? {};
|
||||
const totp = methods.totp === true || (methods.totp === undefined && baseline.totp);
|
||||
const webauthn = methods.webauthn === true || (methods.webauthn === undefined && baseline.webauthn);
|
||||
const backupCodes = methods.backup_codes === true || (methods.backup_codes === undefined && baseline.backupCodes);
|
||||
this.availableMethods = {
|
||||
password: !hasMfa,
|
||||
password: baseline.password,
|
||||
totp,
|
||||
webauthn,
|
||||
backupCodes,
|
||||
hasMfa,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user