feat: setup better-auth with 2fa

This commit is contained in:
Nicolas Meienberger 2026-01-08 20:29:49 +01:00
parent d0dd74a626
commit 52ed37481b
10 changed files with 1450 additions and 134 deletions

View file

@ -84,6 +84,7 @@ jobs:
with:
image: local/zerobyte:ci
fail-build: true
only-fixed: true
severity-cutoff: critical
- name: upload Anchore scan report

View file

@ -0,0 +1,75 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "~/client/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View file

@ -1,8 +1,8 @@
import { createAuthClient } from "better-auth/react";
import { usernameClient } from "better-auth/client/plugins";
import { twoFactorClient, usernameClient } from "better-auth/client/plugins";
import { inferAdditionalFields } from "better-auth/client/plugins";
import type { auth } from "~/lib/auth";
export const authClient = createAuthClient({
plugins: [inferAdditionalFields<typeof auth>(), usernameClient()],
plugins: [inferAdditionalFields<typeof auth>(), usernameClient(), twoFactorClient()],
});

View file

@ -0,0 +1,12 @@
CREATE TABLE `two_factor` (
`id` text PRIMARY KEY NOT NULL,
`secret` text NOT NULL,
`backup_codes` text NOT NULL,
`user_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `twoFactor_secret_idx` ON `two_factor` (`secret`);--> statement-breakpoint
CREATE INDEX `twoFactor_userId_idx` ON `two_factor` (`user_id`);--> statement-breakpoint
ALTER TABLE `users_table` ADD `two_factor_enabled` integer DEFAULT false NOT NULL;--> statement-breakpoint
CREATE INDEX `sessionsTable_userId_idx` ON `sessions_table` (`user_id`);

File diff suppressed because it is too large Load diff

View file

@ -218,6 +218,13 @@
"when": 1767821088612,
"tag": "0030_lower-trim-username",
"breakpoints": true
},
{
"idx": 31,
"version": "6",
"when": 1767863951955,
"tag": "0031_graceful_squadron_supreme",
"breakpoints": true
}
]
}

View file

@ -6,7 +6,7 @@ import {
type MiddlewareOptions,
} from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthMiddleware, username } from "better-auth/plugins";
import { createAuthMiddleware, twoFactor, username } from "better-auth/plugins";
import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user";
import { cryptoUtils } from "~/server/utils/crypto";
import { db } from "~/server/db/db";

View file

@ -50,26 +50,31 @@ export const usersTable = sqliteTable("users_table", {
emailVerified: integer("email_verified", { mode: "boolean" }).default(false).notNull(),
image: text("image"),
displayUsername: text("display_username"),
twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).notNull().default(false),
});
export type User = typeof usersTable.$inferSelect;
export const sessionsTable = sqliteTable("sessions_table", {
id: text().primaryKey(),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: int("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: int("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$onUpdate(() => new Date())
.default(sql`(unixepoch() * 1000)`),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
});
export const sessionsTable = sqliteTable(
"sessions_table",
{
id: text().primaryKey(),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: int("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: int("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$onUpdate(() => new Date())
.default(sql`(unixepoch() * 1000)`),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
},
(table) => [index("sessionsTable_userId_idx").on(table.userId)],
);
export type Session = typeof sessionsTable.$inferSelect;
export const account = sqliteTable(
@ -124,6 +129,7 @@ export const verification = sqliteTable(
export const userRelations = relations(usersTable, ({ many }) => ({
sessions: many(sessionsTable),
accounts: many(account),
twoFactors: many(twoFactor),
}));
export const sessionRelations = relations(sessionsTable, ({ one }) => ({
@ -326,3 +332,16 @@ export const appMetadataTable = sqliteTable("app_metadata", {
.default(sql`(unixepoch() * 1000)`),
});
export type AppMetadata = typeof appMetadataTable.$inferSelect;
export const twoFactor = sqliteTable(
"two_factor",
{
id: text("id").primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
},
(table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)],
);

View file

@ -44,10 +44,12 @@
"hono-openapi": "^1.1.1",
"hono-rate-limiter": "^0.5.3",
"http-errors-enhanced": "^4.0.2",
"input-otp": "^1.4.2",
"isbot": "^5.1.32",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"qrcode.react": "^4.2.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.70.0",
@ -956,6 +958,8 @@
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@ -1260,6 +1264,8 @@
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],

View file

@ -1,116 +1,117 @@
{
"name": "zerobyte",
"private": true,
"type": "module",
"scripts": {
"lint": "oxlint --type-aware",
"build": "react-router build",
"dev": "bunx --bun vite",
"start": "bun ./dist/server/index.js",
"cli:dev": "bun run app/server/cli/main.ts",
"cli": "ZEROBYTE_CLI=1 bun ./dist/server/index.js",
"tsc": "react-router typegen && tsc",
"start:dev": "docker compose down && docker compose up --build zerobyte-dev",
"start:prod": "docker compose down && docker compose up --build zerobyte-prod",
"gen:api-client": "openapi-ts",
"gen:migrations": "drizzle-kit generate",
"studio": "drizzle-kit studio",
"test:server": "dotenv -e .env.test -- bun test app/server --preload ./app/test/setup.ts",
"test:client": "dotenv -e .env.test -- bun test app/client --preload ./app/test/setup-client.ts",
"test": "bun run test:server && bun run test:client"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hono/standard-validator": "^0.2.0",
"@hookform/resolvers": "^5.2.2",
"@inquirer/prompts": "^8.0.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-router/node": "^7.12.0",
"@react-router/serve": "^7.12.0",
"@scalar/hono-api-reference": "^0.9.32",
"@tanstack/react-query": "^5.90.16",
"arktype": "^2.1.28",
"better-auth": "^1.4.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"commander": "^14.0.2",
"cron-parser": "^5.4.0",
"date-fns": "^4.1.0",
"dither-plugin": "^1.1.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"es-toolkit": "^1.42.0",
"hono": "^4.11.3",
"hono-openapi": "^1.1.1",
"hono-rate-limiter": "^0.5.3",
"http-errors-enhanced": "^4.0.2",
"isbot": "^5.1.32",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.70.0",
"react-markdown": "^10.1.0",
"react-router": "^7.12.0",
"react-router-hono-server": "^2.22.0",
"recharts": "3.6.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.3",
"slugify": "^1.6.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tiny-typed-emitter": "^2.1.0",
"winston": "^3.18.3",
"yaml": "^2.8.2"
},
"devDependencies": {
"@faker-js/faker": "^10.2.0",
"@happy-dom/global-registrator": "^20.1.0",
"@hey-api/openapi-ts": "^0.90.2",
"@react-router/dev": "^7.12.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query-devtools": "^5.91.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@types/bun": "^1.3.4",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.7",
"lightningcss": "^1.30.2",
"oxfmt": "^0.23.0",
"oxlint": "^1.38.0",
"oxlint-tsgolint": "^0.10.1",
"tailwindcss": "^4.1.17",
"tinyglobby": "^0.2.15",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-bundle-analyzer": "^1.2.3",
"vite-tsconfig-paths": "^6.0.3"
},
"overrides": {
"esbuild": "^0.27.2",
"hono": "^4.11.3"
},
"packageManager": "bun@1.3.5"
"name": "zerobyte",
"private": true,
"type": "module",
"packageManager": "bun@1.3.5",
"scripts": {
"lint": "oxlint --type-aware",
"build": "react-router build",
"dev": "bunx --bun vite",
"start": "bun ./dist/server/index.js",
"cli:dev": "bun run app/server/cli/main.ts",
"cli": "ZEROBYTE_CLI=1 bun ./dist/server/index.js",
"tsc": "react-router typegen && tsc",
"start:dev": "docker compose down && docker compose up --build zerobyte-dev",
"start:prod": "docker compose down && docker compose up --build zerobyte-prod",
"gen:api-client": "openapi-ts",
"gen:migrations": "drizzle-kit generate",
"studio": "drizzle-kit studio",
"test:server": "dotenv -e .env.test -- bun test app/server --preload ./app/test/setup.ts",
"test:client": "dotenv -e .env.test -- bun test app/client --preload ./app/test/setup-client.ts",
"test": "bun run test:server && bun run test:client"
},
"overrides": {
"esbuild": "^0.27.2"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hono/standard-validator": "^0.2.0",
"@hookform/resolvers": "^5.2.2",
"@inquirer/prompts": "^8.0.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-router/node": "^7.10.0",
"@react-router/serve": "^7.10.0",
"@scalar/hono-api-reference": "^0.9.25",
"@tanstack/react-query": "^5.90.11",
"arktype": "^2.1.28",
"better-auth": "^1.4.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"commander": "^14.0.2",
"cron-parser": "^5.4.0",
"date-fns": "^4.1.0",
"dither-plugin": "^1.1.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"es-toolkit": "^1.42.0",
"hono": "4.10.5",
"hono-openapi": "^1.1.1",
"hono-rate-limiter": "^0.5.0",
"http-errors-enhanced": "^4.0.2",
"input-otp": "^1.4.2",
"isbot": "^5.1.32",
"lucide-react": "^0.555.0",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"qrcode.react": "^4.2.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.68.0",
"react-markdown": "^10.1.0",
"react-router": "^7.10.0",
"react-router-hono-server": "^2.22.0",
"recharts": "3.5.1",
"remark-gfm": "^4.0.1",
"semver": "^7.7.3",
"slugify": "^1.6.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tiny-typed-emitter": "^2.1.0",
"winston": "^3.18.3",
"yaml": "^2.8.2"
},
"devDependencies": {
"@faker-js/faker": "^10.1.0",
"@happy-dom/global-registrator": "^20.0.11",
"@hey-api/openapi-ts": "^0.88.0",
"@react-router/dev": "^7.10.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query-devtools": "^5.91.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@types/bun": "^1.3.4",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.7",
"lightningcss": "^1.30.2",
"oxfmt": "^0.22.0",
"oxlint": "^1.36.0",
"oxlint-tsgolint": "^0.10.1",
"tailwindcss": "^4.1.17",
"tinyglobby": "^0.2.15",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vite-bundle-analyzer": "^1.2.3",
"vite-tsconfig-paths": "^6.0.3"
}
}