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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user