mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(auth): make passkey two-factor authentication opt-in (#2857)
This commit is contained in:
@@ -2,10 +2,10 @@
|
||||
|
||||
import {createTestAccount, createTotpSecret, generateTotpCode, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createRegistrationResponse,
|
||||
createWebAuthnDevice,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnCredentialMetadata,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
@@ -31,13 +31,15 @@ describe('Admin WebAuthn credential delete', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
test('removes the WebAuthn authenticator type when admin deletes the last credential', async () => {
|
||||
let admin = await createTestAccount(harness);
|
||||
admin = await setUserACLs(harness, admin, [
|
||||
async function createAdmin() {
|
||||
const admin = await createTestAccount(harness);
|
||||
return await setUserACLs(harness, admin, [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.USER_LOOKUP,
|
||||
AdminACLs.USER_UPDATE_MFA,
|
||||
]);
|
||||
}
|
||||
async function createPasskeyTarget(twoFactorEnabled: boolean) {
|
||||
const target = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
@@ -45,28 +47,19 @@ describe('Admin WebAuthn credential delete', () => {
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: target.password})
|
||||
.execute();
|
||||
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, target.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
if (registrationOptions.rp.id) {
|
||||
device.rpId = registrationOptions.rp.id;
|
||||
}
|
||||
await createBuilder(harness, target.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: createRegistrationResponse(device, registrationOptions, 'Admin Delete Test Passkey'),
|
||||
challenge: registrationOptions.challenge,
|
||||
name: 'Admin Delete Test Passkey',
|
||||
await registerWebAuthnCredential(
|
||||
harness,
|
||||
target.token,
|
||||
device,
|
||||
() => ({mfa_method: 'totp', mfa_code: generateTotpCode(secret)}),
|
||||
'Admin Delete Test Passkey',
|
||||
);
|
||||
if (twoFactorEnabled) {
|
||||
await setWebAuthnTwoFactor(harness, target.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const credentialsBeforeDelete = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, target.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(credentialsBeforeDelete).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
await createBuilder(harness, target.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
@@ -76,6 +69,15 @@ describe('Admin WebAuthn credential delete', () => {
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
return target;
|
||||
}
|
||||
test('removes the WebAuthn authenticator type when admin deletes the last credential of a two-factor user', async () => {
|
||||
const admin = await createAdmin();
|
||||
const target = await createPasskeyTarget(true);
|
||||
const credentialsBeforeDelete = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, target.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(credentialsBeforeDelete).toHaveLength(1);
|
||||
const userBeforeDelete = await createBuilder<AdminLookupResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users/${target.userId}`)
|
||||
.execute();
|
||||
@@ -93,4 +95,28 @@ describe('Admin WebAuthn credential delete', () => {
|
||||
.execute();
|
||||
expect(userAfterDelete.users[0]?.authenticator_types).toEqual([]);
|
||||
});
|
||||
test('leaves the authenticator types empty throughout for a user who never turned passkey two-factor on', async () => {
|
||||
const admin = await createAdmin();
|
||||
const target = await createPasskeyTarget(false);
|
||||
const credentialsBeforeDelete = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, target.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(credentialsBeforeDelete).toHaveLength(1);
|
||||
const userBeforeDelete = await createBuilder<AdminLookupResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users/${target.userId}`)
|
||||
.execute();
|
||||
expect(userBeforeDelete.users[0]?.authenticator_types).toEqual([]);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.delete(`/admin/users/${target.userId}/webauthn-credentials/${credentialsBeforeDelete[0]!.id}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
const credentialsAfterDelete = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, target.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(credentialsAfterDelete).toHaveLength(0);
|
||||
const userAfterDelete = await createBuilder<AdminLookupResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users/${target.userId}`)
|
||||
.execute();
|
||||
expect(userAfterDelete.users[0]?.authenticator_types).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthPassword from '@app/api/auth/AuthPassword';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {resolveWebAuthnSecondFactor} from '@app/api/auth/services/WebAuthnSecondFactor';
|
||||
import {
|
||||
createInviteCode,
|
||||
createIpAuthorizationTicket,
|
||||
@@ -30,6 +31,7 @@ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {IpAuthorizationRequiredError} from '@fluxer/errors/src/domains/auth/IpAuthorizationRequiredError';
|
||||
import {IpAuthorizationResendCooldownError} from '@fluxer/errors/src/domains/auth/IpAuthorizationResendCooldownError';
|
||||
import {IpAuthorizationResendLimitExceededError} from '@fluxer/errors/src/domains/auth/IpAuthorizationResendLimitExceededError';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import {RegistrationPendingApprovalError} from '@fluxer/errors/src/domains/auth/RegistrationPendingApprovalError';
|
||||
import {RegistrationRejectedError} from '@fluxer/errors/src/domains/auth/RegistrationRejectedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
@@ -72,12 +74,13 @@ interface LoginTokenResult {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface LoginMfaResult {
|
||||
export interface LoginMfaResult {
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
allowed_methods: Array<string>;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
}
|
||||
|
||||
type LoginResult = LoginTokenResult | LoginMfaResult;
|
||||
@@ -323,7 +326,8 @@ export async function login(
|
||||
}
|
||||
}
|
||||
if (hasMfa) {
|
||||
return await createMfaTicketResponse(ctx, currentUser);
|
||||
const webauthnIsSecondFactor = await resolveWebAuthnSecondFactor(ctx, currentUser);
|
||||
return await createMfaTicketResponse(ctx, currentUser, webauthnIsSecondFactor);
|
||||
}
|
||||
if (data.invite_code && inviteService) {
|
||||
try {
|
||||
@@ -387,13 +391,14 @@ export async function loginMfaTotp(
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
|
||||
const hasTotp = Boolean(user.totpSecret) && user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
|
||||
if (!hasTotp && !(await AuthMfa.hasUnconsumedBackupCodes(ctx, user.id))) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
|
||||
}
|
||||
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'code'});
|
||||
const isValid = await AuthMfa.verifyMfaCode(ctx, {
|
||||
userId: user.id,
|
||||
mfaSecret: user.totpSecret,
|
||||
mfaSecret: hasTotp ? user.totpSecret : null,
|
||||
code,
|
||||
allowBackup: true,
|
||||
});
|
||||
@@ -424,6 +429,9 @@ export async function loginMfaWebAuthn(
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
if (!(await resolveWebAuthnSecondFactor(ctx, user))) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
@@ -436,21 +444,26 @@ export async function loginMfaWebAuthn(
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
async function createMfaTicketResponse(ctx: ApiContext, user: User): Promise<LoginMfaResult> {
|
||||
const {users, cache} = ctx.services;
|
||||
export async function createMfaTicketResponse(
|
||||
ctx: ApiContext,
|
||||
user: User,
|
||||
webauthnIsSecondFactor: boolean,
|
||||
): Promise<LoginMfaResult> {
|
||||
const {cache} = ctx.services;
|
||||
const ticket = createMfaTicket(await AuthUtility.generateSecureToken(ctx));
|
||||
await cache.set(`mfa-ticket:${ticket}`, user.id.toString(), seconds('5 minutes'));
|
||||
const credentials = await users.listWebAuthnCredentials(user.id);
|
||||
const hasWebauthn = credentials.length > 0;
|
||||
const hasTotp = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
|
||||
const hasBackupCodes = await AuthMfa.hasUnconsumedBackupCodes(ctx, user.id);
|
||||
const allowedMethods: Array<string> = [];
|
||||
if (hasTotp) allowedMethods.push('totp');
|
||||
if (hasWebauthn) allowedMethods.push('webauthn');
|
||||
if (webauthnIsSecondFactor) allowedMethods.push('webauthn');
|
||||
if (hasBackupCodes) allowedMethods.push('backup_codes');
|
||||
return {
|
||||
mfa: true,
|
||||
ticket,
|
||||
allowed_methods: allowedMethods,
|
||||
totp: hasTotp,
|
||||
webauthn: hasWebauthn,
|
||||
webauthn: webauthnIsSecondFactor,
|
||||
backup_codes: hasBackupCodes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import {timingSafeEqual} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {deriveSudoMethods, userHasMfa} from '@app/api/auth/services/SudoMethods';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {deriveSudoMethods, userHasMfa, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {MfaBackupCode} from '@app/api/models/MfaBackupCode';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
@@ -48,7 +50,7 @@ interface SudoMfaVerificationResult {
|
||||
|
||||
interface VerifyMfaCodeParams {
|
||||
userId: UserID;
|
||||
mfaSecret: string;
|
||||
mfaSecret: string | null;
|
||||
code: string;
|
||||
allowBackup?: boolean;
|
||||
}
|
||||
@@ -56,9 +58,15 @@ interface VerifyMfaCodeParams {
|
||||
interface AvailableMfaMethods {
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
has_mfa: boolean;
|
||||
}
|
||||
|
||||
interface SetWebAuthnTwoFactorResult {
|
||||
user: User;
|
||||
backupCodes: Array<MfaBackupCode> | null;
|
||||
}
|
||||
|
||||
function constantTimeEquals(a: string, b: string): boolean {
|
||||
const bufferA = Buffer.from(a);
|
||||
const bufferB = Buffer.from(b);
|
||||
@@ -72,24 +80,31 @@ function normalizeBackupCode(code: string): string {
|
||||
return code.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
export async function hasUnconsumedBackupCodes(ctx: ApiContext, userId: UserID): Promise<boolean> {
|
||||
const backupCodes = await ctx.services.users.listMfaBackupCodes(userId);
|
||||
return backupCodes.some((backupCode) => !backupCode.consumed);
|
||||
}
|
||||
|
||||
export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams): Promise<boolean> {
|
||||
const {userId, mfaSecret, code, allowBackup = false} = params;
|
||||
const {users, cache, config} = ctx.services;
|
||||
try {
|
||||
const totp = new TotpGenerator(mfaSecret);
|
||||
const isValidTotp = await totp.validateTotp(code);
|
||||
if (isValidTotp) {
|
||||
if (config.dev.testModeEnabled) {
|
||||
return true;
|
||||
}
|
||||
const reuseKey = `mfa-totp:${userId}:${code}`;
|
||||
const lockToken = await cache.acquireLock(reuseKey, seconds('90 seconds'));
|
||||
if (lockToken) {
|
||||
return true;
|
||||
if (mfaSecret !== null) {
|
||||
try {
|
||||
const totp = new TotpGenerator(mfaSecret);
|
||||
const isValidTotp = await totp.validateTotp(code);
|
||||
if (isValidTotp) {
|
||||
if (config.dev.testModeEnabled) {
|
||||
return true;
|
||||
}
|
||||
const reuseKey = `mfa-totp:${userId}:${code}`;
|
||||
const lockToken = await cache.acquireLock(reuseKey, seconds('90 seconds'));
|
||||
if (lockToken) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error({userId, code: `${code.slice(0, 3)}***`, error}, 'Failed to validate TOTP code');
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error({userId, code: `${code.slice(0, 3)}***`, error}, 'Failed to validate TOTP code');
|
||||
}
|
||||
if (allowBackup) {
|
||||
const normalizedCode = normalizeBackupCode(code);
|
||||
@@ -145,8 +160,7 @@ export async function verifyWebAuthnRegistration(
|
||||
expectedChallenge: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const {users, gateway, botMfaMirror, config} = ctx.services;
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
const {users, config} = ctx.services;
|
||||
const existingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
await consumeWebAuthnChallenge(ctx, expectedChallenge, 'registration', {userId});
|
||||
if (existingCredentials.length >= 10) {
|
||||
@@ -214,13 +228,6 @@ export async function verifyWebAuthnRegistration(
|
||||
name,
|
||||
);
|
||||
}
|
||||
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
|
||||
if (!authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
|
||||
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
|
||||
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
|
||||
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
|
||||
@@ -234,15 +241,55 @@ export async function deleteWebAuthnCredential(ctx: ApiContext, userId: UserID,
|
||||
const remainingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
if (remainingCredentials.length === 0) {
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
|
||||
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
|
||||
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
|
||||
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
|
||||
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
if (user.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
const authenticatorTypes = new Set<number>(user.authenticatorTypes ?? []);
|
||||
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
|
||||
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
|
||||
if (!userHasMfa(updatedUser)) {
|
||||
await users.clearMfaBackupCodes(userId);
|
||||
}
|
||||
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
|
||||
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
}
|
||||
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
|
||||
export async function setWebAuthnTwoFactor(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
enabled: boolean,
|
||||
): Promise<SetWebAuthnTwoFactorResult> {
|
||||
const {users, gateway, botMfaMirror} = ctx.services;
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
if (enabled && credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
const authenticatorTypes = new Set<number>(user.authenticatorTypes ?? []);
|
||||
if (authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN) === enabled) {
|
||||
return {user, backupCodes: null};
|
||||
}
|
||||
if (enabled) {
|
||||
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
|
||||
} else {
|
||||
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
|
||||
}
|
||||
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
|
||||
let backupCodes: Array<MfaBackupCode> | null = null;
|
||||
if (enabled) {
|
||||
const existingBackupCodes = await users.listMfaBackupCodes(userId);
|
||||
if (existingBackupCodes.every((backupCode) => backupCode.consumed)) {
|
||||
backupCodes = await users.createMfaBackupCodes(userId, AuthUtility.generateBackupCodes(ctx));
|
||||
}
|
||||
} else if (!userHasMfa(updatedUser)) {
|
||||
await users.clearMfaBackupCodes(userId);
|
||||
}
|
||||
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
|
||||
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
return {user: updatedUser, backupCodes};
|
||||
}
|
||||
|
||||
export async function renameWebAuthnCredential(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
@@ -430,16 +477,17 @@ export async function verifySudoMfa(
|
||||
const {users} = ctx.services;
|
||||
const {userId, method, code, webauthnResponse, webauthnChallenge} = params;
|
||||
const user = await users.findUnique(userId);
|
||||
const hasMfa =
|
||||
(user?.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false) ||
|
||||
(user?.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false);
|
||||
if (!user || !hasMfa) {
|
||||
if (!user) {
|
||||
return {success: false, error: 'MFA not enabled'};
|
||||
}
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
const hasPasskeyCredentials = credentials.length > 0;
|
||||
if (!userHasSudoCapability(user, hasPasskeyCredentials)) {
|
||||
return {success: false, error: 'MFA not enabled'};
|
||||
}
|
||||
switch (method) {
|
||||
case 'totp': {
|
||||
if (!code) return {success: false, error: 'TOTP code is required'};
|
||||
if (!user.totpSecret) return {success: false, error: 'TOTP is not enabled'};
|
||||
await consumeSudoMfaAttempt(ctx, userId);
|
||||
const isValid = await verifyMfaCode(ctx, {userId, mfaSecret: user.totpSecret, code, allowBackup: true});
|
||||
if (isValid) {
|
||||
@@ -451,7 +499,7 @@ export async function verifySudoMfa(
|
||||
if (!webauthnResponse || !webauthnChallenge) {
|
||||
return {success: false, error: 'WebAuthn response and challenge are required'};
|
||||
}
|
||||
if (!user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
if (!hasPasskeyCredentials) {
|
||||
return {success: false, error: 'WebAuthn is not enabled'};
|
||||
}
|
||||
try {
|
||||
@@ -467,15 +515,19 @@ export async function verifySudoMfa(
|
||||
}
|
||||
|
||||
export async function getAvailableMfaMethods(ctx: ApiContext, userId: UserID): Promise<AvailableMfaMethods> {
|
||||
const user = await ctx.services.users.findUnique(userId);
|
||||
const {users} = ctx.services;
|
||||
const user = await users.findUnique(userId);
|
||||
if (!user) {
|
||||
return {totp: false, webauthn: false, has_mfa: false};
|
||||
return {totp: false, webauthn: false, backup_codes: false, has_mfa: false};
|
||||
}
|
||||
const methods = deriveSudoMethods(user);
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
const hasPasskeyCredentials = credentials.length > 0;
|
||||
const methods = deriveSudoMethods(user, hasPasskeyCredentials, await hasUnconsumedBackupCodes(ctx, userId));
|
||||
return {
|
||||
totp: methods.totp,
|
||||
webauthn: methods.webauthn,
|
||||
has_mfa: userHasMfa(user),
|
||||
backup_codes: methods.backup_codes,
|
||||
has_mfa: userHasSudoCapability(user, hasPasskeyCredentials),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {createMfaTicketResponse, type LoginMfaResult} from '@app/api/auth/AuthLogin';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {createMfaTicket, createPasswordResetToken} from '@app/api/BrandedTypes';
|
||||
import {resolveWebAuthnSecondFactor} from '@app/api/auth/services/WebAuthnSecondFactor';
|
||||
import {createPasswordResetToken} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
||||
import * as FetchUtils from '@app/api/utils/FetchUtils';
|
||||
import {hashPassword as hashPasswordUtil, verifyPassword as verifyPasswordUtil} from '@app/api/utils/PasswordUtils';
|
||||
@@ -19,7 +21,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import type {ForgotPasswordRequest, ResetPasswordRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const PWNED_PASSWORDS_TIMEOUT_MS = ms('5 seconds');
|
||||
const PWNED_PASSWORD_CACHE_MAX_PREFIXES = 128;
|
||||
@@ -91,13 +93,7 @@ type ResetPasswordResult =
|
||||
user_id: string;
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
allowed_methods: Array<string>;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
};
|
||||
| LoginMfaResult;
|
||||
|
||||
const pwnedPasswordCache = new PwnedPasswordCache(PWNED_PASSWORD_CACHE_MAX_PREFIXES, ms('1 hour'));
|
||||
|
||||
@@ -267,22 +263,26 @@ export async function resetPassword(
|
||||
if (await isPasswordPwned(ctx, data.password)) {
|
||||
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
|
||||
}
|
||||
const webauthnIsSecondFactor = await resolveWebAuthnSecondFactor(ctx, user);
|
||||
const hasMfa = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) || webauthnIsSecondFactor;
|
||||
const newPasswordHash = await hashPassword(ctx, data.password);
|
||||
const updatedUser = await users.patchUpsert(
|
||||
user.id,
|
||||
{
|
||||
password_hash: newPasswordHash,
|
||||
password_last_changed_at: new Date(),
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
const updates: Partial<UserRow> = {
|
||||
password_hash: newPasswordHash,
|
||||
password_last_changed_at: new Date(),
|
||||
};
|
||||
if (webauthnIsSecondFactor && !user.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
const authenticatorTypes = new Set<number>(user.authenticatorTypes);
|
||||
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
|
||||
updates.authenticator_types = authenticatorTypes;
|
||||
}
|
||||
const updatedUser = await users.patchUpsert(user.id, updates, user.toRow());
|
||||
if (updates.authenticator_types) {
|
||||
await ctx.services.botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
await AuthSession.terminateAllUserSessions(ctx, user.id);
|
||||
await users.deletePasswordResetToken(data.token);
|
||||
const hasMfa =
|
||||
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
|
||||
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN);
|
||||
if (hasMfa) {
|
||||
return await createMfaTicketResponse(ctx, updatedUser);
|
||||
return await createMfaTicketResponse(ctx, updatedUser, webauthnIsSecondFactor);
|
||||
}
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user: updatedUser,
|
||||
@@ -290,31 +290,3 @@ export async function resetPassword(
|
||||
});
|
||||
return {user_id: updatedUser.id.toString(), token};
|
||||
}
|
||||
|
||||
async function createMfaTicketResponse(
|
||||
ctx: ApiContext,
|
||||
user: User,
|
||||
): Promise<{
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
allowed_methods: Array<string>;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
}> {
|
||||
const {users, cache} = ctx.services;
|
||||
const ticket = createMfaTicket(await AuthUtility.generateSecureToken(ctx));
|
||||
await cache.set(`mfa-ticket:${ticket}`, user.id.toString(), seconds('5 minutes'));
|
||||
const credentials = await users.listWebAuthnCredentials(user.id);
|
||||
const hasWebauthn = credentials.length > 0;
|
||||
const hasTotp = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
|
||||
const allowedMethods: Array<string> = [];
|
||||
if (hasTotp) allowedMethods.push('totp');
|
||||
if (hasWebauthn) allowedMethods.push('webauthn');
|
||||
return {
|
||||
mfa: true,
|
||||
ticket: ticket,
|
||||
allowed_methods: allowedMethods,
|
||||
totp: hasTotp,
|
||||
webauthn: hasWebauthn,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -417,6 +417,7 @@ export class AuthRequestService {
|
||||
...result,
|
||||
totp: allowedMethods.has('totp'),
|
||||
webauthn: allowedMethods.has('webauthn'),
|
||||
backup_codes: allowedMethods.has('backup_codes'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {SudoModeMethods} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';
|
||||
|
||||
interface SudoMethodsUser {
|
||||
totpSecret?: string | null;
|
||||
authenticatorTypes?: Set<number> | null;
|
||||
}
|
||||
|
||||
function hasTotpEnrolled(user: SudoMethodsUser): boolean {
|
||||
return (user.totpSecret ?? null) !== null && (user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false);
|
||||
}
|
||||
|
||||
export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boolean {
|
||||
return (
|
||||
(user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false) ||
|
||||
@@ -10,13 +19,18 @@ export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boo
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveSudoMethods(user: {
|
||||
totpSecret?: string | null;
|
||||
authenticatorTypes?: Set<number> | null;
|
||||
}): SudoModeMethods {
|
||||
const authenticatorTypes = user.authenticatorTypes ?? null;
|
||||
export function userHasSudoCapability(user: SudoMethodsUser, hasPasskeyCredentials: boolean): boolean {
|
||||
return hasTotpEnrolled(user) || hasPasskeyCredentials;
|
||||
}
|
||||
|
||||
export function deriveSudoMethods(
|
||||
user: SudoMethodsUser,
|
||||
hasPasskeyCredentials: boolean,
|
||||
hasBackupCodes: boolean,
|
||||
): SudoModeMethods {
|
||||
return {
|
||||
totp: (user.totpSecret ?? null) !== null && (authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false),
|
||||
webauthn: authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false,
|
||||
totp: hasTotpEnrolled(user),
|
||||
webauthn: hasPasskeyCredentials,
|
||||
backup_codes: hasBackupCodes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthPassword from '@app/api/auth/AuthPassword';
|
||||
import {deriveSudoMethods, userHasMfa} from '@app/api/auth/services/SudoMethods';
|
||||
import {deriveSudoMethods, userHasMfa, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
|
||||
import {getSudoModeService} from '@app/api/auth/services/SudoModeService';
|
||||
import {SUDO_MODE_HEADER} from '@app/api/middleware/SudoModeMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
@@ -26,8 +26,9 @@ type SudoVerificationMethod = 'password' | 'mfa' | 'sudo_token';
|
||||
export function hasNoVerifiableCredential(
|
||||
user: {passwordHash: string | null; isBot: boolean},
|
||||
hasMfa: boolean,
|
||||
hasPasskeyCredentials: boolean,
|
||||
): boolean {
|
||||
if (user.isBot || hasMfa) {
|
||||
if (user.isBot || hasMfa || hasPasskeyCredentials) {
|
||||
return false;
|
||||
}
|
||||
return user.passwordHash === null;
|
||||
@@ -52,18 +53,22 @@ async function verifySudoMode(
|
||||
if (user.isBot) {
|
||||
return {verified: true, method: 'sudo_token'};
|
||||
}
|
||||
const apiContext = ctx.get('apiContext');
|
||||
const credentials = await apiContext.services.users.listWebAuthnCredentials(user.id);
|
||||
const hasPasskeyCredentials = credentials.length > 0;
|
||||
const hasMfa = userHasMfa(user);
|
||||
const issueSudoToken = options.issueSudoToken ?? hasMfa;
|
||||
if (hasMfa && ctx.get('sudoModeValid')) {
|
||||
const hasSudoCapability = userHasSudoCapability(user, hasPasskeyCredentials);
|
||||
const issueSudoToken = options.issueSudoToken ?? hasSudoCapability;
|
||||
if (hasSudoCapability && ctx.get('sudoModeValid')) {
|
||||
const sudoToken = ctx.get('sudoModeToken') ?? ctx.req.header(SUDO_MODE_HEADER) ?? undefined;
|
||||
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? sudoToken : undefined};
|
||||
}
|
||||
const incomingToken = ctx.req.header(SUDO_MODE_HEADER);
|
||||
if (!hasMfa && incomingToken && ctx.get('sudoModeValid')) {
|
||||
if (!hasSudoCapability && incomingToken && ctx.get('sudoModeValid')) {
|
||||
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? incomingToken : undefined};
|
||||
}
|
||||
if (hasMfa && body.mfa_method) {
|
||||
const result = await AuthMfa.verifySudoMfa(ctx.get('apiContext'), {
|
||||
if (hasSudoCapability && body.mfa_method) {
|
||||
const result = await AuthMfa.verifySudoMfa(apiContext, {
|
||||
userId: user.id,
|
||||
method: body.mfa_method,
|
||||
code: body.mfa_code,
|
||||
@@ -77,14 +82,14 @@ async function verifySudoMode(
|
||||
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
|
||||
return {verified: true, sudoToken, method: 'mfa'};
|
||||
}
|
||||
if (hasNoVerifiableCredential(user, hasMfa)) {
|
||||
if (hasNoVerifiableCredential(user, hasMfa, hasPasskeyCredentials)) {
|
||||
return {verified: true, method: 'password'};
|
||||
}
|
||||
if (body.password && !hasMfa) {
|
||||
if (!user.passwordHash) {
|
||||
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
|
||||
}
|
||||
const passwordValid = await AuthPassword.verifyPassword(ctx.get('apiContext'), {
|
||||
const passwordValid = await AuthPassword.verifyPassword(apiContext, {
|
||||
password: body.password,
|
||||
passwordHash: user.passwordHash,
|
||||
});
|
||||
@@ -93,7 +98,8 @@ async function verifySudoMode(
|
||||
}
|
||||
return {verified: true, method: 'password'};
|
||||
}
|
||||
throw new SudoModeRequiredError(hasMfa, deriveSudoMethods(user));
|
||||
const hasBackupCodes = await AuthMfa.hasUnconsumedBackupCodes(apiContext, user.id);
|
||||
throw new SudoModeRequiredError(hasSudoCapability, deriveSudoMethods(user, hasPasskeyCredentials, hasBackupCodes));
|
||||
}
|
||||
|
||||
function setSudoTokenHeader(
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
|
||||
interface WebAuthnSecondFactorUser {
|
||||
passwordHash: string | null;
|
||||
authenticatorTypes?: Set<number> | null;
|
||||
}
|
||||
|
||||
export function webAuthnIsSecondFactor(user: WebAuthnSecondFactorUser, hasPasskeyCredentials: boolean): boolean {
|
||||
return (
|
||||
(user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false) ||
|
||||
(user.passwordHash === null && hasPasskeyCredentials)
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveWebAuthnSecondFactor(ctx: ApiContext, user: User): Promise<boolean> {
|
||||
const credentials = await ctx.services.users.listWebAuthnCredentials(user.id);
|
||||
return webAuthnIsSecondFactor(user, credentials.length > 0);
|
||||
}
|
||||
@@ -22,20 +22,10 @@ export interface LoginMfaResponse {
|
||||
allowed_methods: Array<string>;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
}
|
||||
|
||||
type LoginResponse =
|
||||
| {
|
||||
user_id: string;
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
allowed_methods: Array<string>;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
};
|
||||
type LoginResponse = LoginSuccessResponse | LoginMfaResponse;
|
||||
|
||||
export interface UserMeResponse {
|
||||
id: string;
|
||||
|
||||
@@ -627,6 +627,7 @@ describe('Email change flow', () => {
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}>(harness, mfaAccount.token)
|
||||
.patch('/users/@me')
|
||||
@@ -634,13 +635,14 @@ describe('Email change flow', () => {
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(noSudoResp.has_mfa).toBe(true);
|
||||
expect(noSudoResp.methods).toEqual({totp: true, webauthn: false});
|
||||
expect(noSudoResp.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
|
||||
const passwordOnlyResp = await createBuilder<{
|
||||
code: string;
|
||||
has_mfa?: boolean;
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}>(harness, mfaAccount.token)
|
||||
.patch('/users/@me')
|
||||
@@ -648,7 +650,7 @@ describe('Email change flow', () => {
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(passwordOnlyResp.has_mfa).toBe(true);
|
||||
expect(passwordOnlyResp.methods).toEqual({totp: true, webauthn: false});
|
||||
expect(passwordOnlyResp.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
|
||||
const updated = await createBuilder<UserPrivateResponse>(harness, mfaAccount.token)
|
||||
.patch('/users/@me')
|
||||
.body({
|
||||
@@ -712,6 +714,7 @@ describe('Email change flow', () => {
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}>(harness, mfaAccount.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
@@ -719,13 +722,14 @@ describe('Email change flow', () => {
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(noSudoResp.has_mfa).toBe(true);
|
||||
expect(noSudoResp.methods).toEqual({totp: true, webauthn: false});
|
||||
expect(noSudoResp.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
|
||||
const passwordOnlyResp = await createBuilder<{
|
||||
code: string;
|
||||
has_mfa?: boolean;
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}>(harness, mfaAccount.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
@@ -733,7 +737,7 @@ describe('Email change flow', () => {
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(passwordOnlyResp.has_mfa).toBe(true);
|
||||
expect(passwordOnlyResp.methods).toEqual({totp: true, webauthn: false});
|
||||
expect(passwordOnlyResp.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
|
||||
const updated = await createBuilder<UserPrivateResponse>(harness, mfaAccount.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
.body({
|
||||
@@ -776,6 +780,7 @@ describe('Email change flow', () => {
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
@@ -783,7 +788,7 @@ describe('Email change flow', () => {
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(noSudoResp.has_mfa).toBe(false);
|
||||
expect(noSudoResp.methods).toEqual({totp: false, webauthn: false});
|
||||
expect(noSudoResp.methods).toEqual({totp: false, webauthn: false, backup_codes: false});
|
||||
const updated = await createBuilder<UserPrivateResponse>(harness, account.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
.body({email_token: emailToken, password: account.password})
|
||||
@@ -887,6 +892,7 @@ describe('Email change flow', () => {
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}>(harness, mfaAccount.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
@@ -894,7 +900,7 @@ describe('Email change flow', () => {
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(discovery.has_mfa).toBe(true);
|
||||
expect(discovery.methods).toEqual({totp: true, webauthn: false});
|
||||
expect(discovery.methods).toEqual({totp: true, webauthn: false, backup_codes: true});
|
||||
const applied = await createBuilder<UserPrivateResponse>(harness, mfaAccount.token)
|
||||
.post('/users/@me/email-change/apply')
|
||||
.body({
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createAuthenticationResponse,
|
||||
createRegistrationResponse,
|
||||
createWebAuthnDevice,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
type WebAuthnRegistrationOptions,
|
||||
@@ -37,6 +38,7 @@ interface LoginMfaResponse {
|
||||
interface SudoMfaMethodsResponse {
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
has_mfa: boolean;
|
||||
}
|
||||
|
||||
@@ -46,6 +48,7 @@ interface SudoModeRequiredResponse {
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +76,7 @@ async function loginWithTotp(harness: ApiTestHarness, account: TestAccount, secr
|
||||
async function setupWebAuthnOnlyUser(
|
||||
harness: ApiTestHarness,
|
||||
account: TestAccount,
|
||||
twoFactorEnabled: boolean,
|
||||
): Promise<{
|
||||
account: TestAccount;
|
||||
device: WebAuthnDevice;
|
||||
@@ -124,6 +128,12 @@ async function setupWebAuthnOnlyUser(
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
if (twoFactorEnabled) {
|
||||
await setWebAuthnTwoFactor(harness, updatedAccount.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[5]!.code,
|
||||
});
|
||||
}
|
||||
await createBuilder(harness, updatedAccount.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
@@ -162,9 +172,9 @@ describe('MFA Consistency Tests', () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
describe('WebAuthn sudo verification flow', () => {
|
||||
test('WebAuthn user can complete sudo verification with passkey', async () => {
|
||||
test('WebAuthn user with two-factor on can complete sudo verification with passkey', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount, device} = await setupWebAuthnOnlyUser(harness, account);
|
||||
const {account: webauthnAccount, device} = await setupWebAuthnOnlyUser(harness, account, true);
|
||||
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, webauthnAccount.token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null)
|
||||
@@ -180,9 +190,27 @@ describe('MFA Consistency Tests', () => {
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
test('WebAuthn-only user cannot use password for sudo verification', async () => {
|
||||
test('WebAuthn user with two-factor off can complete sudo verification with passkey', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account);
|
||||
const {account: webauthnAccount, device} = await setupWebAuthnOnlyUser(harness, account, false);
|
||||
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, webauthnAccount.token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
const sudoAssertion = createAuthenticationResponse(device, sudoOptions);
|
||||
await createBuilder(harness, webauthnAccount.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: sudoAssertion,
|
||||
webauthn_challenge: sudoOptions.challenge,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
test('WebAuthn-only user with two-factor on cannot use password for sudo verification', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account, true);
|
||||
const errorResp = await createBuilder<{
|
||||
code: string;
|
||||
}>(harness, webauthnAccount.token)
|
||||
@@ -194,6 +222,17 @@ describe('MFA Consistency Tests', () => {
|
||||
.execute();
|
||||
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
|
||||
});
|
||||
test('WebAuthn-only user with two-factor off can use password for sudo verification', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account, false);
|
||||
await createBuilder(harness, webauthnAccount.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
password: account.password,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('Password-only sudo flow for non-MFA users', () => {
|
||||
test('Non-MFA user can use password for sudo verification', async () => {
|
||||
@@ -323,14 +362,37 @@ describe('MFA Consistency Tests', () => {
|
||||
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, account.token)
|
||||
.get('/users/@me/sudo/mfa-methods')
|
||||
.execute();
|
||||
expect(methods).toEqual({totp: false, webauthn: false, has_mfa: false});
|
||||
expect(methods).toEqual({totp: false, webauthn: false, backup_codes: false, has_mfa: false});
|
||||
const errorResp = await createBuilder<SudoModeRequiredResponse>(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({})
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(errorResp.has_mfa).toBe(methods.has_mfa);
|
||||
expect(errorResp.methods).toEqual({totp: methods.totp, webauthn: methods.webauthn});
|
||||
expect(errorResp.methods).toEqual({
|
||||
totp: methods.totp,
|
||||
webauthn: methods.webauthn,
|
||||
backup_codes: methods.backup_codes,
|
||||
});
|
||||
});
|
||||
test('mfa-methods reports webauthn for a passkey user with two-factor off', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {account: webauthnAccount} = await setupWebAuthnOnlyUser(harness, account, false);
|
||||
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, webauthnAccount.token)
|
||||
.get('/users/@me/sudo/mfa-methods')
|
||||
.execute();
|
||||
expect(methods).toEqual({totp: false, webauthn: true, backup_codes: false, has_mfa: true});
|
||||
const errorResp = await createBuilder<SudoModeRequiredResponse>(harness, webauthnAccount.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({})
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(errorResp.has_mfa).toBe(methods.has_mfa);
|
||||
expect(errorResp.methods).toEqual({
|
||||
totp: methods.totp,
|
||||
webauthn: methods.webauthn,
|
||||
backup_codes: methods.backup_codes,
|
||||
});
|
||||
});
|
||||
test('mfa-methods agrees with the SUDO_MODE_REQUIRED body for an enrolled TOTP user', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
@@ -347,14 +409,18 @@ describe('MFA Consistency Tests', () => {
|
||||
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, loggedIn.token)
|
||||
.get('/users/@me/sudo/mfa-methods')
|
||||
.execute();
|
||||
expect(methods).toEqual({totp: true, webauthn: false, has_mfa: true});
|
||||
expect(methods).toEqual({totp: true, webauthn: false, backup_codes: true, has_mfa: true});
|
||||
const errorResp = await createBuilder<SudoModeRequiredResponse>(harness, loggedIn.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({})
|
||||
.expect(403, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(errorResp.has_mfa).toBe(methods.has_mfa);
|
||||
expect(errorResp.methods).toEqual({totp: methods.totp, webauthn: methods.webauthn});
|
||||
expect(errorResp.methods).toEqual({
|
||||
totp: methods.totp,
|
||||
webauthn: methods.webauthn,
|
||||
backup_codes: methods.backup_codes,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('MFA requirement propagates to sensitive operations', () => {
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
seedMfaTicket,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createRegistrationResponse,
|
||||
createWebAuthnDevice,
|
||||
type WebAuthnRegistrationOptions,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
@@ -41,7 +41,7 @@ describe('Auth MFA TOTP without secret', () => {
|
||||
.execute();
|
||||
expect(login.code).toBe('INVALID_FORM_BODY');
|
||||
});
|
||||
it('rejects TOTP login when only WebAuthn is enabled', async () => {
|
||||
it('rejects TOTP login when passkey two-factor is on and no TOTP secret remains', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
@@ -53,25 +53,14 @@ describe('Auth MFA TOTP without secret', () => {
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
if (regOptions.rp.id) {
|
||||
device.rpId = regOptions.rp.id;
|
||||
}
|
||||
const registrationResponse = createRegistrationResponse(device, regOptions, 'Test Passkey');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: regOptions.challenge,
|
||||
name: 'Test Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
@@ -105,4 +94,39 @@ describe('Auth MFA TOTP without secret', () => {
|
||||
.execute();
|
||||
expect(bypassAttempt.code).toBe('INVALID_FORM_BODY');
|
||||
});
|
||||
it('issues a session token when passkey two-factor is off and no TOTP secret remains', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
const totpData = await createBuilder<{
|
||||
backup_codes: Array<{
|
||||
code: string;
|
||||
}>;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: totpData.backup_codes[0]!.code,
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const login = await createBuilderWithoutAuth<{
|
||||
mfa?: true;
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.execute();
|
||||
expect(login.mfa).toBeUndefined();
|
||||
expect(login.token).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,13 +4,25 @@ import {
|
||||
clearTestEmails,
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createUniqueEmail,
|
||||
findLastTestEmail,
|
||||
type LoginSuccessResponse,
|
||||
listTestEmails,
|
||||
type TestAccount,
|
||||
type TestEmailRecord,
|
||||
totpCodeNow,
|
||||
unclaimAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createWebAuthnDevice,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface MfaRequiredResponse {
|
||||
@@ -19,6 +31,7 @@ interface MfaRequiredResponse {
|
||||
allowed_methods: Array<string>;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
}
|
||||
|
||||
async function waitForEmail(harness: ApiTestHarness, type: string, recipient: string): Promise<TestEmailRecord> {
|
||||
@@ -34,6 +47,34 @@ async function waitForEmail(harness: ApiTestHarness, type: string, recipient: st
|
||||
throw new Error(`Email not found: type=${type}, recipient=${recipient}`);
|
||||
}
|
||||
|
||||
async function claimEmailWithoutPassword(harness: ApiTestHarness, account: TestAccount): Promise<TestAccount> {
|
||||
await unclaimAccount(harness, account.userId);
|
||||
const start = await createBuilder<{ticket: string; original_proof?: string}>(harness, account.token)
|
||||
.post('/users/@me/email-change/start')
|
||||
.body({})
|
||||
.execute();
|
||||
const email = createUniqueEmail('passwordless-reset');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/email-change/request-new')
|
||||
.body({ticket: start.ticket, new_email: email, original_proof: start.original_proof})
|
||||
.execute();
|
||||
const newEmail = await waitForEmail(harness, 'email_change_new', email);
|
||||
const verify = await createBuilder<{email_token: string}>(harness, account.token)
|
||||
.post('/users/@me/email-change/verify-new')
|
||||
.body({ticket: start.ticket, code: newEmail.metadata['code'], original_proof: start.original_proof})
|
||||
.execute();
|
||||
await createBuilder(harness, account.token).patch('/users/@me').body({email_token: verify.email_token}).execute();
|
||||
return {...account, email};
|
||||
}
|
||||
|
||||
async function requestPasswordReset(harness: ApiTestHarness, email: string): Promise<string> {
|
||||
await clearTestEmails(harness);
|
||||
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email}).expect(204).execute();
|
||||
const mail = await waitForEmail(harness, 'password_reset', email);
|
||||
const token = mail.metadata['token'];
|
||||
expect(token).toBeDefined();
|
||||
return token!;
|
||||
}
|
||||
describe('Auth reset password requires MFA', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
@@ -66,7 +107,8 @@ describe('Auth reset password requires MFA', () => {
|
||||
expect(resetResp.ticket).toBeDefined();
|
||||
expect(resetResp.totp).toBe(true);
|
||||
expect(resetResp.webauthn).toBe(false);
|
||||
expect(resetResp.allowed_methods).toEqual(['totp']);
|
||||
expect(resetResp.backup_codes).toBe(true);
|
||||
expect(resetResp.allowed_methods).toEqual(['totp', 'backup_codes']);
|
||||
const mfaResp = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
@@ -86,4 +128,112 @@ describe('Auth reset password requires MFA', () => {
|
||||
expect(login.totp).toBe(true);
|
||||
expect(login.webauthn).toBe(false);
|
||||
});
|
||||
it('returns a session after password reset for a passkey user who left two-factor off', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await clearTestEmails(harness);
|
||||
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email: account.email}).expect(204).execute();
|
||||
const email = await waitForEmail(harness, 'password_reset', account.email);
|
||||
const token = email.metadata['token'];
|
||||
expect(token).toBeDefined();
|
||||
const resetResp = await createBuilderWithoutAuth<LoginSuccessResponse | MfaRequiredResponse>(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token, password: 'new-strong-password-123'})
|
||||
.execute();
|
||||
expect('mfa' in resetResp).toBe(false);
|
||||
expect((resetResp as LoginSuccessResponse).token).toBeTruthy();
|
||||
});
|
||||
it('returns an MFA ticket after password reset for a passkey user who turned two-factor on', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
await clearTestEmails(harness);
|
||||
await createBuilderWithoutAuth(harness).post('/auth/forgot').body({email: account.email}).expect(204).execute();
|
||||
const email = await waitForEmail(harness, 'password_reset', account.email);
|
||||
const token = email.metadata['token'];
|
||||
expect(token).toBeDefined();
|
||||
const resetResp = await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token, password: 'new-strong-password-123'})
|
||||
.execute();
|
||||
expect(resetResp.mfa).toBe(true);
|
||||
expect(resetResp.totp).toBe(false);
|
||||
expect(resetResp.webauthn).toBe(true);
|
||||
expect(resetResp.allowed_methods).toContain('webauthn');
|
||||
const mfaOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/login/mfa/webauthn/authentication-options')
|
||||
.body({ticket: resetResp.ticket})
|
||||
.execute();
|
||||
if (mfaOptions.rpId) {
|
||||
device.rpId = mfaOptions.rpId;
|
||||
}
|
||||
const mfaResp = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/webauthn')
|
||||
.body({
|
||||
response: createAuthenticationResponse(device, mfaOptions),
|
||||
challenge: mfaOptions.challenge,
|
||||
ticket: resetResp.ticket,
|
||||
})
|
||||
.execute();
|
||||
expect(mfaResp.token).toBeDefined();
|
||||
});
|
||||
it('returns an MFA ticket after password reset for an account with no password that holds a passkey', async () => {
|
||||
const base = await createTestAccount(harness);
|
||||
const account = await claimEmailWithoutPassword(harness, base);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({}));
|
||||
const token = await requestPasswordReset(harness, account.email);
|
||||
const resetResp = await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token, password: 'new-strong-password-123'})
|
||||
.execute();
|
||||
expect(resetResp.mfa).toBe(true);
|
||||
expect(resetResp.totp).toBe(false);
|
||||
expect(resetResp.webauthn).toBe(true);
|
||||
expect(resetResp.allowed_methods).toContain('webauthn');
|
||||
const mfaOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/login/mfa/webauthn/authentication-options')
|
||||
.body({ticket: resetResp.ticket})
|
||||
.execute();
|
||||
if (mfaOptions.rpId) {
|
||||
device.rpId = mfaOptions.rpId;
|
||||
}
|
||||
const mfaResp = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
|
||||
.post('/auth/login/mfa/webauthn')
|
||||
.body({
|
||||
response: createAuthenticationResponse(device, mfaOptions),
|
||||
challenge: mfaOptions.challenge,
|
||||
ticket: resetResp.ticket,
|
||||
})
|
||||
.execute();
|
||||
expect(mfaResp.token).toBeTruthy();
|
||||
const me = await createBuilder<{id: string; authenticator_types: Array<number>}>(harness, mfaResp.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.id).toBe(account.userId);
|
||||
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
});
|
||||
it('keeps demanding the passkey on a second password reset for an account that started with no password', async () => {
|
||||
const base = await createTestAccount(harness);
|
||||
const account = await claimEmailWithoutPassword(harness, base);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({}));
|
||||
const firstToken = await requestPasswordReset(harness, account.email);
|
||||
await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token: firstToken, password: 'new-strong-password-123'})
|
||||
.execute();
|
||||
const secondToken = await requestPasswordReset(harness, account.email);
|
||||
const secondReset = await createBuilderWithoutAuth<MfaRequiredResponse>(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token: secondToken, password: 'another-strong-password-456'})
|
||||
.execute();
|
||||
expect(secondReset.mfa).toBe(true);
|
||||
expect(secondReset.webauthn).toBe(true);
|
||||
expect(secondReset.allowed_methods).toContain('webauthn');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, unclaimAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createSudoWebAuthnBody,
|
||||
createWebAuthnDevice,
|
||||
registerWebAuthnCredential,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface SudoMfaMethodsResponse {
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
has_mfa: boolean;
|
||||
}
|
||||
|
||||
describe('Sudo mode for passwordless accounts holding a passkey', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('still waves through a passwordless account that holds no credential at all', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await unclaimAccount(harness, account.userId);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
it('challenges a passwordless account that holds a passkey it never made a second factor', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await unclaimAccount(harness, account.userId);
|
||||
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, account.token)
|
||||
.get('/users/@me/sudo/mfa-methods')
|
||||
.execute();
|
||||
expect(methods).toEqual({totp: false, webauthn: true, backup_codes: false, has_mfa: true});
|
||||
const errorResp = await createBuilder<{
|
||||
code: string;
|
||||
}>(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
|
||||
});
|
||||
it('lets the passwordless passkey holder clear the challenge with an assertion', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await unclaimAccount(harness, account.userId);
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body(sudoBody)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {userHasMfa} from '@app/api/auth/services/SudoMethods';
|
||||
import {userHasMfa, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
|
||||
import {hasNoVerifiableCredential} from '@app/api/auth/services/SudoVerificationService';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {EMPTY_USER_ROW, type UserRow} from '@app/api/database/types/UserTypes';
|
||||
@@ -25,17 +25,17 @@ describe('sudo verification credential capability', () => {
|
||||
it('lets an SSO provisioned account without a password satisfy sudo mode', () => {
|
||||
const user = createUser({password_hash: null, traits: new Set<string>(['sso'])});
|
||||
expect(user.isUnclaimedAccount()).toBe(false);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(true);
|
||||
});
|
||||
|
||||
it('still lets an unclaimed account satisfy sudo mode', () => {
|
||||
const user = createUser({password_hash: null});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(true);
|
||||
});
|
||||
|
||||
it('still requires a password from accounts that have one', () => {
|
||||
const user = createUser({password_hash: 'hash', traits: new Set<string>(['sso'])});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(false);
|
||||
});
|
||||
|
||||
it('still requires MFA from an SSO account that enrolled a second factor', () => {
|
||||
@@ -45,11 +45,36 @@ describe('sudo verification credential capability', () => {
|
||||
authenticator_types: new Set<number>([UserAuthenticatorTypes.TOTP]),
|
||||
});
|
||||
expect(userHasMfa(user)).toBe(true);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(false);
|
||||
});
|
||||
|
||||
it('never applies to bots', () => {
|
||||
const user = createUser({password_hash: null, bot: true});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), false)).toBe(false);
|
||||
});
|
||||
|
||||
it('still requires MFA from a passwordless account holding a passkey it never made a second factor', () => {
|
||||
const user = createUser({password_hash: null, traits: new Set<string>(['sso'])});
|
||||
expect(userHasMfa(user)).toBe(false);
|
||||
expect(userHasSudoCapability(user, true)).toBe(true);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), true)).toBe(false);
|
||||
});
|
||||
|
||||
it('still requires MFA from an unclaimed account holding a passkey', () => {
|
||||
const user = createUser({password_hash: null});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user), true)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports no sudo capability for an account with a TOTP secret that was never enrolled', () => {
|
||||
const user = createUser({totp_secret: 'JBSWY3DPEHPK3PXP'});
|
||||
expect(userHasSudoCapability(user, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports sudo capability from an enrolled TOTP secret without any passkey', () => {
|
||||
const user = createUser({
|
||||
totp_secret: 'JBSWY3DPEHPK3PXP',
|
||||
authenticator_types: new Set<number>([UserAuthenticatorTypes.TOTP]),
|
||||
});
|
||||
expect(userHasSudoCapability(user, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
createTotpSecret,
|
||||
createWebAuthnDevice,
|
||||
generateTotpCode,
|
||||
registerWebAuthnCredential,
|
||||
type WebAuthnCredentialMetadata,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('WebAuthn credential registration', () => {
|
||||
@@ -64,4 +66,23 @@ describe('WebAuthn credential registration', () => {
|
||||
expect(credentials[0].name).toBe('Test Passkey');
|
||||
expect(credentials[0].id).toBe(device.credentialId.toString('base64url'));
|
||||
});
|
||||
it('does not turn passkeys into a second factor when a credential is registered', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
const me = await createBuilder<{
|
||||
authenticator_types: Array<number>;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.TOTP]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,54 +1,65 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, createTotpSecret, generateTotpCode} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createRegistrationResponse,
|
||||
createTestAccount,
|
||||
createTotpSecret,
|
||||
generateTotpCode,
|
||||
type LoginMfaResponse,
|
||||
type LoginSuccessResponse,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createSudoWebAuthnBody,
|
||||
createWebAuthnDevice,
|
||||
loginWithDiscoverablePasskey,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnDevice,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface BackupCodesResponse {
|
||||
backup_codes: Array<{
|
||||
code: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface LoginMfaResponse {
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
}
|
||||
|
||||
interface WebAuthnRegistrationOptions {
|
||||
challenge: string;
|
||||
rp: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
authenticatorSelection?: {
|
||||
residentKey?: string;
|
||||
requireResidentKey?: boolean;
|
||||
userVerification?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface WebAuthnAuthenticationOptions {
|
||||
challenge: string;
|
||||
rpId: string;
|
||||
allowCredentials?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
}>;
|
||||
userVerification: string;
|
||||
async function setupPasskeyOnlyAccount(
|
||||
harness: ApiTestHarness,
|
||||
twoFactorEnabled: boolean,
|
||||
): Promise<{
|
||||
account: TestAccount;
|
||||
device: WebAuthnDevice;
|
||||
}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
if (twoFactorEnabled) {
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
}
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: generateTotpCode(secret),
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const token = await loginWithDiscoverablePasskey(harness, device);
|
||||
return {account: {...account, token}, device};
|
||||
}
|
||||
|
||||
describe('WebAuthn MFA Consistency Tests', () => {
|
||||
@@ -56,84 +67,8 @@ describe('WebAuthn MFA Consistency Tests', () => {
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
test('WebAuthn-only user cannot use password for sudo - password rejected with 403', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
expect(login.mfa).toBe(true);
|
||||
const mfaLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({
|
||||
code: backupCodes.backup_codes[0]!.code,
|
||||
ticket: login.ticket,
|
||||
})
|
||||
.execute();
|
||||
account.token = mfaLogin.token;
|
||||
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[1]!.code,
|
||||
})
|
||||
.execute();
|
||||
expect(registrationOptions.authenticatorSelection).toMatchObject({
|
||||
residentKey: 'preferred',
|
||||
requireResidentKey: false,
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: registrationOptions.challenge,
|
||||
name: 'Test Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[2]!.code,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: backupCodes.backup_codes[3]!.code,
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[4]!.code,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const discoverableOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
const discoverableAssertion = createAuthenticationResponse(device, discoverableOptions);
|
||||
const passkeyLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.body({
|
||||
response: discoverableAssertion,
|
||||
challenge: discoverableOptions.challenge,
|
||||
})
|
||||
.execute();
|
||||
account.token = passkeyLogin.token;
|
||||
test('passkey user with two-factor on cannot use password for sudo - password rejected with 403', async () => {
|
||||
const {account} = await setupPasskeyOnlyAccount(harness, true);
|
||||
const {json: errorResp} = await createBuilder<{
|
||||
code: string;
|
||||
}>(harness, account.token)
|
||||
@@ -145,18 +80,34 @@ describe('WebAuthn MFA Consistency Tests', () => {
|
||||
.executeWithResponse();
|
||||
expect(errorResp.code).toBe('SUDO_MODE_REQUIRED');
|
||||
});
|
||||
test('WebAuthn-only user can use WebAuthn for sudo verification', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
test('passkey user with two-factor off can still use password for sudo', async () => {
|
||||
const {account} = await setupPasskeyOnlyAccount(harness, false);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
});
|
||||
test('passkey user with two-factor on can use WebAuthn for sudo verification', async () => {
|
||||
const {account, device} = await setupPasskeyOnlyAccount(harness, true);
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
const {response: disableResp} = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body(sudoBody)
|
||||
.expect(204)
|
||||
.executeWithResponse();
|
||||
const sudoToken = disableResp.headers.get('x-sudo-mode-token');
|
||||
expect(sudoToken).toBeNull();
|
||||
});
|
||||
test('passkey user with two-factor off can use WebAuthn for sudo verification', async () => {
|
||||
const {account, device} = await setupPasskeyOnlyAccount(harness, false);
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
await createBuilder(harness, account.token).post('/users/@me/disable').body(sudoBody).expect(204).execute();
|
||||
});
|
||||
test('passkey user with two-factor on requires MFA when logging in with password', async () => {
|
||||
const {account} = await setupPasskeyOnlyAccount(harness, true);
|
||||
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
@@ -165,144 +116,35 @@ describe('WebAuthn MFA Consistency Tests', () => {
|
||||
})
|
||||
.execute();
|
||||
expect(login.mfa).toBe(true);
|
||||
const mfaLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
expect(login.ticket).toBeTruthy();
|
||||
expect(login.webauthn).toBe(true);
|
||||
});
|
||||
test('passkey user with two-factor off logs in with password and receives a session token', async () => {
|
||||
const {account} = await setupPasskeyOnlyAccount(harness, false);
|
||||
const login = await createBuilderWithoutAuth<LoginSuccessResponse | LoginMfaResponse>(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
code: backupCodes.backup_codes[0]!.code,
|
||||
ticket: login.ticket,
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
account.token = mfaLogin.token;
|
||||
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[1]!.code,
|
||||
})
|
||||
expect('mfa' in login).toBe(false);
|
||||
expect((login as LoginSuccessResponse).token).toBeTruthy();
|
||||
const userInfo = await createBuilder<{
|
||||
id: string;
|
||||
}>(harness, (login as LoginSuccessResponse).token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: registrationOptions.challenge,
|
||||
name: 'Test Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[2]!.code,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: backupCodes.backup_codes[3]!.code,
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[4]!.code,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const discoverableOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
const discoverableAssertion = createAuthenticationResponse(device, discoverableOptions);
|
||||
const passkeyLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.body({
|
||||
response: discoverableAssertion,
|
||||
challenge: discoverableOptions.challenge,
|
||||
})
|
||||
.execute();
|
||||
account.token = passkeyLogin.token;
|
||||
expect(userInfo.id).toBe(account.userId);
|
||||
});
|
||||
test('sudo WebAuthn options stay available to a passkey user with two-factor off', async () => {
|
||||
const {account, device} = await setupPasskeyOnlyAccount(harness, false);
|
||||
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, account.token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
expect(sudoOptions.userVerification).toBe('discouraged');
|
||||
const sudoAssertion = createAuthenticationResponse(device, sudoOptions);
|
||||
const {response: disableResp2} = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: sudoAssertion,
|
||||
webauthn_challenge: sudoOptions.challenge,
|
||||
})
|
||||
.expect(204)
|
||||
.executeWithResponse();
|
||||
const sudoToken = disableResp2.headers.get('x-sudo-mode-token');
|
||||
expect(sudoToken).toBeNull();
|
||||
});
|
||||
test('WebAuthn-only user requires MFA when logging in with password', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
const backupCodes = await createBuilder<BackupCodesResponse>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({
|
||||
secret,
|
||||
code: generateTotpCode(secret),
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
const login = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
expect(login.mfa).toBe(true);
|
||||
const mfaLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({
|
||||
code: backupCodes.backup_codes[0]!.code,
|
||||
ticket: login.ticket,
|
||||
})
|
||||
.execute();
|
||||
account.token = mfaLogin.token;
|
||||
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[1]!.code,
|
||||
})
|
||||
.execute();
|
||||
const registrationResponse = createRegistrationResponse(device, registrationOptions, 'Test Passkey');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: registrationOptions.challenge,
|
||||
name: 'Test Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[2]!.code,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: backupCodes.backup_codes[3]!.code,
|
||||
mfa_method: 'totp',
|
||||
mfa_code: backupCodes.backup_codes[4]!.code,
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const login2 = await createBuilderWithoutAuth<LoginMfaResponse>(harness)
|
||||
.post('/auth/login')
|
||||
.body({
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})
|
||||
.execute();
|
||||
expect(login2.mfa).toBe(true);
|
||||
expect(login2.ticket).toBeTruthy();
|
||||
expect(login2.webauthn).toBe(true);
|
||||
expect(sudoOptions.allowCredentials?.length).toBeGreaterThan(0);
|
||||
expect(device.credentialId.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,16 +4,17 @@ import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
type LoginMfaResponse,
|
||||
type LoginSuccessResponse,
|
||||
loginUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createRegistrationResponse,
|
||||
createTotpSecret,
|
||||
createWebAuthnDevice,
|
||||
generateTotpCode,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
@@ -30,7 +31,7 @@ describe('WebAuthn MFA login', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('validates the WebAuthn MFA login flow', async () => {
|
||||
it('validates the WebAuthn MFA login flow when passkey two-factor is turned on', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
@@ -38,25 +39,17 @@ describe('WebAuthn MFA login', () => {
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
const regOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
if (regOptions.rp.id) {
|
||||
device.rpId = regOptions.rp.id;
|
||||
}
|
||||
const registrationResponse = createRegistrationResponse(device, regOptions, 'MFA Passkey');
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: registrationResponse,
|
||||
challenge: regOptions.challenge,
|
||||
name: 'MFA Passkey',
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
await registerWebAuthnCredential(
|
||||
harness,
|
||||
account.token,
|
||||
device,
|
||||
() => ({mfa_method: 'totp', mfa_code: generateTotpCode(secret)}),
|
||||
'MFA Passkey',
|
||||
);
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
const loginResp = await loginUser(harness, {email: account.email, password: account.password});
|
||||
expect('mfa' in loginResp && loginResp.mfa).toBe(true);
|
||||
const loginMfaResp = loginResp as LoginMfaResponse;
|
||||
@@ -83,7 +76,7 @@ describe('WebAuthn MFA login', () => {
|
||||
.body({
|
||||
response: mfaAssertion,
|
||||
challenge: mfaOptions.challenge,
|
||||
ticket: (loginResp as LoginMfaResponse).ticket,
|
||||
ticket: loginMfaResp.ticket,
|
||||
})
|
||||
.execute();
|
||||
expect(webauthnMfaLogin.token).toBeTruthy();
|
||||
@@ -94,4 +87,39 @@ describe('WebAuthn MFA login', () => {
|
||||
.execute();
|
||||
expect(userInfo.id).toBe(account.userId);
|
||||
});
|
||||
it('issues a session token instead of an MFA ticket when passkey two-factor is left off', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(
|
||||
harness,
|
||||
account.token,
|
||||
device,
|
||||
() => ({mfa_method: 'totp', mfa_code: generateTotpCode(secret)}),
|
||||
'MFA Passkey',
|
||||
);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: generateTotpCode(secret),
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const loginResp = await loginUser(harness, {email: account.email, password: account.password});
|
||||
expect('mfa' in loginResp).toBe(false);
|
||||
const loginSuccessResp = loginResp as LoginSuccessResponse;
|
||||
expect(loginSuccessResp.token).toBeTruthy();
|
||||
const userInfo = await createBuilder<{
|
||||
id: string;
|
||||
}>(harness, loginSuccessResp.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(userInfo.id).toBe(account.userId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createTotpSecret,
|
||||
generateTotpCode,
|
||||
type LoginMfaResponse,
|
||||
type LoginSuccessResponse,
|
||||
loginUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createAuthenticationResponse,
|
||||
createWebAuthnDevice,
|
||||
loginWithDiscoverablePasskey,
|
||||
registerWebAuthnCredential,
|
||||
type WebAuthnAuthenticationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('WebAuthn opt-in login', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('does not offer webauthn at login to a TOTP user whose passkey is opted out', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
const login = (await loginUser(harness, {
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})) as LoginMfaResponse;
|
||||
expect(login.mfa).toBe(true);
|
||||
expect(login.totp).toBe(true);
|
||||
expect(login.webauthn).toBe(false);
|
||||
expect(login.allowed_methods).not.toContain('webauthn');
|
||||
expect(login.allowed_methods).toContain('totp');
|
||||
});
|
||||
it('rejects the WebAuthn MFA login route for a user who never turned passkey two-factor on', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
const login = (await loginUser(harness, {
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})) as LoginMfaResponse;
|
||||
const mfaOptions = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/login/mfa/webauthn/authentication-options')
|
||||
.body({ticket: login.ticket})
|
||||
.execute();
|
||||
if (mfaOptions.rpId) {
|
||||
device.rpId = mfaOptions.rpId;
|
||||
}
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login/mfa/webauthn')
|
||||
.body({
|
||||
response: createAuthenticationResponse(device, mfaOptions),
|
||||
challenge: mfaOptions.challenge,
|
||||
ticket: login.ticket,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED')
|
||||
.execute();
|
||||
const totpLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({ticket: login.ticket, code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
expect(totpLogin.token).toBeTruthy();
|
||||
});
|
||||
it('completes the passwordless journey for an account that never enrolled TOTP or the toggle', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const me = await createBuilder<{
|
||||
authenticator_types: Array<number>;
|
||||
mfa_enabled: boolean;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.authenticator_types).toEqual([]);
|
||||
expect(me.mfa_enabled).toBe(false);
|
||||
const passwordLogin = await loginUser(harness, {email: account.email, password: account.password});
|
||||
expect('mfa' in passwordLogin).toBe(false);
|
||||
expect((passwordLogin as LoginSuccessResponse).token).toBeTruthy();
|
||||
const passkeyToken = await loginWithDiscoverablePasskey(harness, device);
|
||||
expect(passkeyToken).toBeTruthy();
|
||||
const passkeyMe = await createBuilder<{
|
||||
id: string;
|
||||
authenticator_types: Array<number>;
|
||||
}>(harness, passkeyToken)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(passkeyMe.id).toBe(account.userId);
|
||||
expect(passkeyMe.authenticator_types).not.toContain(UserAuthenticatorTypes.WEBAUTHN);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
generateKeyPairSync,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {decode as base32Decode, encode as base32Encode} from 'hi-base32';
|
||||
|
||||
export interface WebAuthnDevice {
|
||||
@@ -54,6 +56,22 @@ export interface WebAuthnCredentialMetadata {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type SudoVerificationBody = Record<string, unknown>;
|
||||
|
||||
export type SudoVerificationBodyFactory = () => SudoVerificationBody;
|
||||
|
||||
export interface WebAuthnTwoFactorResult {
|
||||
user: {
|
||||
id: string;
|
||||
mfa_enabled: boolean;
|
||||
authenticator_types: Array<number>;
|
||||
};
|
||||
backup_codes: Array<{
|
||||
code: string;
|
||||
consumed: boolean;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
interface AuthenticatorAttestationResponse {
|
||||
clientDataJSON: string;
|
||||
attestationObject: string;
|
||||
@@ -415,3 +433,80 @@ export function createAuthenticationResponseWithoutUV(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerWebAuthnCredential(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
device: WebAuthnDevice,
|
||||
createSudoBody: SudoVerificationBodyFactory,
|
||||
name = 'Test Passkey',
|
||||
): Promise<void> {
|
||||
const options = await createBuilder<WebAuthnRegistrationOptions>(harness, token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body(createSudoBody())
|
||||
.execute();
|
||||
if (options.rp.id) {
|
||||
device.rpId = options.rp.id;
|
||||
}
|
||||
await createBuilder(harness, token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: createRegistrationResponse(device, options, name),
|
||||
challenge: options.challenge,
|
||||
name,
|
||||
...createSudoBody(),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function createSudoWebAuthnBody(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
device: WebAuthnDevice,
|
||||
): Promise<SudoVerificationBody> {
|
||||
const options = await createBuilder<WebAuthnAuthenticationOptions>(harness, token)
|
||||
.post('/users/@me/sudo/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
if (options.rpId) {
|
||||
device.rpId = options.rpId;
|
||||
}
|
||||
return {
|
||||
mfa_method: 'webauthn',
|
||||
webauthn_response: createAuthenticationResponse(device, options),
|
||||
webauthn_challenge: options.challenge,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setWebAuthnTwoFactor(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
enabled: boolean,
|
||||
sudo: SudoVerificationBody,
|
||||
): Promise<WebAuthnTwoFactorResult> {
|
||||
return createBuilder<WebAuthnTwoFactorResult>(harness, token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled, ...sudo})
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function loginWithDiscoverablePasskey(harness: ApiTestHarness, device: WebAuthnDevice): Promise<string> {
|
||||
const options = await createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
|
||||
.post('/auth/webauthn/authentication-options')
|
||||
.body(null)
|
||||
.execute();
|
||||
if (options.rpId) {
|
||||
device.rpId = options.rpId;
|
||||
}
|
||||
const login = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/webauthn/authenticate')
|
||||
.body({
|
||||
response: createAuthenticationResponse(device, options),
|
||||
challenge: options.challenge,
|
||||
})
|
||||
.execute();
|
||||
return login.token;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createTotpSecret,
|
||||
generateTotpCode,
|
||||
type LoginMfaResponse,
|
||||
loginUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createSudoWebAuthnBody,
|
||||
createWebAuthnDevice,
|
||||
registerWebAuthnCredential,
|
||||
type SudoVerificationBody,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnCredentialMetadata,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface BackupCode {
|
||||
code: string;
|
||||
consumed: boolean;
|
||||
}
|
||||
|
||||
async function readBackupCodes(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
sudo: SudoVerificationBody,
|
||||
): Promise<Array<BackupCode>> {
|
||||
const response = await createBuilder<{
|
||||
backup_codes: Array<BackupCode>;
|
||||
}>(harness, token)
|
||||
.post('/users/@me/mfa/backup-codes')
|
||||
.body({regenerate: false, ...sudo})
|
||||
.execute();
|
||||
return response.backup_codes;
|
||||
}
|
||||
|
||||
describe('WebAuthn two-factor backup codes', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('mints backup codes when passkey two-factor is turned on for an account with no TOTP', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
expect(enabled.backup_codes).not.toBeNull();
|
||||
expect(enabled.backup_codes!.length).toBeGreaterThan(0);
|
||||
expect(enabled.backup_codes!.every((backupCode) => !backupCode.consumed)).toBe(true);
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
const stored = await readBackupCodes(harness, account.token, sudoBody);
|
||||
expect(stored.map((backupCode) => backupCode.code).sort()).toEqual(
|
||||
enabled.backup_codes!.map((backupCode) => backupCode.code).sort(),
|
||||
);
|
||||
});
|
||||
it('mints nothing when the account already holds backup codes from TOTP', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
expect(enabled.backup_codes).toBeNull();
|
||||
});
|
||||
it('accepts a minted backup code at login when the passkey is unavailable', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
const login = (await loginUser(harness, {
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
})) as LoginMfaResponse;
|
||||
expect(login.mfa).toBe(true);
|
||||
expect(login.totp).toBe(false);
|
||||
expect(login.webauthn).toBe(true);
|
||||
expect(login.backup_codes).toBe(true);
|
||||
expect(login.allowed_methods).toContain('backup_codes');
|
||||
const backupLogin = await createBuilderWithoutAuth<{
|
||||
token: string;
|
||||
}>(harness)
|
||||
.post('/auth/login/mfa/totp')
|
||||
.body({ticket: login.ticket, code: enabled.backup_codes![0]!.code})
|
||||
.execute();
|
||||
expect(backupLogin.token).toBeTruthy();
|
||||
const me = await createBuilder<{
|
||||
id: string;
|
||||
}>(harness, backupLogin.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(me.id).toBe(account.userId);
|
||||
});
|
||||
it('keeps backup codes when TOTP is disabled while passkey two-factor stays on', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: generateTotpCode(secret),
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
const stored = await readBackupCodes(harness, account.token, sudoBody);
|
||||
expect(stored.length).toBeGreaterThan(0);
|
||||
});
|
||||
it('keeps backup codes when passkey two-factor is turned off while TOTP stays on', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
await setWebAuthnTwoFactor(harness, account.token, false, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
const stored = await readBackupCodes(harness, account.token, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
expect(stored.length).toBeGreaterThan(0);
|
||||
});
|
||||
it('clears backup codes only once no second factor remains', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
await setWebAuthnTwoFactor(harness, account.token, false, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
});
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
code: generateTotpCode(secret),
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const stored = await readBackupCodes(harness, account.token, {password: account.password});
|
||||
expect(stored).toHaveLength(0);
|
||||
});
|
||||
it('clears backup codes when the last passkey is deleted and nothing else remains', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
const credentials = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/mfa/webauthn/credentials/${credentials[0]!.id}`)
|
||||
.body(sudoBody)
|
||||
.expect(204)
|
||||
.execute();
|
||||
const stored = await readBackupCodes(harness, account.token, {password: account.password});
|
||||
expect(stored).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, loginUser, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createWebAuthnDevice,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnDevice,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface PrivateUserResponse {
|
||||
id: string;
|
||||
mfa_enabled: boolean;
|
||||
authenticator_types: Array<number>;
|
||||
}
|
||||
|
||||
interface SudoMfaMethodsResponse {
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
has_mfa: boolean;
|
||||
}
|
||||
|
||||
interface SudoModeRequiredResponse {
|
||||
code: string;
|
||||
has_mfa?: boolean;
|
||||
methods?: {
|
||||
totp?: boolean;
|
||||
webauthn?: boolean;
|
||||
backup_codes?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface ValidationErrorBody {
|
||||
code: string;
|
||||
errors: Array<{path: string; code: string}>;
|
||||
}
|
||||
|
||||
interface BackupCode {
|
||||
code: string;
|
||||
consumed: boolean;
|
||||
}
|
||||
|
||||
async function withTotpReplayProtection<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previous = Config.dev.testModeEnabled;
|
||||
Config.dev.testModeEnabled = false;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
Config.dev.testModeEnabled = previous;
|
||||
}
|
||||
}
|
||||
|
||||
async function createPasskeyOnlyTwoFactorAccount(
|
||||
harness: ApiTestHarness,
|
||||
): Promise<{account: TestAccount; device: WebAuthnDevice; backupCodes: Array<string>}> {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
expect(enabled.user.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
expect(enabled.backup_codes).not.toBeNull();
|
||||
return {account, device, backupCodes: enabled.backup_codes!.map((backupCode) => backupCode.code)};
|
||||
}
|
||||
|
||||
async function fetchMe(harness: ApiTestHarness, token: string): Promise<PrivateUserResponse> {
|
||||
return createBuilder<PrivateUserResponse>(harness, token).get('/users/@me').execute();
|
||||
}
|
||||
|
||||
describe('Sudo mode recovery for passkey two-factor accounts without TOTP', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('turns passkey two-factor off with a backup code when the passkey cannot be used', async () => {
|
||||
const {account, backupCodes} = await createPasskeyOnlyTwoFactorAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, password: account.password})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
await withTotpReplayProtection(async () => {
|
||||
const disabled = await createBuilder<{user: PrivateUserResponse}>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, mfa_method: 'totp', mfa_code: backupCodes[0]!})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(disabled.user.authenticator_types).toEqual([]);
|
||||
expect(disabled.user.mfa_enabled).toBe(false);
|
||||
});
|
||||
const me = await fetchMe(harness, account.token);
|
||||
expect(me.authenticator_types).toEqual([]);
|
||||
expect(me.mfa_enabled).toBe(false);
|
||||
const login = await loginUser(harness, {email: account.email, password: account.password});
|
||||
expect('mfa' in login).toBe(false);
|
||||
});
|
||||
it('advertises the backup code option in the sudo methods endpoint and the sudo mode challenge alike', async () => {
|
||||
const {account} = await createPasskeyOnlyTwoFactorAccount(harness);
|
||||
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, account.token)
|
||||
.get('/users/@me/sudo/mfa-methods')
|
||||
.execute();
|
||||
expect(methods).toEqual({totp: false, webauthn: true, backup_codes: true, has_mfa: true});
|
||||
const challenge = await createBuilder<SudoModeRequiredResponse>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
expect(challenge.has_mfa).toBe(methods.has_mfa);
|
||||
expect(challenge.methods).toEqual({
|
||||
totp: methods.totp,
|
||||
webauthn: methods.webauthn,
|
||||
backup_codes: methods.backup_codes,
|
||||
});
|
||||
});
|
||||
it('spends a backup code accepted as a sudo proof and refuses the same code afterwards', async () => {
|
||||
const {account, backupCodes} = await createPasskeyOnlyTwoFactorAccount(harness);
|
||||
const spent = backupCodes[0]!;
|
||||
await withTotpReplayProtection(async () => {
|
||||
const stored = await createBuilder<{backup_codes: Array<BackupCode>}>(harness, account.token)
|
||||
.post('/users/@me/mfa/backup-codes')
|
||||
.body({regenerate: false, mfa_method: 'totp', mfa_code: spent})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(stored.backup_codes.find((backupCode) => backupCode.code === spent)?.consumed).toBe(true);
|
||||
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, mfa_method: 'totp', mfa_code: spent})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(error.errors[0]?.path).toBe('mfa_code');
|
||||
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_MFA_CODE);
|
||||
});
|
||||
const me = await fetchMe(harness, account.token);
|
||||
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
});
|
||||
it('rejects a backup code that was never minted and leaves passkey two-factor on', async () => {
|
||||
const {account} = await createPasskeyOnlyTwoFactorAccount(harness);
|
||||
await withTotpReplayProtection(async () => {
|
||||
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, mfa_method: 'totp', mfa_code: 'aaaa-bbbb'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(error.errors[0]?.path).toBe('mfa_code');
|
||||
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_MFA_CODE);
|
||||
});
|
||||
const me = await fetchMe(harness, account.token);
|
||||
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
});
|
||||
it('rejects a code-entry sudo proof for a passkey account that holds neither TOTP nor backup codes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const methods = await createBuilder<SudoMfaMethodsResponse>(harness, account.token)
|
||||
.get('/users/@me/sudo/mfa-methods')
|
||||
.execute();
|
||||
expect(methods).toEqual({totp: false, webauthn: true, backup_codes: false, has_mfa: true});
|
||||
await withTotpReplayProtection(async () => {
|
||||
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({mfa_method: 'totp', mfa_code: 'aaaa-bbbb'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(error.errors[0]?.path).toBe('mfa_code');
|
||||
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_MFA_CODE);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createSudoWebAuthnBody,
|
||||
createWebAuthnDevice,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnCredentialMetadata,
|
||||
type WebAuthnTwoFactorResult,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface PrivateUserResponse {
|
||||
id: string;
|
||||
mfa_enabled: boolean;
|
||||
authenticator_types: Array<number>;
|
||||
}
|
||||
|
||||
describe('WebAuthn two-factor toggle', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('reports an empty authenticator types array for an account with no second factor', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(me.authenticator_types).toEqual([]);
|
||||
expect(me.mfa_enabled).toBe(false);
|
||||
});
|
||||
it('rejects enabling passkey two-factor when the account has no registered credential', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: true, password: account.password})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'NO_PASSKEYS_REGISTERED')
|
||||
.execute();
|
||||
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(me.authenticator_types).toEqual([]);
|
||||
});
|
||||
it('round trips enabling and disabling passkey two-factor', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const enabled = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
expect(enabled.user.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
expect(enabled.user.mfa_enabled).toBe(true);
|
||||
const afterEnable = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(afterEnable.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
const disabled = await setWebAuthnTwoFactor(harness, account.token, false, sudoBody);
|
||||
expect(disabled.user.authenticator_types).toEqual([]);
|
||||
expect(disabled.user.mfa_enabled).toBe(false);
|
||||
const afterDisable = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(afterDisable.authenticator_types).toEqual([]);
|
||||
});
|
||||
it('refuses to disable passkey two-factor without a sudo proof', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
await createBuilder(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, password: account.password})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(me.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
});
|
||||
it('accepts a passkey assertion as the sudo proof for disabling passkey two-factor', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
const disabled = await createBuilder<WebAuthnTwoFactorResult>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, ...sudoBody})
|
||||
.execute();
|
||||
expect(disabled.user.authenticator_types).toEqual([]);
|
||||
});
|
||||
it('leaves the account untouched when the requested state already matches', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
const firstEnable = await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
expect(firstEnable.backup_codes).not.toBeNull();
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
const secondEnable = await setWebAuthnTwoFactor(harness, account.token, true, sudoBody);
|
||||
expect(secondEnable.backup_codes).toBeNull();
|
||||
expect(secondEnable.user.authenticator_types).toEqual([UserAuthenticatorTypes.WEBAUTHN]);
|
||||
});
|
||||
it('drops the passkey second factor when the last credential is deleted', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
await registerWebAuthnCredential(harness, account.token, device, () => ({password: account.password}));
|
||||
await setWebAuthnTwoFactor(harness, account.token, true, {password: account.password});
|
||||
const credentials = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, account.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
const sudoBody = await createSudoWebAuthnBody(harness, account.token, device);
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/mfa/webauthn/credentials/${credentials[0]!.id}`)
|
||||
.body(sudoBody)
|
||||
.expect(204)
|
||||
.execute();
|
||||
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(me.authenticator_types).toEqual([]);
|
||||
expect(me.mfa_enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -16924,6 +16924,63 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/mfa/webauthn/two-factor": {
|
||||
"put": {
|
||||
"operationId": "set_webauthn_two_factor",
|
||||
"summary": "Set WebAuthn two-factor authentication",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebAuthnTwoFactorResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Choose whether registered passkeys are required as a second factor when signing in with email and password. Enabling requires at least one registered credential and mints backup codes when the account has none. Requires sudo mode verification.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/WebAuthnTwoFactorRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/mobile-devices": {
|
||||
"post": {
|
||||
"operationId": "register_mobile_push_device",
|
||||
@@ -21879,10 +21936,17 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"totp": {"type": "boolean", "description": "Whether TOTP is enabled"},
|
||||
"webauthn": {"type": "boolean", "description": "Whether WebAuthn is enabled"},
|
||||
"has_mfa": {"type": "boolean", "description": "Whether any MFA method is enabled"}
|
||||
"webauthn": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the account has at least one registered WebAuthn credential"
|
||||
},
|
||||
"backup_codes": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the account has at least one unconsumed backup code"
|
||||
},
|
||||
"has_mfa": {"type": "boolean", "description": "Whether the account can satisfy a sudo mode challenge"}
|
||||
},
|
||||
"required": ["totp", "webauthn", "has_mfa"],
|
||||
"required": ["totp", "webauthn", "backup_codes", "has_mfa"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VoiceActivitySharingUpdateRequest": {
|
||||
@@ -22782,6 +22846,62 @@
|
||||
"required": ["device_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WebAuthnTwoFactorRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether registered passkeys count as a second factor when logging in"
|
||||
},
|
||||
"password": {
|
||||
"description": "Account password for sudo verification",
|
||||
"$ref": "#/components/schemas/PasswordType"
|
||||
},
|
||||
"mfa_method": {
|
||||
"description": "MFA method to use for verification",
|
||||
"x-enumNames": ["TOTP", "WebAuthn"],
|
||||
"x-enumDescriptions": [
|
||||
"Time-based one-time password authentication via authenticator app",
|
||||
"Security key or biometric authentication"
|
||||
],
|
||||
"enum": ["totp", "webauthn"],
|
||||
"type": "string"
|
||||
},
|
||||
"mfa_code": {"description": "MFA verification code from an authenticator app", "type": "string"},
|
||||
"webauthn_response": {
|
||||
"description": "WebAuthn authentication response",
|
||||
"$ref": "#/components/schemas/WebAuthnAuthenticationResponse"
|
||||
},
|
||||
"webauthn_challenge": {"description": "WebAuthn challenge string", "type": "string"}
|
||||
},
|
||||
"required": ["enabled"]
|
||||
},
|
||||
"WebAuthnTwoFactorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user": {"description": "The updated account", "$ref": "#/components/schemas/UserPrivateResponse"},
|
||||
"backup_codes": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "description": "The backup code"},
|
||||
"consumed": {"type": "boolean", "description": "Whether the code has been used"}
|
||||
},
|
||||
"required": ["code", "consumed"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "Backup codes minted by this call, or null when none were minted"
|
||||
}
|
||||
},
|
||||
"required": ["user", "backup_codes"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SudoVerificationSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -28201,9 +28321,13 @@
|
||||
"description": "List of allowed MFA methods"
|
||||
},
|
||||
"totp": {"type": "boolean", "description": "Whether TOTP authenticator MFA is available"},
|
||||
"webauthn": {"type": "boolean", "description": "Whether WebAuthn security key MFA is available"}
|
||||
"webauthn": {"type": "boolean", "description": "Whether WebAuthn security key MFA is available"},
|
||||
"backup_codes": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the account has at least one unconsumed backup code"
|
||||
}
|
||||
},
|
||||
"required": ["mfa", "ticket", "allowed_methods", "totp", "webauthn"],
|
||||
"required": ["mfa", "ticket", "allowed_methods", "totp", "webauthn", "backup_codes"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
@@ -28259,9 +28383,13 @@
|
||||
"description": "List of allowed MFA methods"
|
||||
},
|
||||
"totp": {"type": "boolean", "description": "Whether TOTP authenticator MFA is available"},
|
||||
"webauthn": {"type": "boolean", "description": "Whether WebAuthn security key MFA is available"}
|
||||
"webauthn": {"type": "boolean", "description": "Whether WebAuthn security key MFA is available"},
|
||||
"backup_codes": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the account has at least one unconsumed backup code"
|
||||
}
|
||||
},
|
||||
"required": ["mfa", "ticket", "allowed_methods", "totp", "webauthn"],
|
||||
"required": ["mfa", "ticket", "allowed_methods", "totp", "webauthn", "backup_codes"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{"$ref": "#/components/schemas/AuthRegistrationPendingApprovalResponse"}
|
||||
@@ -33378,6 +33506,14 @@
|
||||
"required": ["id", "rawId", "type", "clientExtensionResults", "response"],
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"UserAuthenticatorTypes": {
|
||||
"description": "Authenticator type",
|
||||
"type": "integer",
|
||||
"enum": [0, 2],
|
||||
"format": "int32",
|
||||
"x-enumNames": ["TOTP", "WEBAUTHN"],
|
||||
"x-enumDescriptions": ["Time-based one-time password authenticator", "WebAuthn authenticator"]
|
||||
},
|
||||
"HexString32Type": {"type": "string", "pattern": "^[a-f0-9]{32}$"},
|
||||
"PhoneNumberType": {"type": "string"},
|
||||
"RelationshipTypesInput": {
|
||||
@@ -33762,14 +33898,6 @@
|
||||
"enum": ["online", "dnd", "idle", "invisible"],
|
||||
"type": "string"
|
||||
},
|
||||
"UserAuthenticatorTypes": {
|
||||
"description": "Authenticator type",
|
||||
"type": "integer",
|
||||
"enum": [0, 2],
|
||||
"format": "int32",
|
||||
"x-enumNames": ["TOTP", "WEBAUTHN"],
|
||||
"x-enumDescriptions": ["Time-based one-time password authenticator", "WebAuthn authenticator"]
|
||||
},
|
||||
"HexString16Type": {"type": "string", "pattern": "^[a-f0-9]{16}$"},
|
||||
"Int64Type": {
|
||||
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}],
|
||||
|
||||
@@ -100,6 +100,10 @@ export const AuthRateLimitConfigs = {
|
||||
bucket: 'mfa:webauthn:delete',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
MFA_WEBAUTHN_TWO_FACTOR: {
|
||||
bucket: 'mfa:webauthn:two_factor',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
PHONE_SEND_VERIFICATION: {
|
||||
bucket: 'phone:send_verification',
|
||||
config: {limit: 5, windowMs: ms('1 minute')},
|
||||
|
||||
@@ -154,7 +154,7 @@ export function mapUserToPrivateResponse(user: User): UserPrivateResponse {
|
||||
banner: stripBannerForUser(user),
|
||||
banner_color: user.bannerColor,
|
||||
mfa_enabled: authenticatorTypes.length > 0,
|
||||
authenticator_types: authenticatorTypes.length > 0 ? authenticatorTypes : undefined,
|
||||
authenticator_types: authenticatorTypes,
|
||||
verified: user.emailVerified,
|
||||
premium_type: isActuallyPremium ? (user.premiumType ?? UserPremiumTypes.NONE) : UserPremiumTypes.NONE,
|
||||
premium_since: isActuallyPremium ? (user.premiumSince?.toISOString() ?? null) : null,
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
WebAuthnCredentialListResponse,
|
||||
WebAuthnCredentialUpdateRequest,
|
||||
WebAuthnRegisterRequest,
|
||||
WebAuthnTwoFactorRequest,
|
||||
WebAuthnTwoFactorResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {CredentialIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {EmptyBodyRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
@@ -431,6 +433,30 @@ export function UserAuthController(app: HonoApp) {
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/users/@me/mfa/webauthn/two-factor',
|
||||
RateLimitMiddleware(RateLimitConfigs.MFA_WEBAUTHN_TWO_FACTOR),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', WebAuthnTwoFactorRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_webauthn_two_factor',
|
||||
summary: 'Set WebAuthn two-factor authentication',
|
||||
responseSchema: WebAuthnTwoFactorResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Choose whether registered passkeys are required as a second factor when signing in with email and password. Enabling requires at least one registered credential and mints backup codes when the account has none. Requires sudo mode verification.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body);
|
||||
return ctx.json(await ctx.get('userAuthRequestService').setWebAuthnTwoFactor({user, data: body}));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/users/@me/sudo/mfa-methods',
|
||||
RateLimitMiddleware(RateLimitConfigs.SUDO_MFA_METHODS),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {randomUUID, timingSafeEqual} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {requireEmailVerified} from '@app/api/auth/EmailVerificationUtils';
|
||||
import {userHasMfa} from '@app/api/auth/services/SudoMethods';
|
||||
import type {MfaBackupCode} from '@app/api/models/MfaBackupCode';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {regenerateMfaBackupCodes} from '@app/api/user/services/UserAuth';
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
checkChangeRateLimit,
|
||||
generateChangeVerificationCode,
|
||||
} from '@app/api/user/services/UserChangeChallengeUtils';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
@@ -65,7 +65,7 @@ export class MfaBackupCodesChallengeService {
|
||||
if (!user.email) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS);
|
||||
}
|
||||
if (!user.totpSecret || !user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP)) {
|
||||
if (!userHasMfa(user)) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
await checkChangeRateLimit(rateLimit, {
|
||||
@@ -148,7 +148,7 @@ export class MfaBackupCodesChallengeService {
|
||||
maxAttempts: 5,
|
||||
windowMs: ms('15 minutes'),
|
||||
});
|
||||
if (!user.totpSecret || !user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP)) {
|
||||
if (!userHasMfa(user)) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
if (!state.verification_proof) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthPassword from '@app/api/auth/AuthPassword';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {deriveSudoMethods, userHasMfa} from '@app/api/auth/services/SudoMethods';
|
||||
import {deriveSudoMethods, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
|
||||
import type {SudoVerificationResult} from '@app/api/auth/services/SudoVerificationService';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
@@ -64,7 +65,6 @@ export class UserAccountSecurityService {
|
||||
const isUnclaimedAccount = user.isUnclaimedAccount();
|
||||
const identityVerifiedViaSudo = sudoContext?.method === 'mfa' || sudoContext?.method === 'sudo_token';
|
||||
const identityVerifiedViaPassword = sudoContext?.method === 'password';
|
||||
const hasMfa = userHasMfa(user);
|
||||
const rawEmail = data.email?.trim();
|
||||
const normalizedEmail = rawEmail?.toLowerCase();
|
||||
const hasPasswordRequiredChanges =
|
||||
@@ -74,7 +74,7 @@ export class UserAccountSecurityService {
|
||||
data.new_password !== undefined;
|
||||
const requiresVerification = hasPasswordRequiredChanges && !isUnclaimedAccount;
|
||||
if (requiresVerification && !identityVerifiedViaSudo && !identityVerifiedViaPassword) {
|
||||
throw new SudoModeRequiredError(hasMfa, deriveSudoMethods(user));
|
||||
throw await this.createSudoModeRequiredError(user);
|
||||
}
|
||||
if (isUnclaimedAccount && data.new_password) {
|
||||
updates.password_hash = await this.hashNewPassword(data.new_password);
|
||||
@@ -85,7 +85,7 @@ export class UserAccountSecurityService {
|
||||
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
|
||||
}
|
||||
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
|
||||
throw new SudoModeRequiredError(hasMfa, deriveSudoMethods(user));
|
||||
throw await this.createSudoModeRequiredError(user);
|
||||
}
|
||||
updates.password_hash = await this.hashNewPassword(data.new_password);
|
||||
updates.password_last_changed_at = new Date();
|
||||
@@ -164,6 +164,16 @@ export class UserAccountSecurityService {
|
||||
});
|
||||
}
|
||||
|
||||
private async createSudoModeRequiredError(user: User): Promise<SudoModeRequiredError> {
|
||||
const credentials = await this.deps.apiContext.services.users.listWebAuthnCredentials(user.id);
|
||||
const hasPasskeyCredentials = credentials.length > 0;
|
||||
const hasBackupCodes = await AuthMfa.hasUnconsumedBackupCodes(this.deps.apiContext, user.id);
|
||||
return new SudoModeRequiredError(
|
||||
userHasSudoCapability(user, hasPasskeyCredentials),
|
||||
deriveSudoMethods(user, hasPasskeyCredentials, hasBackupCodes),
|
||||
);
|
||||
}
|
||||
|
||||
private async hashNewPassword(newPassword: string): Promise<string> {
|
||||
if (await AuthPassword.isPasswordPwned(this.deps.apiContext, newPassword)) {
|
||||
throw InputValidationError.fromCode('new_password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {deriveSudoMethods, userHasMfa} from '@app/api/auth/services/SudoMethods';
|
||||
import {deriveSudoMethods, userHasMfa, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
|
||||
import type {SudoVerificationResult} from '@app/api/auth/services/SudoVerificationService';
|
||||
import type {MfaBackupCode} from '@app/api/models/MfaBackupCode';
|
||||
import type {User} from '@app/api/models/User';
|
||||
@@ -36,12 +36,23 @@ interface GetMfaBackupCodesParams {
|
||||
sudoContext: SudoVerificationResult;
|
||||
}
|
||||
|
||||
function assertSudoVerifiedForMfa(user: User, sudoContext: SudoVerificationResult): void {
|
||||
async function assertSudoVerifiedForMfa(
|
||||
ctx: ApiContext,
|
||||
user: User,
|
||||
sudoContext: SudoVerificationResult,
|
||||
): Promise<void> {
|
||||
const identityVerifiedViaSudo = sudoContext.method === 'mfa' || sudoContext.method === 'sudo_token';
|
||||
const identityVerifiedViaPassword = sudoContext.method === 'password';
|
||||
if (!identityVerifiedViaSudo && !identityVerifiedViaPassword) {
|
||||
throw new SudoModeRequiredError(userHasMfa(user), deriveSudoMethods(user));
|
||||
if (identityVerifiedViaSudo || identityVerifiedViaPassword) {
|
||||
return;
|
||||
}
|
||||
const credentials = await ctx.services.users.listWebAuthnCredentials(user.id);
|
||||
const hasPasskeyCredentials = credentials.length > 0;
|
||||
const hasBackupCodes = await AuthMfa.hasUnconsumedBackupCodes(ctx, user.id);
|
||||
throw new SudoModeRequiredError(
|
||||
userHasSudoCapability(user, hasPasskeyCredentials),
|
||||
deriveSudoMethods(user, hasPasskeyCredentials, hasBackupCodes),
|
||||
);
|
||||
}
|
||||
|
||||
export async function enableMfaTotp(
|
||||
@@ -49,7 +60,7 @@ export async function enableMfaTotp(
|
||||
{user, secret, code, sudoContext}: EnableMfaTotpParams,
|
||||
): Promise<Array<MfaBackupCode>> {
|
||||
const {users, botMfaMirror} = ctx.services;
|
||||
assertSudoVerifiedForMfa(user, sudoContext);
|
||||
await assertSudoVerifiedForMfa(ctx, user, sudoContext);
|
||||
if (user.totpSecret) throw new MfaNotDisabledError();
|
||||
const userId = user.id;
|
||||
if (!(await AuthMfa.verifyMfaCode(ctx, {userId: user.id, mfaSecret: secret, code}))) {
|
||||
@@ -75,7 +86,7 @@ export async function enableMfaTotp(
|
||||
export async function disableMfaTotp(ctx: ApiContext, {user, code, sudoContext}: DisableMfaTotpParams): Promise<void> {
|
||||
const {users, botMfaMirror} = ctx.services;
|
||||
if (!user.totpSecret) throw new MfaNotEnabledError();
|
||||
assertSudoVerifiedForMfa(user, sudoContext);
|
||||
await assertSudoVerifiedForMfa(ctx, user, sudoContext);
|
||||
if (
|
||||
sudoContext.method !== 'mfa' &&
|
||||
!(await AuthMfa.verifyMfaCode(ctx, {
|
||||
@@ -102,7 +113,9 @@ export async function disableMfaTotp(ctx: ApiContext, {user, code, sudoContext}:
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await users.clearMfaBackupCodes(userId);
|
||||
if (!userHasMfa(updatedUser)) {
|
||||
await users.clearMfaBackupCodes(userId);
|
||||
}
|
||||
await dispatchUserUpdate(ctx, updatedUser);
|
||||
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
@@ -112,7 +125,7 @@ export async function getMfaBackupCodes(
|
||||
{user, regenerate, sudoContext}: GetMfaBackupCodesParams,
|
||||
): Promise<Array<MfaBackupCode>> {
|
||||
const {users} = ctx.services;
|
||||
assertSudoVerifiedForMfa(user, sudoContext);
|
||||
await assertSudoVerifiedForMfa(ctx, user, sudoContext);
|
||||
if (regenerate) {
|
||||
return regenerateMfaBackupCodes(ctx, user);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuild
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import * as UserAuth from '@app/api/user/services/UserAuth';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {PhoneAddNotEligibleError} from '@fluxer/errors/src/domains/auth/PhoneAddNotEligibleError';
|
||||
@@ -26,6 +27,8 @@ import type {
|
||||
WebAuthnCredentialListResponse,
|
||||
WebAuthnCredentialUpdateRequest,
|
||||
WebAuthnRegisterRequest,
|
||||
WebAuthnTwoFactorRequest,
|
||||
WebAuthnTwoFactorResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
|
||||
interface UserAuthWithSudoRequest<T> {
|
||||
@@ -194,6 +197,22 @@ export class UserAuthRequestService {
|
||||
await AuthMfa.deleteWebAuthnCredential(this.apiContext, user.id, credentialId);
|
||||
}
|
||||
|
||||
async setWebAuthnTwoFactor({
|
||||
user,
|
||||
data,
|
||||
}: UserAuthRequest<WebAuthnTwoFactorRequest>): Promise<WebAuthnTwoFactorResponse> {
|
||||
if (data.enabled) {
|
||||
requireEmailVerified(user, 'mfa');
|
||||
}
|
||||
const result = await AuthMfa.setWebAuthnTwoFactor(this.apiContext, user.id, data.enabled);
|
||||
return {
|
||||
user: mapUserToPrivateResponse(result.user),
|
||||
backup_codes: result.backupCodes
|
||||
? result.backupCodes.map((backupCode) => ({code: backupCode.code, consumed: backupCode.consumed}))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async listSudoMfaMethods(user: User): Promise<SudoMfaMethodsResponse> {
|
||||
return AuthMfa.getAvailableMfaMethods(this.apiContext, user.id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user