* 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>
143 lines
3.6 KiB
TypeScript
143 lines
3.6 KiB
TypeScript
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;
|
|
}
|
|
}
|