feat(auth): make passkey two-factor authentication opt-in (#2857)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-20 05:06:50 +02:00
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;
+23 -11
View File
@@ -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,
};
}