UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

326 lines (325 loc) 12.8 kB
import { mergeSchema } from "../../db/schema.mjs"; import { generateRandomString } from "../../crypto/random.mjs"; import { symmetricEncrypt } from "../../crypto/index.mjs"; import { deleteSessionCookie, expireCookie, setSessionCookie } from "../../cookies/index.mjs"; import { sensitiveSessionMiddleware, sessionMiddleware } from "../../api/routes/session.mjs"; import { shouldRequirePassword, validatePassword } from "../../utils/password.mjs"; import { PACKAGE_VERSION } from "../../version.mjs"; import { TWO_FACTOR_ERROR_CODES } from "./error-code.mjs"; import { twoFactorClient } from "./client.mjs"; import { TRUST_DEVICE_COOKIE_NAME, TWO_FACTOR_COOKIE_NAME } from "./constant.mjs"; import { backupCode2fa, generateBackupCodes } from "./backup-codes/index.mjs"; import { otp2fa } from "./otp/index.mjs"; import { schema } from "./schema.mjs"; import { totp2fa } from "./totp/index.mjs"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api"; import * as z from "zod"; import { createHMAC } from "@better-auth/utils/hmac"; import { createOTP } from "@better-auth/utils/otp"; //#region src/plugins/two-factor/index.ts const twoFactor = (options) => { const opts = { twoFactorTable: "twoFactor" }; const trustDeviceMaxAge = options?.trustDeviceMaxAge ?? 2592e3; const allowPasswordless = options?.allowPasswordless; const backupCodeOptions = { storeBackupCodes: "encrypted", ...options?.backupCodeOptions }; const totp = totp2fa({ ...options?.totpOptions, allowPasswordless: options?.totpOptions?.allowPasswordless ?? allowPasswordless }); const backupCode = backupCode2fa({ ...backupCodeOptions, allowPasswordless: options?.backupCodeOptions?.allowPasswordless ?? allowPasswordless }); const otp = otp2fa(options?.otpOptions); const passwordSchema = z.string().meta({ description: "User password" }); const enableTwoFactorBodySchema = allowPasswordless ? z.object({ password: passwordSchema.optional(), issuer: z.string().meta({ description: "Custom issuer for the TOTP URI" }).optional() }) : z.object({ password: passwordSchema, issuer: z.string().meta({ description: "Custom issuer for the TOTP URI" }).optional() }); const disableTwoFactorBodySchema = allowPasswordless ? z.object({ password: passwordSchema.optional() }) : z.object({ password: passwordSchema }); return { id: "two-factor", version: PACKAGE_VERSION, endpoints: { ...totp.endpoints, ...otp.endpoints, ...backupCode.endpoints, /** * ### Endpoint * * POST `/two-factor/enable` * * ### API Methods * * **server:** * `auth.api.enableTwoFactor` * * **client:** * `authClient.twoFactor.enable` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-enable) */ enableTwoFactor: createAuthEndpoint("/two-factor/enable", { method: "POST", body: enableTwoFactorBodySchema, use: [sessionMiddleware], metadata: { openapi: { summary: "Enable two factor authentication", description: "Use this endpoint to enable two factor authentication. This will generate a TOTP URI and backup codes. Once the user verifies the TOTP URI, the two factor authentication will be enabled.", responses: { 200: { description: "Successful response", content: { "application/json": { schema: { type: "object", properties: { totpURI: { type: "string", description: "TOTP URI" }, backupCodes: { type: "array", items: { type: "string" }, description: "Backup codes" } } } } } } } } } }, async (ctx) => { const user = ctx.context.session.user; const { password, issuer } = ctx.body; if (await shouldRequirePassword(ctx, user.id, allowPasswordless)) { if (!password) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_PASSWORD); if (!await validatePassword(ctx, { password, userId: user.id })) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_PASSWORD); } const secret = generateRandomString(32); const encryptedSecret = await symmetricEncrypt({ key: ctx.context.secretConfig, data: secret }); const backupCodes = await generateBackupCodes(ctx.context.secretConfig, backupCodeOptions); if (options?.skipVerificationOnEnable) { const updatedUser = await ctx.context.internalAdapter.updateUser(user.id, { twoFactorEnabled: true }); /** * Update the session cookie with the new user data */ await setSessionCookie(ctx, { session: await ctx.context.internalAdapter.createSession(updatedUser.id, false, ctx.context.session.session), user: updatedUser }); await ctx.context.internalAdapter.deleteSession(ctx.context.session.session.token); } const existingTwoFactor = await ctx.context.adapter.findOne({ model: opts.twoFactorTable, where: [{ field: "userId", value: user.id }] }); await ctx.context.adapter.deleteMany({ model: opts.twoFactorTable, where: [{ field: "userId", value: user.id }] }); await ctx.context.adapter.create({ model: opts.twoFactorTable, data: { secret: encryptedSecret, backupCodes: backupCodes.encryptedBackupCodes, userId: user.id, verified: existingTwoFactor != null && existingTwoFactor.verified !== false || !!options?.skipVerificationOnEnable } }); const totpURI = createOTP(secret, { digits: options?.totpOptions?.digits || 6, period: options?.totpOptions?.period }).url(issuer || options?.issuer || ctx.context.appName, user.email); return ctx.json({ totpURI, backupCodes: backupCodes.backupCodes }); }), /** * ### Endpoint * * POST `/two-factor/disable` * * ### API Methods * * **server:** * `auth.api.disableTwoFactor` * * **client:** * `authClient.twoFactor.disable` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/2fa#api-method-two-factor-disable) */ disableTwoFactor: createAuthEndpoint("/two-factor/disable", { method: "POST", body: disableTwoFactorBodySchema, use: [sensitiveSessionMiddleware], metadata: { openapi: { summary: "Disable two factor authentication", description: "Use this endpoint to disable two factor authentication.", responses: { 200: { description: "Successful response", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean" } } } } } } } } } }, async (ctx) => { const user = ctx.context.session.user; const { password } = ctx.body; if (await shouldRequirePassword(ctx, user.id, allowPasswordless)) { if (!password) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_PASSWORD); if (!await validatePassword(ctx, { password, userId: user.id })) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_PASSWORD); } const updatedUser = await ctx.context.internalAdapter.updateUser(user.id, { twoFactorEnabled: false }); await ctx.context.adapter.delete({ model: opts.twoFactorTable, where: [{ field: "userId", value: updatedUser.id }] }); /** * Update the session cookie with the new user data */ await setSessionCookie(ctx, { session: await ctx.context.internalAdapter.createSession(updatedUser.id, false, ctx.context.session.session), user: updatedUser }); await ctx.context.internalAdapter.deleteSession(ctx.context.session.session.token); const disableTrustCookie = ctx.context.createAuthCookie(TRUST_DEVICE_COOKIE_NAME, { maxAge: trustDeviceMaxAge }); const disableTrustValue = await ctx.getSignedCookie(disableTrustCookie.name, ctx.context.secret); if (disableTrustValue) { const [, trustId] = disableTrustValue.split("!"); if (trustId) await ctx.context.internalAdapter.deleteVerificationByIdentifier(trustId); expireCookie(ctx, disableTrustCookie); } return ctx.json({ status: true }); }) }, options, hooks: { after: [{ matcher(context) { return context.path === "/sign-in/email" || context.path === "/sign-in/username" || context.path === "/sign-in/phone-number"; }, handler: createAuthMiddleware(async (ctx) => { const data = ctx.context.newSession; if (!data) return; if (!data?.user.twoFactorEnabled) return; const trustDeviceCookieAttrs = ctx.context.createAuthCookie(TRUST_DEVICE_COOKIE_NAME, { maxAge: trustDeviceMaxAge }); const trustDeviceCookie = await ctx.getSignedCookie(trustDeviceCookieAttrs.name, ctx.context.secret); if (trustDeviceCookie) { const [token, trustIdentifier] = trustDeviceCookie.split("!"); if (token && trustIdentifier) { if (token === await createHMAC("SHA-256", "base64urlnopad").sign(ctx.context.secret, `${data.user.id}!${trustIdentifier}`)) { const verificationRecord = await ctx.context.internalAdapter.findVerificationValue(trustIdentifier); if (verificationRecord && verificationRecord.value === data.user.id && verificationRecord.expiresAt > /* @__PURE__ */ new Date()) { await ctx.context.internalAdapter.deleteVerificationByIdentifier(trustIdentifier); const newTrustIdentifier = `trust-device-${generateRandomString(32)}`; const newToken = await createHMAC("SHA-256", "base64urlnopad").sign(ctx.context.secret, `${data.user.id}!${newTrustIdentifier}`); await ctx.context.internalAdapter.createVerificationValue({ value: data.user.id, identifier: newTrustIdentifier, expiresAt: new Date(Date.now() + trustDeviceMaxAge * 1e3) }); const newTrustDeviceCookie = ctx.context.createAuthCookie(TRUST_DEVICE_COOKIE_NAME, { maxAge: trustDeviceMaxAge }); await ctx.setSignedCookie(newTrustDeviceCookie.name, `${newToken}!${newTrustIdentifier}`, ctx.context.secret, trustDeviceCookieAttrs.attributes); return; } } } expireCookie(ctx, trustDeviceCookieAttrs); } /** * Remove the session cookie set by the credential sign-in. * * The credential handler already created a session and set * `ctx.context.newSession`. Since 2FA is still pending, that * session is deleted here and `newSession` is reset to `null` * so downstream hooks don't observe a session that no longer * exists. Hooks that read `ctx.context.newSession` after a * sign-in must therefore null-check it: it is `null` while a * 2FA challenge is in flight (no authenticated session yet). */ deleteSessionCookie(ctx, true); await ctx.context.internalAdapter.deleteSession(data.session.token); ctx.context.setNewSession(null); const maxAge = options?.twoFactorCookieMaxAge ?? 600; const twoFactorCookie = ctx.context.createAuthCookie(TWO_FACTOR_COOKIE_NAME, { maxAge }); const identifier = `2fa-${generateRandomString(20)}`; const expiresAt = new Date(Date.now() + maxAge * 1e3); await ctx.context.internalAdapter.createVerificationValue({ value: data.user.id, identifier, expiresAt }); await ctx.context.internalAdapter.createVerificationValue({ value: "0", identifier: `2fa-attempts-${identifier}`, expiresAt }); await ctx.setSignedCookie(twoFactorCookie.name, identifier, ctx.context.secret, twoFactorCookie.attributes); const twoFactorMethods = []; /** * totp requires per-user setup, so we check * that the user actually has a secret stored. */ if (!options?.totpOptions?.disable) { const userTotpSecret = await ctx.context.adapter.findOne({ model: opts.twoFactorTable, where: [{ field: "userId", value: data.user.id }] }); if (userTotpSecret && userTotpSecret.verified !== false) twoFactorMethods.push("totp"); } /** * otp is server-level — if sendOTP is configured, * any user with 2fa enabled can receive a code. */ if (options?.otpOptions?.sendOTP) twoFactorMethods.push("otp"); return ctx.json({ twoFactorRedirect: true, twoFactorMethods }); }) }] }, schema: mergeSchema(schema, { ...options?.schema, twoFactor: { ...options?.schema?.twoFactor, ...options?.twoFactorTable ? { modelName: options.twoFactorTable } : {} } }), rateLimit: [{ pathMatcher(path) { return path.startsWith("/two-factor/"); }, window: 10, max: 3 }], $ERROR_CODES: TWO_FACTOR_ERROR_CODES }; }; //#endregion export { TWO_FACTOR_ERROR_CODES, twoFactor, twoFactorClient };