Add Optional 2FA Support (#61)

* Add TOTP MFA Support

* Add Passkey MFA Support

It's not impossible I missed some minor cleanup, but most things make sense and there isn't a lot of obvious duplication anymore.

---------

Co-authored-by: Bruno Bernardino <me@brunobernardino.com>
This commit is contained in:
0xGingi
2025-05-29 12:30:28 -04:00
committed by GitHub
parent 2a77915630
commit 455a7201e9
28 changed files with 2361 additions and 40 deletions

View File

@@ -8,8 +8,10 @@ import { User, UserSession } from './types.ts';
import { UserModel, UserSessionModel, validateUserAndSession } from './models/user.ts';
import { AppConfig } from './config.ts';
const JWT_SECRET = Deno.env.get('JWT_SECRET') || '';
export const JWT_SECRET = Deno.env.get('JWT_SECRET') || '';
export const PASSWORD_SALT = Deno.env.get('PASSWORD_SALT') || '';
export const MFA_KEY = Deno.env.get('MFA_KEY') || '';
export const MFA_SALT = Deno.env.get('MFA_SALT') || '';
export const COOKIE_NAME = 'bewcloud-app-v1';
export interface JwtData {
@@ -25,10 +27,10 @@ const textToData = (text: string) => new TextEncoder().encode(text);
export const dataToText = (data: Uint8Array) => new TextDecoder().decode(data);
const generateKey = async (key: string) =>
export const generateKey = async (key: string): Promise<CryptoKey> =>
await crypto.subtle.importKey('raw', textToData(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
async function signAuthJwt(key: CryptoKey, data: JwtData) {
async function signAuthJwt(key: CryptoKey, data: JwtData): Promise<string> {
const payload = encodeBase64Url(textToData(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))) + '.' +
encodeBase64Url(textToData(JSON.stringify(data) || ''));
const signature = encodeBase64Url(
@@ -37,7 +39,7 @@ async function signAuthJwt(key: CryptoKey, data: JwtData) {
return `${payload}.${signature}`;
}
async function verifyAuthJwt(key: CryptoKey, jwt: string) {
export async function verifyAuthJwt(key: CryptoKey, jwt: string): Promise<JwtData> {
const jwtParts = jwt.split('.');
if (jwtParts.length !== 3) {
throw new Error('Malformed JWT');
@@ -51,7 +53,7 @@ async function verifyAuthJwt(key: CryptoKey, jwt: string) {
throw new Error('Invalid JWT');
}
async function resolveCookieDomain(request: Request) {
export async function resolveCookieDomain(request: Request) {
const config = await AppConfig.getConfig();
const baseUrl = config.auth.baseUrl;
@@ -65,7 +67,9 @@ async function resolveCookieDomain(request: Request) {
return '';
}
export async function getDataFromRequest(request: Request) {
export async function getDataFromRequest(
request: Request,
): Promise<{ user: User; session: UserSession | undefined; tokenData?: JwtData['data'] } | null> {
const cookies = getCookies(request.headers);
const authorizationHeader = request.headers.get('authorization');
@@ -119,7 +123,9 @@ async function getDataFromAuthorizationHeader(authorizationHeader: string) {
return null;
}
async function getDataFromCookie(cookieValue: string) {
async function getDataFromCookie(
cookieValue: string,
): Promise<{ user: User; session: UserSession | undefined; tokenData?: JwtData['data'] } | null> {
if (!cookieValue) {
return null;
}
@@ -139,7 +145,7 @@ async function getDataFromCookie(cookieValue: string) {
return null;
}
export async function generateToken(tokenData: JwtData['data']) {
export async function generateToken(tokenData: JwtData['data']): Promise<string> {
const key = await generateKey(JWT_SECRET);
const token = await signAuthJwt(key, { data: tokenData });

View File

@@ -11,6 +11,7 @@ export class AppConfig {
allowSignups: false,
enableEmailVerification: false,
enableForeverSignup: true,
enableMultiFactor: false,
allowedCookieDomains: [],
skipCookieDomainSecurity: false,
},
@@ -193,6 +194,12 @@ export class AppConfig {
return this.config.auth.enableForeverSignup;
}
static async isMultiFactorAuthEnabled(): Promise<boolean> {
await this.loadConfig();
return this.config.auth.enableMultiFactor;
}
static async getFilesRootPath(): Promise<string> {
await this.loadConfig();

View File

@@ -61,7 +61,19 @@ export default class Database {
this.db = postgresClient;
} else {
throw error;
console.log('Failed to connect to Postgres!');
console.error(error);
// This allows tests (and the app) to work even if Postgres is not available
const mockPostgresClient = {
queryObject: () => {
return {
rows: [],
};
},
} as unknown as Client;
this.db = mockPostgresClient;
}
}
}

View File

@@ -0,0 +1,142 @@
import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts';
import { MultiFactorAuthMethod, User } from '/lib/types.ts';
import { getMultiFactorAuthMethodByIdFromUser } from '/lib/utils/multi-factor-auth.ts';
import {
COOKIE_NAME as AUTH_COOKIE_NAME,
generateKey,
generateToken,
JWT_SECRET,
JwtData,
resolveCookieDomain,
verifyAuthJwt,
} from '/lib/auth.ts';
import { isRunningLocally } from '/lib/utils/misc.ts';
import { AppConfig } from '/lib/config.ts';
import { UserModel } from './user.ts';
const COOKIE_NAME = `${AUTH_COOKIE_NAME}-mfa`;
const MFA_SESSION_ID = 'mfa';
export interface MultiFactorAuthSetup {
method: MultiFactorAuthMethod;
qrCodeUrl?: string;
plainTextSecret?: string;
plainTextBackupCodes?: string[];
}
export class MultiFactorAuthModel {
static generateMethodId(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
static enableMethodForUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
methodId: string,
): void {
const method = getMultiFactorAuthMethodByIdFromUser(user, methodId);
if (method) {
method.enabled = true;
}
}
static disableMethodFromUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
methodId: string,
): void {
const method = getMultiFactorAuthMethodByIdFromUser(user, methodId);
if (method) {
method.enabled = false;
}
}
static async createSessionResponse(
request: Request,
user: User,
{ urlToRedirectTo = '/' }: {
urlToRedirectTo?: string;
} = {},
) {
const response = new Response('MFA Required', {
status: 303,
headers: {
'Location': `/mfa-verify?user=${user.id}&redirect=${encodeURIComponent(urlToRedirectTo)}`,
'Content-Type': 'text/html; charset=utf-8',
},
});
const responseWithCookie = await this.createSessionCookie(request, user, response);
return responseWithCookie;
}
private static async createSessionCookie(
request: Request,
user: User,
response: Response,
) {
const token = await generateToken({ user_id: user.id, session_id: MFA_SESSION_ID });
const cookie: Cookie = {
name: COOKIE_NAME,
value: token,
expires: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes
path: '/',
secure: isRunningLocally(request) ? false : true,
httpOnly: true,
sameSite: 'Lax',
domain: await resolveCookieDomain(request),
};
if (await AppConfig.isCookieDomainSecurityDisabled()) {
delete cookie.domain;
}
setCookie(response.headers, cookie);
return response;
}
static async getDataFromRequest(request: Request): Promise<{ user: User } | null> {
const cookies = getCookies(request.headers);
if (cookies[COOKIE_NAME]) {
const result = await this.getDataFromCookie(cookies[COOKIE_NAME]);
if (result) {
return result;
}
}
return null;
}
private static async getDataFromCookie(cookieValue: string): Promise<{ user: User } | null> {
if (!cookieValue) {
return null;
}
const key = await generateKey(JWT_SECRET);
try {
const token = await verifyAuthJwt(key, cookieValue) as JwtData;
const user = await UserModel.getById(token.data.user_id);
if (!user || token.data.session_id !== MFA_SESSION_ID) {
throw new Error('Not Found');
}
return { user };
} catch (error) {
console.error(error);
}
return null;
}
}

View File

@@ -0,0 +1,198 @@
import {
AuthenticationResponseJSON,
generateAuthenticationOptions,
generateRegistrationOptions,
PublicKeyCredentialCreationOptionsJSON,
RegistrationResponseJSON,
VerifiedAuthenticationResponse,
VerifiedRegistrationResponse,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { MultiFactorAuthMethod, User } from '/lib/types.ts';
export interface PasskeyCredential {
credentialID: string;
credentialPublicKey: string;
counter: number;
credentialDeviceType: string;
credentialBackedUp: boolean;
transports?: AuthenticatorTransport[];
}
export interface PasskeySetupData {
methodId: string;
options: PublicKeyCredentialCreationOptionsJSON;
}
export interface PasskeyAuthenticationData {
options: PublicKeyCredentialCreationOptionsJSON;
}
const RP_NAME = 'bewCloud';
const RP_ID = (baseUrl: string) => {
try {
return new URL(baseUrl).hostname;
} catch {
return 'localhost';
}
};
/**
* Excludes Ed25519 as per https://simplewebauthn.dev/docs/packages/server#domexception-notsupportederror-unrecognized-name
*/
const SUPPORTED_ALGORITHM_IDS = [-7, -257];
export class PasskeyModel {
static async generateRegistrationOptions(
userId: string,
email: string,
baseUrl: string,
existingCredentials: PasskeyCredential[] = [],
): Promise<PublicKeyCredentialCreationOptionsJSON> {
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID(baseUrl),
userID: new TextEncoder().encode(userId),
userName: email,
userDisplayName: email,
attestationType: 'none',
excludeCredentials: existingCredentials.map((credential) => ({
id: credential.credentialID,
type: 'public-key',
transports: credential.transports || [],
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
authenticatorAttachment: 'platform',
},
supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
});
return options;
}
static async verifyRegistration(
response: RegistrationResponseJSON,
expectedChallenge: string,
expectedOrigin: string,
expectedRPID: string,
): Promise<VerifiedRegistrationResponse> {
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin,
expectedRPID,
supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
});
return verification;
}
static async generateAuthenticationOptions(
baseUrl: string,
allowedCredentials?: PasskeyCredential[],
): Promise<PublicKeyCredentialCreationOptionsJSON> {
const options = await generateAuthenticationOptions({
rpID: RP_ID(baseUrl),
allowCredentials: allowedCredentials?.map((credential) => ({
id: credential.credentialID,
type: 'public-key',
transports: credential.transports,
})),
userVerification: 'preferred',
});
return options as PublicKeyCredentialCreationOptionsJSON;
}
static async verifyAuthentication(
response: AuthenticationResponseJSON,
expectedChallenge: string,
expectedOrigin: string,
expectedRPID: string,
credential: PasskeyCredential,
): Promise<VerifiedAuthenticationResponse> {
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin,
expectedRPID,
credential: {
id: credential.credentialID,
publicKey: isoBase64URL.toBuffer(credential.credentialPublicKey),
counter: credential.counter,
transports: credential.transports,
},
});
return verification;
}
static createMethod(
id: string,
name: string,
credentialID: string,
credentialPublicKey: string,
counter: number,
credentialDeviceType: string,
credentialBackedUp: boolean,
transports?: AuthenticatorTransport[],
): MultiFactorAuthMethod {
return {
type: 'passkey',
id,
name,
enabled: false,
created_at: new Date(),
metadata: {
passkey: {
credential_id: credentialID,
public_key: credentialPublicKey,
counter,
device_type: credentialDeviceType,
backed_up: credentialBackedUp,
transports,
},
},
};
}
static getCredentialsFromUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
): PasskeyCredential[] {
if (!user.extra.multi_factor_auth_methods) return [];
return user.extra.multi_factor_auth_methods
.filter((method) => method.type === 'passkey' && method.enabled && method.metadata.passkey)
.map((method) => ({
credentialID: method.metadata.passkey!.credential_id,
credentialPublicKey: method.metadata.passkey!.public_key,
counter: method.metadata.passkey!.counter || 0,
credentialDeviceType: method.metadata.passkey!.device_type || 'unknown',
credentialBackedUp: method.metadata.passkey!.backed_up || false,
transports: method.metadata.passkey!.transports,
}));
}
static updateCounterForUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
credentialID: string,
newCounter: number,
): void {
if (!user.extra.multi_factor_auth_methods) {
return;
}
const method = user.extra.multi_factor_auth_methods.find(
(method) => method.type === 'passkey' && method.metadata.passkey?.credential_id === credentialID,
);
if (method?.metadata.passkey) {
method.metadata.passkey.counter = newCounter;
}
}
}

View File

@@ -0,0 +1,227 @@
import { Secret, TOTP } from 'otpauth';
import QRCode from 'qrcode';
import { encodeBase32 } from 'std/encoding/base32.ts';
import { decodeBase64, encodeBase64 } from 'std/encoding/base64.ts';
import { MultiFactorAuthMethod } from '/lib/types.ts';
import { MFA_KEY, MFA_SALT } from '/lib/auth.ts';
import { generateHash } from '/lib/utils/misc.ts';
import { MultiFactorAuthSetup } from '/lib/models/multi-factor-auth.ts';
export class TOTPModel {
private static async getEncryptionKey(): Promise<CryptoKey> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(MFA_KEY),
{ name: 'PBKDF2' },
false,
['deriveKey'],
);
return await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: new TextEncoder().encode(MFA_SALT),
iterations: 100000,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
private static generateBackupCodes(count = 8): string[] {
const codes: string[] = [];
for (let i = 0; i < count; i++) {
const bytes = new Uint8Array(4);
crypto.getRandomValues(bytes);
const code = Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
.substring(0, 8);
codes.push(code);
}
return codes;
}
private static async hashBackupCodes(codes: string[]): Promise<string[]> {
const hashedCodes: string[] = [];
for (const code of codes) {
const hashedCode = await generateHash(`${code}:${MFA_SALT}`, 'SHA-256');
hashedCodes.push(hashedCode);
}
return hashedCodes;
}
private static async verifyBackupCodeHash(
code: string,
hashedCodes: string[],
): Promise<{ isValid: boolean; codeIndex: number }> {
const hashedInput = await generateHash(`${code}:${MFA_SALT}`, 'SHA-256');
const codeIndex = hashedCodes.indexOf(hashedInput);
return { isValid: codeIndex !== -1, codeIndex };
}
private static async verifyBackupCodeHashed(
hashedBackupCodes: string[],
providedCode: string,
): Promise<{ isValid: boolean; remainingCodes: string[] }> {
const { isValid, codeIndex } = await this.verifyBackupCodeHash(providedCode, hashedBackupCodes);
if (!isValid) {
return { isValid: false, remainingCodes: hashedBackupCodes };
}
const remainingCodes = [...hashedBackupCodes];
remainingCodes.splice(codeIndex, 1);
return { isValid: true, remainingCodes };
}
private static async encryptTOTPSecret(secret: string): Promise<string> {
const key = await this.getEncryptionKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const encodedSecret = new TextEncoder().encode(secret);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encodedSecret,
);
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return encodeBase64(combined);
}
static async decryptTOTPSecret(encryptedSecret: string): Promise<string> {
const key = await this.getEncryptionKey();
const combined = decodeBase64(encryptedSecret);
const iv = combined.slice(0, 12);
const encrypted = combined.slice(12);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
encrypted,
);
return new TextDecoder().decode(decrypted);
}
private static generateTOTPSecret(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
return encodeBase32(bytes);
}
private static createTOTP(secret: string, issuer: string, accountName: string): TOTP {
return new TOTP({
issuer,
label: accountName,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: Secret.fromBase32(secret),
});
}
private static async generateQRCodeDataURL(secret: string, issuer: string, accountName: string): Promise<string> {
const totp = this.createTOTP(secret, issuer, accountName);
const uri = totp.toString();
return await QRCode.toDataURL(uri);
}
private static verifyTOTPToken(secret: string, token: string, window = 1): boolean {
const totp = new TOTP({
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: Secret.fromBase32(secret),
});
const currentTime = Math.floor(Date.now() / 1000);
for (let i = -window; i <= window; i++) {
const testTime = currentTime + (i * 30);
const expectedToken = totp.generate({ timestamp: testTime * 1000 });
if (expectedToken === token) {
return true;
}
}
return false;
}
static async createMethod(
id: string,
name: string,
issuer: string,
accountName: string,
): Promise<MultiFactorAuthSetup> {
const secret = this.generateTOTPSecret();
const backupCodes = this.generateBackupCodes();
const qrCodeUrl = await this.generateQRCodeDataURL(secret, issuer, accountName);
const encryptedSecret = await this.encryptTOTPSecret(secret);
const hashedBackupCodes = await this.hashBackupCodes(backupCodes);
const method: MultiFactorAuthMethod = {
type: 'totp',
id,
name,
enabled: false,
created_at: new Date(),
metadata: {
totp: {
hashed_secret: encryptedSecret,
hashed_backup_codes: hashedBackupCodes,
},
},
};
return {
method,
qrCodeUrl,
plainTextSecret: secret,
plainTextBackupCodes: backupCodes,
};
}
static async verifyMethodToken(
metadata: MultiFactorAuthMethod['metadata'],
token: string,
): Promise<{ isValid: boolean; remainingCodes?: string[] }> {
if (!metadata.totp) {
return { isValid: false };
}
const { totp } = metadata;
if (token.length === 6 && /^\d+$/.test(token)) { // Try the TOTP first
try {
const decryptedSecret = await this.decryptTOTPSecret(totp.hashed_secret);
const isValid = this.verifyTOTPToken(decryptedSecret, token);
return { isValid };
} catch {
return { isValid: false };
}
} else if (token.length === 8 && /^[a-fA-F0-9]+$/.test(token)) { // Otherwise, try the backup codes
const { isValid, remainingCodes } = await this.verifyBackupCodeHashed(
totp.hashed_backup_codes,
token.toLowerCase(),
);
return { isValid, remainingCodes };
}
return { isValid: false };
}
static verifyTOTP(secret: string, token: string): boolean {
return this.verifyTOTPToken(secret, token);
}
}

View File

@@ -13,6 +13,7 @@ export interface User {
is_admin?: boolean;
dav_hashed_password?: string;
expenses_currency?: SupportedCurrencySymbol;
multi_factor_auth_methods?: MultiFactorAuthMethod[];
};
created_at: Date;
}
@@ -156,6 +157,8 @@ export interface Config {
enableEmailVerification: boolean;
/** If true, all signups become active for 100 years */
enableForeverSignup: boolean;
/** If true, users can enable multi-factor authentication (TOTP or Passkeys) */
enableMultiFactor: boolean;
/** Can be set to allow more than the baseUrl's domain for session cookies */
allowedCookieDomains: string[];
/** If true, the cookie domain will not be strictly set and checked against. This skipping slightly reduces security, but is usually necessary for reverse proxies like Cloudflare Tunnel. */
@@ -178,3 +181,27 @@ export interface Config {
helpEmail: string;
};
}
export type MultiFactorAuthMethodType = 'totp' | 'passkey';
export interface MultiFactorAuthMethod {
type: MultiFactorAuthMethodType;
id: string;
name: string;
enabled: boolean;
created_at: Date;
metadata: {
totp?: {
hashed_secret: string;
hashed_backup_codes: string[];
};
passkey?: {
credential_id: string;
public_key: string;
counter?: number;
device_type?: string;
backed_up?: boolean;
transports?: AuthenticatorTransport[];
};
};
}

View File

@@ -0,0 +1,28 @@
// This file contains some multi-factor authentication utilities that are isomorphic.
import { MultiFactorAuthMethod, User } from '/lib/types.ts';
export function getMultiFactorAuthMethodsFromUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
): MultiFactorAuthMethod[] {
return user.extra.multi_factor_auth_methods || [];
}
export function getEnabledMultiFactorAuthMethodsFromUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
): MultiFactorAuthMethod[] {
return getMultiFactorAuthMethodsFromUser(user).filter((method) => method.enabled);
}
export function getMultiFactorAuthMethodByIdFromUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
id: string,
): MultiFactorAuthMethod | undefined {
return getMultiFactorAuthMethodsFromUser(user).find((method) => method.id === id);
}
export function isMultiFactorAuthEnabledForUser(
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
): boolean {
return getEnabledMultiFactorAuthMethodsFromUser(user).length > 0;
}