UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

342 lines (341 loc) • 12.2 kB
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 };