mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-28 01:46:45 -05:00
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:
@@ -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>>(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
136
packages/better-auth/src/plugins/two-factor/verify-two-factor.ts
Normal file
136
packages/better-auth/src/plugins/two-factor/verify-two-factor.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user