better-auth
Version:
The most comprehensive authentication framework for TypeScript.
342 lines (341 loc) • 12.2 kB
JavaScript
import { parseUserOutput } from "../../../db/schema.mjs";
import { generateRandomString } from "../../../crypto/random.mjs";
import { symmetricDecrypt, symmetricEncrypt } from "../../../crypto/index.mjs";
import { sessionMiddleware } from "../../../api/routes/session.mjs";
import { shouldRequirePassword } from "../../../utils/password.mjs";
import { PACKAGE_VERSION } from "../../../version.mjs";
import { TWO_FACTOR_ERROR_CODES } from "../error-code.mjs";
import { assertTwoFactorNotLocked, recordTwoFactorFailure, resetTwoFactorFailures, verifyTwoFactor } from "../verify-two-factor.mjs";
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
import { safeJSONParse } from "@better-auth/core/utils/json";
import { createAuthEndpoint } from "@better-auth/core/api";
import * as z from "zod";
//#region src/plugins/two-factor/backup-codes/index.ts
function generateBackupCodesFn(options) {
return Array.from({ length: options?.amount ?? 10 }).fill(null).map(() => generateRandomString(options?.length ?? 10, "a-z", "0-9", "A-Z")).map((code) => `${code.slice(0, 5)}-${code.slice(5)}`);
}
async function encodeBackupCodes(codes, secret, options) {
const json = JSON.stringify(codes);
if (options?.storeBackupCodes === "encrypted") return symmetricEncrypt({
data: json,
key: secret
});
if (typeof options?.storeBackupCodes === "object" && "encrypt" in options?.storeBackupCodes) return options.storeBackupCodes.encrypt(json);
return json;
}
async function generateBackupCodes(secret, options) {
const backupCodes = options?.customBackupCodesGenerate ? options.customBackupCodesGenerate() : generateBackupCodesFn(options);
return {
backupCodes,
encryptedBackupCodes: await encodeBackupCodes(backupCodes, secret, options)
};
}
async function verifyBackupCode(data, key, options) {
const codes = await getBackupCodes(data.backupCodes, key, options);
if (!codes) return {
status: false,
updated: null
};
return {
status: codes.includes(data.code),
updated: codes.filter((code) => code !== data.code)
};
}
async function getBackupCodes(backupCodes, key, options) {
if (options?.storeBackupCodes === "encrypted") return safeJSONParse(await symmetricDecrypt({
key,
data: backupCodes
}));
if (typeof options?.storeBackupCodes === "object" && "decrypt" in options?.storeBackupCodes) return safeJSONParse(await options?.storeBackupCodes.decrypt(backupCodes));
return safeJSONParse(backupCodes);
}
const verifyBackupCodeBodySchema = z.object({
code: z.string().meta({ description: `A backup code to verify. Eg: "123456"` }),
/**
* Disable setting the session cookie
*/
disableSession: z.boolean().meta({ description: "If true, the session cookie will not be set." }).optional(),
/**
* 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().meta({ description: "If true, the device will be trusted for 30 days. It'll be refreshed on every sign in request within this time. Eg: true" }).optional()
});
const viewBackupCodesBodySchema = z.object({ userId: z.coerce.string().meta({ description: `The user ID to view all backup codes. Eg: "user-id"` }) });
const backupCode2fa = (opts) => {
const twoFactorTable = "twoFactor";
const passwordSchema = z.string().meta({ description: "The users password." });
const generateBackupCodesBodySchema = opts.allowPasswordless ? z.object({ password: passwordSchema.optional() }) : z.object({ password: passwordSchema });
return {
id: "backup_code",
version: PACKAGE_VERSION,
endpoints: {
/**
* ### Endpoint
*
* POST `/two-factor/verify-backup-code`
*
* ### API Methods
*
* **server:**
* `auth.api.verifyBackupCode`
*
* **client:**
* `authClient.twoFactor.verifyBackupCode`
*
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-verify-backup-code)
*/
verifyBackupCode: createAuthEndpoint("/two-factor/verify-backup-code", {
method: "POST",
body: verifyBackupCodeBodySchema,
metadata: { openapi: {
description: "Verify a backup code for two-factor authentication",
responses: { "200": {
description: "Backup code verified successfully",
content: { "application/json": { schema: {
type: "object",
properties: {
user: {
type: "object",
properties: {
id: {
type: "string",
description: "Unique identifier of the user"
},
email: {
type: "string",
format: "email",
nullable: true,
description: "User's email address"
},
emailVerified: {
type: "boolean",
nullable: true,
description: "Whether the email is verified"
},
name: {
type: "string",
nullable: true,
description: "User's name"
},
image: {
type: "string",
format: "uri",
nullable: true,
description: "User's profile image URL"
},
twoFactorEnabled: {
type: "boolean",
description: "Whether two-factor authentication is enabled for the user"
},
createdAt: {
type: "string",
format: "date-time",
description: "Timestamp when the user was created"
},
updatedAt: {
type: "string",
format: "date-time",
description: "Timestamp when the user was last updated"
}
},
required: [
"id",
"twoFactorEnabled",
"createdAt",
"updatedAt"
],
description: "The authenticated user object with two-factor details"
},
session: {
type: "object",
properties: {
token: {
type: "string",
description: "Session token"
},
userId: {
type: "string",
description: "ID of the user associated with the session"
},
createdAt: {
type: "string",
format: "date-time",
description: "Timestamp when the session was created"
},
expiresAt: {
type: "string",
format: "date-time",
description: "Timestamp when the session expires"
}
},
required: [
"token",
"userId",
"createdAt",
"expiresAt"
],
description: "The current session object, included unless disableSession is true"
}
},
required: ["user", "session"]
} } }
} }
} }
}, async (ctx) => {
const { session, valid, beginAttempt } = await verifyTwoFactor(ctx);
const user = session.user;
const isSignIn = !session.session;
const twoFactor = await ctx.context.adapter.findOne({
model: twoFactorTable,
where: [{
field: "userId",
value: user.id
}]
});
if (!twoFactor) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED);
if (isSignIn) await assertTwoFactorNotLocked(ctx, twoFactorTable, twoFactor);
const attempt = isSignIn ? await beginAttempt(5) : null;
let validate;
try {
validate = await verifyBackupCode({
backupCodes: twoFactor.backupCodes,
code: ctx.body.code
}, ctx.context.secretConfig, opts);
} catch (error) {
await attempt?.restore();
throw error;
}
if (!validate.status || !validate.updated) {
await attempt?.recordFailure();
if (isSignIn) await recordTwoFactorFailure(ctx, twoFactorTable, twoFactor);
throw APIError.from("UNAUTHORIZED", TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE);
}
const updatedBackupCodes = await encodeBackupCodes(validate.updated, ctx.context.secretConfig, opts);
if (!await ctx.context.adapter.incrementOne({
model: twoFactorTable,
where: [{
field: "id",
value: twoFactor.id
}, {
field: "backupCodes",
value: twoFactor.backupCodes
}],
increment: {},
set: { backupCodes: updatedBackupCodes }
})) throw APIError.fromStatus("CONFLICT", { message: "Failed to verify backup code. Please try again." });
if (isSignIn) await resetTwoFactorFailures(ctx, twoFactorTable, twoFactor);
if (!ctx.body.disableSession) return valid(ctx);
return ctx.json({
token: session.session?.token,
user: parseUserOutput(ctx.context.options, session.user)
});
}),
/**
* ### Endpoint
*
* POST `/two-factor/generate-backup-codes`
*
* ### API Methods
*
* **server:**
* `auth.api.generateBackupCodes`
*
* **client:**
* `authClient.twoFactor.generateBackupCodes`
*
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-generate-backup-codes)
*/
generateBackupCodes: createAuthEndpoint("/two-factor/generate-backup-codes", {
method: "POST",
body: generateBackupCodesBodySchema,
use: [sessionMiddleware],
metadata: { openapi: {
description: "Generate new backup codes for two-factor authentication",
responses: { "200": {
description: "Backup codes generated successfully",
content: { "application/json": { schema: {
type: "object",
properties: {
status: {
type: "boolean",
description: "Indicates if the backup codes were generated successfully",
enum: [true]
},
backupCodes: {
type: "array",
items: { type: "string" },
description: "Array of generated backup codes in plain text"
}
},
required: ["status", "backupCodes"]
} } }
} }
} }
}, async (ctx) => {
const user = ctx.context.session.user;
if (!user.twoFactorEnabled) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TWO_FACTOR_NOT_ENABLED);
if (await shouldRequirePassword(ctx, user.id, opts.allowPasswordless)) {
if (!ctx.body.password) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_PASSWORD);
await ctx.context.password.checkPassword(user.id, ctx);
}
const twoFactor = await ctx.context.adapter.findOne({
model: twoFactorTable,
where: [{
field: "userId",
value: user.id
}]
});
if (!twoFactor) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.TWO_FACTOR_NOT_ENABLED);
const backupCodes = await generateBackupCodes(ctx.context.secretConfig, opts);
await ctx.context.adapter.update({
model: twoFactorTable,
update: { backupCodes: backupCodes.encryptedBackupCodes },
where: [{
field: "id",
value: twoFactor.id
}]
});
return ctx.json({
status: true,
backupCodes: backupCodes.backupCodes
});
}),
/**
* A server-only function that returns a user's decrypted two-factor
* backup codes. It is not exposed over HTTP and has no client method;
* call it from trusted server code with a `userId` taken from an
* authenticated session.
*
* ### API Methods
*
* **server:**
* `auth.api.viewBackupCodes`
*
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-view-backup-codes)
*/
viewBackupCodes: createAuthEndpoint.serverOnly({
method: "POST",
body: viewBackupCodesBodySchema
}, async (ctx) => {
const twoFactor = await ctx.context.adapter.findOne({
model: twoFactorTable,
where: [{
field: "userId",
value: ctx.body.userId
}]
});
if (!twoFactor) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.BACKUP_CODES_NOT_ENABLED);
const decryptedBackupCodes = await getBackupCodes(twoFactor.backupCodes, ctx.context.secretConfig, opts);
if (!decryptedBackupCodes) throw APIError.from("BAD_REQUEST", TWO_FACTOR_ERROR_CODES.INVALID_BACKUP_CODE);
return ctx.json({
status: true,
backupCodes: decryptedBackupCodes
});
})
}
};
};
//#endregion
export { backupCode2fa, encodeBackupCodes, generateBackupCodes, getBackupCodes, verifyBackupCode };