feat(two-factor): refactor two-factor authentication with better error handling, configurable otp limits and verification (#2234)

* feat(two-factor): refactor two-factor authentication with better error handling, configurable otp limits and verification

* address review comments
This commit is contained in:
Bereket Engida
2025-04-12 21:41:52 +03:00
committed by GitHub
parent a445e897c0
commit de91c26708
9 changed files with 306 additions and 212 deletions

View File

@@ -61,7 +61,6 @@ export const verificationSchema = z.object({
updatedAt: z.date().default(() => new Date()),
expiresAt: z.date(),
identifier: z.string(),
nonce: z.string().nullish(),
});
export function parseOutputData<T extends Record<string, any>>(

View File

@@ -3,15 +3,14 @@ import { z } from "zod";
import { createAuthEndpoint } from "../../../api/call";
import { sessionMiddleware } from "../../../api";
import { symmetricDecrypt, symmetricEncrypt } from "../../../crypto";
import { verifyTwoFactorMiddleware } from "../verify-middleware";
import type {
TwoFactorProvider,
TwoFactorTable,
UserWithTwoFactor,
} from "../types";
import { APIError } from "better-call";
import { setSessionCookie } from "../../../cookies";
import { TWO_FACTOR_ERROR_CODES } from "../error-code";
import { verifyTwoFactor } from "../verify-two-factor";
export interface BackupCodeOptions {
/**
@@ -120,7 +119,6 @@ export const backupCode2fa = (options?: BackupCodeOptions) => {
})
.optional(),
}),
use: [verifyTwoFactorMiddleware],
metadata: {
openapi: {
description: "Verify a backup code for two-factor authentication",
@@ -233,7 +231,8 @@ export const backupCode2fa = (options?: BackupCodeOptions) => {
},
},
async (ctx) => {
const user = ctx.context.session.user as UserWithTwoFactor;
const { session, valid } = await verifyTwoFactor(ctx);
const user = session.user as UserWithTwoFactor;
const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({
model: twoFactorTable,
where: [
@@ -279,14 +278,19 @@ export const backupCode2fa = (options?: BackupCodeOptions) => {
});
if (!ctx.body.disableSession) {
await setSessionCookie(ctx, {
session: ctx.context.session.session,
user,
});
return valid(ctx);
}
return ctx.json({
user: user,
session: ctx.context.session,
token: session.session?.token,
user: {
id: session.user?.id,
email: session.user.email,
emailVerified: session.user.emailVerified,
name: session.user.name,
image: session.user.image,
createdAt: session.user.createdAt,
updatedAt: session.user.updatedAt,
},
});
},
),

View File

@@ -5,4 +5,8 @@ export const TWO_FACTOR_ERROR_CODES = {
TWO_FACTOR_NOT_ENABLED: "Two factor isn't enabled",
BACKUP_CODES_NOT_ENABLED: "Backup codes aren't enabled",
INVALID_BACKUP_CODE: "Invalid backup code",
INVALID_CODE: "Invalid code",
TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE:
"Too many attempts. Please request a new code.",
INVALID_TWO_FACTOR_COOKIE: "Invalid two factor cookie",
} as const;

View File

@@ -296,21 +296,22 @@ export const twoFactor = (options?: TwoFactorOptions) => {
*/
deleteSessionCookie(ctx, true);
await ctx.context.internalAdapter.deleteSession(data.session.token);
const maxAge = options?.otpOptions?.period || 60 * 5; // 5 minutes
const twoFactorCookie = ctx.context.createAuthCookie(
TWO_FACTOR_COOKIE_NAME,
{
maxAge: 60 * 10, // 10 minutes
maxAge,
},
);
/**
* We set the user id and the session
* id as a hash. Later will fetch for
* sessions with the user id compare
* the hash and set that as session.
*/
const identifier = `2fa-${generateRandomString(20)}`;
await ctx.context.internalAdapter.createVerificationValue({
value: data.user.id,
identifier,
expiresAt: new Date(Date.now() + maxAge * 1000),
});
await ctx.setSignedCookie(
twoFactorCookie.name,
data.user.id,
identifier,
ctx.context.secret,
twoFactorCookie.attributes,
);

View File

@@ -1,7 +1,7 @@
import { APIError } from "better-call";
import { z } from "zod";
import { createAuthEndpoint } from "../../../api/call";
import { verifyTwoFactorMiddleware } from "../verify-middleware";
import { verifyTwoFactor } from "../verify-two-factor";
import type {
TwoFactorProvider,
TwoFactorTable,
@@ -10,6 +10,7 @@ import type {
import { TWO_FACTOR_ERROR_CODES } from "../error-code";
import { generateRandomString } from "../../../crypto";
import { setSessionCookie } from "../../../cookies";
import { BASE_ERROR_CODES } from "../../../error/codes";
export interface OTPOptions {
/**
@@ -48,6 +49,12 @@ export interface OTPOptions {
*/
request?: Request,
) => Promise<void> | void;
/**
* The number of allowed attempts for the OTP
*
* @default 5
*/
allowedAttempts?: number;
}
/**
@@ -78,7 +85,6 @@ export const otp2fa = (options?: OTPOptions) => {
trustDevice: z.boolean().optional(),
})
.optional(),
use: [verifyTwoFactorMiddleware],
metadata: {
openapi: {
summary: "Send two factor OTP",
@@ -112,13 +118,13 @@ export const otp2fa = (options?: OTPOptions) => {
message: "otp isn't configured",
});
}
const user = ctx.context.session.user as UserWithTwoFactor;
const { session, key } = await verifyTwoFactor(ctx);
const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({
model: twoFactorTable,
where: [
{
field: "userId",
value: user.id,
value: session.user.id,
},
],
});
@@ -129,11 +135,14 @@ export const otp2fa = (options?: OTPOptions) => {
}
const code = generateRandomString(opts.digits, "0-9");
await ctx.context.internalAdapter.createVerificationValue({
value: code,
identifier: `2fa-otp-${user.id}`,
value: `${code}!0`,
identifier: `2fa-otp-${key}`,
expiresAt: new Date(Date.now() + opts.period),
});
await options.sendOTP({ user, otp: code }, ctx.request);
await options.sendOTP(
{ user: session.user as UserWithTwoFactor, otp: code },
ctx.request,
);
return ctx.json({ status: true });
},
);
@@ -153,7 +162,6 @@ export const otp2fa = (options?: OTPOptions) => {
*/
trustDevice: z.boolean().optional(),
}),
use: [verifyTwoFactorMiddleware],
metadata: {
openapi: {
summary: "Verify two factor OTP",
@@ -226,13 +234,13 @@ export const otp2fa = (options?: OTPOptions) => {
},
},
async (ctx) => {
const user = ctx.context.session.user;
const { session, key, valid, invalid } = await verifyTwoFactor(ctx);
const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({
model: twoFactorTable,
where: [
{
field: "userId",
value: user.id,
value: session.user.id,
},
],
});
@@ -243,39 +251,74 @@ export const otp2fa = (options?: OTPOptions) => {
}
const toCheckOtp =
await ctx.context.internalAdapter.findVerificationValue(
`2fa-otp-${user.id}`,
`2fa-otp-${key}`,
);
const [otp, counter] = toCheckOtp?.value?.split("!") ?? [];
if (!toCheckOtp || toCheckOtp.expiresAt < new Date()) {
await ctx.context.internalAdapter.deleteVerificationValue(
`2fa-otp-${key}`,
);
throw new APIError("BAD_REQUEST", {
message: TWO_FACTOR_ERROR_CODES.OTP_HAS_EXPIRED,
});
}
if (toCheckOtp.value === ctx.body.code) {
if (!user.twoFactorEnabled) {
const allowedAttempts = options?.allowedAttempts || 5;
if (parseInt(counter) >= allowedAttempts) {
await ctx.context.internalAdapter.deleteVerificationValue(
`2fa-otp-${key}`,
);
throw new APIError("BAD_REQUEST", {
message: TWO_FACTOR_ERROR_CODES.TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE,
});
}
if (otp === ctx.body.code) {
if (!session.user.twoFactorEnabled) {
if (!session.session) {
throw new APIError("BAD_REQUEST", {
message: BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION,
});
}
const updatedUser = await ctx.context.internalAdapter.updateUser(
user.id,
session.user.id,
{
twoFactorEnabled: true,
},
);
const newSession = await ctx.context.internalAdapter.createSession(
user.id,
session.user.id,
ctx.headers,
false,
ctx.context.session.session,
session.session,
);
await ctx.context.internalAdapter.deleteSession(
ctx.context.session.session.token,
session.session.token,
);
await setSessionCookie(ctx, {
session: newSession,
user: updatedUser,
});
return ctx.json({
token: newSession.token,
user: {
id: updatedUser.id,
email: updatedUser.email,
emailVerified: updatedUser.emailVerified,
name: updatedUser.name,
image: updatedUser.image,
createdAt: updatedUser.createdAt,
updatedAt: updatedUser.updatedAt,
},
});
}
return ctx.context.valid(ctx);
return valid(ctx);
} else {
return ctx.context.invalid();
await ctx.context.internalAdapter.updateVerificationValue(
toCheckOtp.id,
{
value: `${otp}!${parseInt(counter) + 1}`,
},
);
return invalid("INVALID_CODE");
}
},
);

View File

@@ -4,7 +4,7 @@ import { createAuthEndpoint } from "../../../api/call";
import { sessionMiddleware } from "../../../api";
import { symmetricDecrypt } from "../../../crypto";
import type { BackupCodeOptions } from "../backup-codes";
import { verifyTwoFactorMiddleware } from "../verify-middleware";
import { verifyTwoFactor } from "../verify-two-factor";
import type {
TwoFactorProvider,
TwoFactorTable,
@@ -13,6 +13,7 @@ import type {
import { setSessionCookie } from "../../../cookies";
import { TWO_FACTOR_ERROR_CODES } from "../error-code";
import { createOTP } from "@better-auth/utils/otp";
import { BASE_ERROR_CODES } from "../../../error/codes";
export type TOTPOptions = {
/**
@@ -53,7 +54,11 @@ export const totp2fa = (options?: TOTPOptions) => {
"/totp/generate",
{
method: "POST",
use: [sessionMiddleware],
body: z.object({
secret: z.string({
description: "The secret to generate the TOTP code",
}),
}),
metadata: {
openapi: {
summary: "Generate TOTP code",
@@ -76,6 +81,7 @@ export const totp2fa = (options?: TOTPOptions) => {
},
},
},
SERVER_ONLY: true,
},
},
async (ctx) => {
@@ -87,22 +93,7 @@ export const totp2fa = (options?: TOTPOptions) => {
message: "totp isn't configured",
});
}
const user = ctx.context.session.user as UserWithTwoFactor;
const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({
model: twoFactorTable,
where: [
{
field: "userId",
value: user.id,
},
],
});
if (!twoFactor) {
throw new APIError("BAD_REQUEST", {
message: TWO_FACTOR_ERROR_CODES.TOTP_NOT_ENABLED,
});
}
const code = await createOTP(twoFactor.secret, {
const code = await createOTP(ctx.body.secret, {
period: opts.period,
digits: opts.digits,
}).totp();
@@ -203,7 +194,6 @@ export const totp2fa = (options?: TOTPOptions) => {
})
.optional(),
}),
use: [verifyTwoFactorMiddleware],
metadata: {
openapi: {
summary: "Verify two factor TOTP",
@@ -237,7 +227,8 @@ export const totp2fa = (options?: TOTPOptions) => {
message: "totp isn't configured",
});
}
const user = ctx.context.session.user as UserWithTwoFactor;
const { session, valid, invalid } = await verifyTwoFactor(ctx);
const user = session.user as UserWithTwoFactor;
const twoFactor = await ctx.context.adapter.findOne<TwoFactorTable>({
model: twoFactorTable,
where: [
@@ -262,10 +253,15 @@ export const totp2fa = (options?: TOTPOptions) => {
digits: opts.digits,
}).verify(ctx.body.code);
if (!status) {
return ctx.context.invalid();
return invalid("INVALID_CODE");
}
if (!user.twoFactorEnabled) {
if (!session.session) {
throw new APIError("BAD_REQUEST", {
message: BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION,
});
}
const updatedUser = await ctx.context.internalAdapter.updateUser(
user.id,
{
@@ -274,25 +270,18 @@ export const totp2fa = (options?: TOTPOptions) => {
ctx,
);
const newSession = await ctx.context.internalAdapter
.createSession(
user.id,
ctx.headers,
false,
ctx.context.session.session,
)
.createSession(user.id, ctx.headers, false, session.session)
.catch((e) => {
throw e;
});
await ctx.context.internalAdapter.deleteSession(
ctx.context.session.session.token,
);
await ctx.context.internalAdapter.deleteSession(session.session.token);
await setSessionCookie(ctx, {
session: newSession,
user: updatedUser,
});
}
return ctx.context.valid(ctx);
return valid(ctx);
},
);
return {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { twoFactor, twoFactorClient } from ".";
import { TWO_FACTOR_ERROR_CODES, twoFactor, twoFactorClient } from ".";
import { createAuthClient } from "../../client";
import { parseSetCookieHeader } from "../../cookies";
import type { TwoFactorTable, UserWithTwoFactor } from "./types";
@@ -52,7 +52,6 @@ describe("two factor", async () => {
headers,
},
});
expect(res.data?.backupCodes.length).toEqual(10);
expect(res.data?.totpURI).toBeDefined();
const dbUser = await db.findOne<UserWithTwoFactor>({
@@ -193,6 +192,18 @@ describe("two factor", async () => {
expect(verifyRes.data?.token).toBeDefined();
});
it("should fail if two factor cookie is missing", async () => {
const res = await client.twoFactor.verifyTotp({
code: "123456",
fetchOptions: {
headers,
},
});
expect(res.error?.message).toBe(
TWO_FACTOR_ERROR_CODES.INVALID_TWO_FACTOR_COOKIE,
);
});
let backupCodes: string[] = [];
it("should generate backup codes", async () => {
await client.twoFactor.enable({
@@ -338,6 +349,53 @@ describe("two factor", async () => {
expect(signInRes.data?.user).toBeDefined();
});
it("should limit OTP verification attempts", async () => {
const headers = new Headers();
// Sign in to trigger 2FA
await client.signIn.email({
email: testUser.email,
password: testUser.password,
fetchOptions: {
onSuccess(context) {
const parsed = parseSetCookieHeader(
context.response.headers.get("Set-Cookie") || "",
);
headers.append(
"cookie",
`better-auth.two_factor=${
parsed.get("better-auth.two_factor")?.value
}`,
);
},
},
});
await client.twoFactor.sendOtp({
fetchOptions: {
headers,
},
});
for (let i = 0; i < 5; i++) {
const res = await client.twoFactor.verifyOtp({
code: "000000", // Invalid code
fetchOptions: {
headers,
},
});
expect(res.error?.message).toBe("Invalid code");
}
// Next attempt should be blocked
const res = await client.twoFactor.verifyOtp({
code: OTP, // Even with correct code
fetchOptions: {
headers,
},
});
expect(res.error?.message).toBe(
"Too many attempts. Please request a new code.",
);
});
it("should disable two factor", async () => {
const res = await client.twoFactor.disable({
password: testUser.password,

View File

@@ -1,140 +0,0 @@
import { APIError } from "better-call";
import { createAuthMiddleware } from "../../api/call";
import { TRUST_DEVICE_COOKIE_NAME, TWO_FACTOR_COOKIE_NAME } from "./constant";
import { setSessionCookie } from "../../cookies";
import { z } from "zod";
import { getSessionFromCtx } from "../../api";
import type { UserWithTwoFactor } from "./types";
import { createHMAC } from "@better-auth/utils/hmac";
import type { GenericEndpointContext } from "../../types";
export const verifyTwoFactorMiddleware = createAuthMiddleware(
{
body: z.object({
/**
* if true, the device will be trusted
* for 30 days. It'll be refreshed on
* every sign in request within this time.
*/
trustDevice: z.boolean().optional(),
}),
},
async (ctx) => {
const session = await getSessionFromCtx(ctx);
if (!session) {
const cookieName = ctx.context.createAuthCookie(TWO_FACTOR_COOKIE_NAME);
const userId = await ctx.getSignedCookie(
cookieName.name,
ctx.context.secret,
);
if (!userId) {
throw new APIError("UNAUTHORIZED", {
message: "invalid two factor cookie",
});
}
const user = (await ctx.context.internalAdapter.findUserById(
userId,
)) as UserWithTwoFactor;
if (!user) {
throw new APIError("UNAUTHORIZED", {
message: "invalid two factor cookie",
});
}
const dontRememberMe = await ctx.getSignedCookie(
ctx.context.authCookies.dontRememberToken.name,
ctx.context.secret,
);
const session = await ctx.context.internalAdapter.createSession(
userId,
ctx.headers,
!!dontRememberMe,
);
if (!session) {
throw new APIError("INTERNAL_SERVER_ERROR", {
message: "failed to create session",
});
}
return {
valid: async (ctx: GenericEndpointContext) => {
await setSessionCookie(ctx, {
session,
user,
});
if (ctx.body.trustDevice) {
const trustDeviceCookie = ctx.context.createAuthCookie(
TRUST_DEVICE_COOKIE_NAME,
{
maxAge: 30 * 24 * 60 * 60, // 30 days, it'll be refreshed on sign in requests
},
);
/**
* create a token that will be used to
* verify the device
*/
const token = await createHMAC("SHA-256", "base64urlnopad").sign(
ctx.context.secret,
`${user.id}!${session.token}`,
);
await ctx.setSignedCookie(
trustDeviceCookie.name,
`${token}!${session.token}`,
ctx.context.secret,
trustDeviceCookie.attributes,
);
// delete the dont remember me cookie
ctx.setCookie(ctx.context.authCookies.dontRememberToken.name, "", {
maxAge: 0,
});
// delete the two factor cookie
ctx.setCookie(cookieName.name, "", {
maxAge: 0,
});
}
return ctx.json({
token: session.token,
user: {
id: user.id,
email: user.email,
emailVerified: user.emailVerified,
name: user.name,
image: user.image,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
});
},
invalid: async () => {
throw new APIError("UNAUTHORIZED", {
message: "invalid two factor authentication",
});
},
session: {
session,
user,
},
};
}
return {
valid: async (ctx: GenericEndpointContext) => {
return ctx.json({
token: session.session.token,
user: {
id: session.user.id,
email: session.user.email,
emailVerified: session.user.emailVerified,
name: session.user.name,
image: session.user.image,
createdAt: session.user.createdAt,
updatedAt: session.user.updatedAt,
},
});
},
invalid: async () => {
throw new APIError("UNAUTHORIZED", {
message: "invalid two factor authentication",
});
},
session,
};
},
);

View File

@@ -0,0 +1,136 @@
import { APIError } from "better-call";
import { TRUST_DEVICE_COOKIE_NAME, TWO_FACTOR_COOKIE_NAME } from "./constant";
import { setSessionCookie } from "../../cookies";
import { getSessionFromCtx } from "../../api";
import type { UserWithTwoFactor } from "./types";
import { createHMAC } from "@better-auth/utils/hmac";
import type { GenericEndpointContext } from "../../types";
import { TWO_FACTOR_ERROR_CODES } from "./error-code";
export async function verifyTwoFactor(ctx: GenericEndpointContext) {
const session = await getSessionFromCtx(ctx);
if (!session) {
const cookieName = ctx.context.createAuthCookie(TWO_FACTOR_COOKIE_NAME);
const twoFactorCookie = await ctx.getSignedCookie(
cookieName.name,
ctx.context.secret,
);
if (!twoFactorCookie) {
throw new APIError("UNAUTHORIZED", {
message: TWO_FACTOR_ERROR_CODES.INVALID_TWO_FACTOR_COOKIE,
});
}
const verificationToken =
await ctx.context.internalAdapter.findVerificationValue(twoFactorCookie);
if (!verificationToken) {
throw new APIError("UNAUTHORIZED", {
message: TWO_FACTOR_ERROR_CODES.INVALID_TWO_FACTOR_COOKIE,
});
}
const user = (await ctx.context.internalAdapter.findUserById(
verificationToken.value,
)) as UserWithTwoFactor;
if (!user) {
throw new APIError("UNAUTHORIZED", {
message: TWO_FACTOR_ERROR_CODES.INVALID_TWO_FACTOR_COOKIE,
});
}
const dontRememberMe = await ctx.getSignedCookie(
ctx.context.authCookies.dontRememberToken.name,
ctx.context.secret,
);
return {
valid: async (ctx: GenericEndpointContext) => {
const session = await ctx.context.internalAdapter.createSession(
verificationToken.value,
ctx.headers,
!!dontRememberMe,
);
if (!session) {
throw new APIError("INTERNAL_SERVER_ERROR", {
message: "failed to create session",
});
}
await setSessionCookie(ctx, {
session,
user,
});
if (ctx.body.trustDevice) {
const trustDeviceCookie = ctx.context.createAuthCookie(
TRUST_DEVICE_COOKIE_NAME,
{
maxAge: 30 * 24 * 60 * 60, // 30 days, it'll be refreshed on sign in requests
},
);
/**
* create a token that will be used to
* verify the device
*/
const token = await createHMAC("SHA-256", "base64urlnopad").sign(
ctx.context.secret,
`${user.id}!${session.token}`,
);
await ctx.setSignedCookie(
trustDeviceCookie.name,
`${token}!${session.token}`,
ctx.context.secret,
trustDeviceCookie.attributes,
);
// delete the dont remember me cookie
ctx.setCookie(ctx.context.authCookies.dontRememberToken.name, "", {
maxAge: 0,
});
// delete the two factor cookie
ctx.setCookie(cookieName.name, "", {
maxAge: 0,
});
}
return ctx.json({
token: session.token,
user: {
id: user.id,
email: user.email,
emailVerified: user.emailVerified,
name: user.name,
image: user.image,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
});
},
invalid: async (errorKey: keyof typeof TWO_FACTOR_ERROR_CODES) => {
throw new APIError("UNAUTHORIZED", {
message: TWO_FACTOR_ERROR_CODES[errorKey],
});
},
session: {
session: null,
user,
},
key: twoFactorCookie,
};
}
return {
valid: async (ctx: GenericEndpointContext) => {
return ctx.json({
token: session.session.token,
user: {
id: session.user.id,
email: session.user.email,
emailVerified: session.user.emailVerified,
name: session.user.name,
image: session.user.image,
createdAt: session.user.createdAt,
updatedAt: session.user.updatedAt,
},
});
},
invalid: async () => {
throw new APIError("UNAUTHORIZED", {
message: TWO_FACTOR_ERROR_CODES.INVALID_TWO_FACTOR_COOKIE,
});
},
session,
key: `${session.user.id}!${session.session.id}`,
};
}