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:
22
lib/auth.ts
22
lib/auth.ts
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
142
lib/models/multi-factor-auth.ts
Normal file
142
lib/models/multi-factor-auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
198
lib/models/multi-factor-auth/passkey.ts
Normal file
198
lib/models/multi-factor-auth/passkey.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
227
lib/models/multi-factor-auth/totp.ts
Normal file
227
lib/models/multi-factor-auth/totp.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
27
lib/types.ts
27
lib/types.ts
@@ -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[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
28
lib/utils/multi-factor-auth.ts
Normal file
28
lib/utils/multi-factor-auth.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user