Compare commits

...

13 Commits

  1. 6
      .env.example
  2. 9
      README.md
  3. 8
      app/middleware/auth.ts
  4. 9
      app/middleware/guest.ts
  5. 41
      app/pages/login.vue
  6. 81
      app/pages/me.vue
  7. 56
      app/pages/register.vue
  8. 7
      bun.lock
  9. 1233
      docs/superpowers/plans/2026-04-12-auth-user.md
  10. 258
      docs/superpowers/specs/2026-04-12-auth-user-design.md
  11. 7
      package.json
  12. 74
      packages/drizzle-pkg/lib/schema/schema.ts
  13. 32
      packages/drizzle-pkg/migrations/0001_auth_user.sql
  14. 306
      packages/drizzle-pkg/migrations/meta/0001_snapshot.json
  15. 7
      packages/drizzle-pkg/migrations/meta/_journal.json
  16. 34
      packages/drizzle-pkg/seed.ts
  17. 7
      server/api/auth/forgot-password.post.ts
  18. 10
      server/api/auth/login.post.ts
  19. 3
      server/api/auth/logout.post.ts
  20. 3
      server/api/auth/register.post.ts
  21. 13
      server/api/auth/reset-password.post.ts
  22. 7
      server/api/auth/verify-email.post.ts
  23. 15
      server/api/hello.ts
  24. 3
      server/api/me.get.ts
  25. 7
      server/api/me.patch.ts
  26. 5
      server/plugins/04.redis.ts
  27. 17
      server/plugins/05.api-error-json.ts
  28. 306
      server/services/auth.ts
  29. 9
      server/utils/challenge-token.ts
  30. 9
      server/utils/errors.ts
  31. 9
      server/utils/mailer.ts
  32. 15
      server/utils/password.ts
  33. 22
      server/utils/rate-limit.ts
  34. 19
      server/utils/redis.ts
  35. 37
      server/utils/session-cookie.ts
  36. 37
      server/utils/session-redis.ts
  37. 5
      server/utils/session-ttl.ts
  38. 5
      server/utils/verification-policy.ts
  39. 57
      test/integration/auth.http.test.ts
  40. 15
      test/unit/challenge-token.test.ts
  41. 10
      test/unit/password.test.ts
  42. 17
      test/unit/session-ttl.test.ts
  43. 11
      test/unit/verification-policy.test.ts

6
.env.example

@ -1 +1,5 @@
DATABASE_URL=postgresql://postgres:123456qaz@localhost:6666/postgres
DATABASE_URL=postgresql://postgres:123456qaz@localhost:6666/postgres
REDIS_URL=redis://127.0.0.1:6379
SESSION_TTL_SECONDS=604800
AUTH_DEBUG_LOG_TOKENS=false
COOKIE_DOMAIN=

9
README.md

@ -9,4 +9,11 @@
## 开发与部署
用 Linux 开发与部署,包管理器采用 bun@1.3.11。数据库为 **postgres**(通过 `DATABASE_URL` 连接;本地可参考 `.env.example` 复制为 `.env.local`)。部署时可直接打包 `.output` 目录,在服务器环境执行迁移命令,省时省力。
用 Linux 开发与部署,包管理器采用 bun@1.3.11。数据库为 **postgres**(通过 `DATABASE_URL` 连接;本地可参考 `.env.example` 复制为 `.env.local`)。部署时可直接打包 `.output` 目录,在服务器环境执行迁移命令,省时省力。
会话与限流依赖 **Redis**(`REDIS_URL`,见 `.env.example`)。认证相关设计见 `docs/superpowers/specs/2026-04-12-auth-user-design.md`,实现任务见 `docs/superpowers/plans/2026-04-12-auth-user.md`。
- 迁移:`bun run db:migrate`(需可用 `DATABASE_URL`)。
- 生成迁移:在仓库根执行 `bun run db:generate -- --name <tag>`(`--` 之后参数传给 `drizzle-kit generate`)。
- 单元测试:`bun run test`。
- 可选 HTTP 集成:终端 A `bun run dev`(需 `DATABASE_URL`、`REDIS_URL`),终端 B `TEST_INTEGRATION=1 NODE_ENV=test bun run test:integration`。

8
app/middleware/auth.ts

@ -0,0 +1,8 @@
export default defineNuxtRouteMiddleware(async (to) => {
const fetch = useRequestFetch();
try {
await fetch("/api/me", { credentials: "include" });
} catch {
return navigateTo({ path: "/login", query: { redirect: to.fullPath } });
}
});

9
app/middleware/guest.ts

@ -0,0 +1,9 @@
export default defineNuxtRouteMiddleware(async () => {
const fetch = useRequestFetch();
try {
await fetch("/api/me", { credentials: "include" });
return navigateTo("/me");
} catch {
return;
}
});

41
app/pages/login.vue

@ -0,0 +1,41 @@
<script setup lang="ts">
definePageMeta({ middleware: "guest" });
const email = ref("");
const password = ref("");
const errorMsg = ref("");
async function onSubmit() {
errorMsg.value = "";
try {
await $fetch("/api/auth/login", {
method: "POST",
body: { email: email.value, password: password.value },
credentials: "include",
});
await navigateTo("/me");
} catch (e: unknown) {
const err = e as { data?: { error?: { message?: string } } };
errorMsg.value = err.data?.error?.message ?? "登录失败";
}
}
</script>
<template>
<div style="max-width: 360px; margin: 2rem auto; font-family: system-ui">
<h1>登录</h1>
<form @submit.prevent="onSubmit">
<label>
邮箱
<input v-model="email" type="email" required style="display: block; width: 100%; margin: 0.25rem 0 1rem" />
</label>
<label>
密码
<input v-model="password" type="password" minlength="8" required style="display: block; width: 100%; margin: 0.25rem 0 1rem" />
</label>
<p v-if="errorMsg" style="color: crimson">{{ errorMsg }}</p>
<button type="submit">登录</button>
</form>
<p><NuxtLink to="/register">没有账号?注册</NuxtLink></p>
</div>
</template>

81
app/pages/me.vue

@ -0,0 +1,81 @@
<script setup lang="ts">
definePageMeta({ middleware: "auth" });
type User = {
id: number;
name: string;
age: number;
email: string;
emailVerified: boolean;
};
const fetch = useRequestFetch();
const { data, refresh } = await useAsyncData("me", () =>
fetch<{ user: User }>("/api/me", { credentials: "include" }),
);
const name = ref(data.value?.user.name ?? "");
const age = ref(data.value?.user.age ?? 0);
const patchError = ref("");
watch(
() => data.value?.user,
(u) => {
if (u) {
name.value = u.name;
age.value = u.age;
}
},
{ immediate: true },
);
async function saveProfile() {
patchError.value = "";
try {
await $fetch("/api/me", {
method: "PATCH",
body: { name: name.value, age: age.value },
credentials: "include",
});
await refresh();
} catch (e: unknown) {
const err = e as { statusCode?: number; data?: { error?: { message?: string } } };
if (err.statusCode === 403) {
patchError.value = "需先完成邮箱验证后才能修改资料。";
} else {
patchError.value = err.data?.error?.message ?? "保存失败";
}
}
}
async function logout() {
await $fetch("/api/auth/logout", { method: "POST", credentials: "include" });
await navigateTo("/login");
}
</script>
<template>
<div style="max-width: 420px; margin: 2rem auto; font-family: system-ui">
<h1>个人资料</h1>
<p v-if="data?.user">
邮箱:{{ data.user.email }}
<span v-if="data.user.emailVerified">(已验证)</span>
<span v-else>(未验证)</span>
</p>
<p v-if="patchError" style="color: darkorange">{{ patchError }}</p>
<form @submit.prevent="saveProfile">
<label>
姓名
<input v-model="name" type="text" required style="display: block; width: 100%; margin: 0.25rem 0 1rem" />
</label>
<label>
年龄
<input v-model.number="age" type="number" min="1" max="150" required style="display: block; width: 100%; margin: 0.25rem 0 1rem" />
</label>
<button type="submit">保存</button>
</form>
<p style="margin-top: 2rem">
<button type="button" @click="logout">退出登录</button>
</p>
</div>
</template>

56
app/pages/register.vue

@ -0,0 +1,56 @@
<script setup lang="ts">
definePageMeta({ middleware: "guest" });
const email = ref("");
const password = ref("");
const name = ref("");
const age = ref(20);
const errorMsg = ref("");
async function onSubmit() {
errorMsg.value = "";
try {
await $fetch("/api/auth/register", {
method: "POST",
body: {
email: email.value,
password: password.value,
name: name.value,
age: Number(age.value),
},
credentials: "include",
});
await navigateTo("/me");
} catch (e: unknown) {
const err = e as { data?: { error?: { message?: string } } };
errorMsg.value = err.data?.error?.message ?? "注册失败";
}
}
</script>
<template>
<div style="max-width: 360px; margin: 2rem auto; font-family: system-ui">
<h1>注册</h1>
<form @submit.prevent="onSubmit">
<label>
邮箱
<input v-model="email" type="email" required style="display: block; width: 100%; margin: 0.25rem 0 1rem" />
</label>
<label>
密码(至少 8 位)
<input v-model="password" type="password" minlength="8" required style="display: block; width: 100%; margin: 0.25rem 0 1rem" />
</label>
<label>
姓名
<input v-model="name" type="text" required style="display: block; width: 100%; margin: 0.25rem 0 1rem" />
</label>
<label>
年龄
<input v-model.number="age" type="number" min="1" max="150" required style="display: block; width: 100%; margin: 0.25rem 0 1rem" />
</label>
<p v-if="errorMsg" style="color: crimson">{{ errorMsg }}</p>
<button type="submit">注册并登录</button>
</form>
<p><NuxtLink to="/login">已有账号?登录</NuxtLink></p>
</div>
</template>

7
bun.lock

@ -5,10 +5,12 @@
"": {
"name": "person-panel",
"dependencies": {
"bcryptjs": "^3.0.3",
"dotenv": "17.4.1",
"drizzle-orm": "^0.45.2",
"drizzle-pkg": "workspace:*",
"drizzle-seed": "0.3.1",
"ioredis": "^5.10.1",
"log4js": "^6.9.1",
"logger": "workspace:*",
"nuxt": "4.4.2",
@ -17,6 +19,7 @@
"vue-router": "5.0.4",
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/pg": "^8.20.0",
"drizzle-kit": "0.31.10",
"tsx": "4.21.0",
@ -483,6 +486,8 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
@ -589,6 +594,8 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="],
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="],

1233
docs/superpowers/plans/2026-04-12-auth-user.md

File diff suppressed because it is too large

258
docs/superpowers/specs/2026-04-12-auth-user-design.md

@ -0,0 +1,258 @@
# 登录、注册与用户信息 — 设计规格
**状态**:已定稿(2026-04-12)
**范围**:第一版邮箱密码 + Redis Session;未验证邮箱分级门禁;忘记密码闭环(无生产邮件);最小 Nuxt 页面;为 OAuth 预留数据与流程边界。
---
## 1. 目标与非目标
### 1.1 目标(第一版交付)
- **注册**:邮箱 + 密码;持久化密码哈希;可创建邮箱验证类 challenge(与是否发信解耦)。
- **登录 / 登出**:校验密码后创建 **Redis Session**,通过 **HttpOnly Cookie** 下发 opaque session id;登出删除会话并清 Cookie;登录成功 **轮换 session id**。
- **当前用户**:从 Cookie → Redis → `user_id` 解析会话上下文。
- **用户信息**:`GET` / `PATCH` 个人资料;**第一版不允许修改 `email`**;可更新字段如 `name`、`age`(与现有 `users_table` 对齐)。
- **未验证账号**:不阻止登录;对指定操作通过 **集中配置** 要求 `email_verified_at`;新增能力时优先改配置而非复制校验逻辑。
- **忘记密码**:`forgot-password` 与 `reset-password` **API 闭环**;无生产邮件时 token 通过开发日志或运维/DB 获取;对外响应防枚举。
- **OAuth**:不在第一版实现;数据与流程边界见第 8 节。
### 1.2 非目标(第一版不做)
- 第三方 OAuth 回调与 UI。
- 2FA、设备管理、复杂风控。
- 生产级「发验证邮件 / 发重置邮件」投递(通过 `Mailer` 抽象预留,第一版 `NoopMailer`)。
- 完整运营后台;管理员手工重置仅作为运维流程文档化。
---
## 2. 数据模型与迁移
### 2.1 `users_table` 扩展
在现有表上 **增量迁移**(保持表名 `users_table` 与 Drizzle 映射,减少改名风险):
| 列 | 说明 |
|----|------|
| `password_hash` | 非空(已有行需迁移策略:开发环境可清空后重建;生产需单独评估) |
| `email_verified_at` | `timestamptz`,可空 |
| `created_at` / `updated_at` | 建议新增,便于审计 |
保留:`id`、`name`、`age`、`email`(唯一)。
**邮箱变更**:第一版 **禁止** PATCH 修改 `email`。
### 2.2 `auth_challenges`
单表承载多种「挑战」:
| 列 | 说明 |
|----|------|
| `id` | 主键 |
| `user_id` | 外键 → `users_table` |
| `type` | 枚举:`email_verify` \| `password_reset` |
| `token_hash` | 仅存储哈希,不存明文 token |
| `expires_at` | 过期时间 |
| `consumed_at` | 可空;消费后置位 |
| 可选 | `created_at`、`created_ip` |
索引:按 `token_hash` 查询(实现时注意与过期清理策略配合)。
### 2.3 Session
- **仅存 Redis**,不建 Postgres session 表。
- Key 建议:`sess:{sessionId}`;Value JSON 至少含 `userId`,可选 `createdAt` 等。
- TTL 与 Cookie `Max-Age` **一致**;续期策略在实现计划中写死默认(如固定过期或滑动窗口)。
### 2.4 `linked_accounts`(OAuth 预留)
第二版可落库,第一版可在迁移中 **创建空表** 或 **仅文档约定**(二选一在实现计划中固定):
- `user_id`、`provider`(如 `github`)、`provider_user_id`
- 唯一约束:`(provider, provider_user_id)`
OAuth 绑定不写入 `users` 宽表。
### 2.5 迁移注意
- 使用 Drizzle 生成并版本化迁移。
- 示例接口 `server/api/hello` 若返回用户列表,须在实现阶段改为 **鉴权后可用** 或 **删除**,禁止公开泄露。
---
## 3. Cookie、Redis 与安全
### 3.1 Cookie
- **HttpOnly**:必选。
- **Secure**:`NODE_ENV=production` 且 HTTPS 时必选;本地 HTTP 可关闭。
- **SameSite**:默认 **`Lax`**。
- **Path**:`/`;`Domain` 由部署环境可选配置。
- Cookie 名:实现中用常量(如 `SESSION_COOKIE_NAME`),全仓统一。
### 3.2 Redis
- 环境变量 **`REDIS_URL`**。
- Nitro 进程内 **单例连接**(插件或惰性初始化),禁止每请求新建连接。
### 3.3 密码与 token
- 密码哈希:**argon2id 或 bcrypt 二选一**,全仓单一封装;实现计划选定并锁定依赖。
- 挑战 token:生成随机明文 → 仅存 **`token_hash`**;校验时使用恒定时间比较。
### 3.4 滥用防护
- 对登录、注册等接口做 **限流**(建议 Redis 计数器 + 时间窗口)。
- `forgot-password`:**始终 HTTP 200** + 统一文案,不泄露邮箱是否注册。
### 3.5 CSRF
- 第一版假设 **同站** Nuxt 调用 API,`SameSite=Lax`。
- 若未来开放跨站写操作,须引入 **CSRF token** 或等价机制(单独规格)。
---
## 4. API 约定
### 4.1 路由一览
| 方法与路径 | 行为 |
|------------|------|
| `POST /api/auth/register` | 注册;**建议成功后直接建立会话**(与登录一致) |
| `POST /api/auth/login` | 登录;轮换 session;写 Cookie |
| `POST /api/auth/logout` | 登出;幂等 |
| `POST /api/auth/forgot-password` | body:`{ email }`;统一 200 响应 |
| `POST /api/auth/reset-password` | body:`{ token, new_password }`;成功后消费 challenge、更新 `password_hash`、**吊销该用户所有 Redis session** |
| `POST /api/auth/verify-email` | body:`{ token }`;成功则写 `email_verified_at`、消费 challenge |
| `GET /api/me` | 当前用户摘要;未登录 **401** |
| `PATCH /api/me` | 更新资料;未登录 **401**;若配置要求已验证而未满足 **403** |
全仓路径命名保持一致;若调整仅允许整仓重命名并更新本文档。
### 4.2 错误响应
统一 JSON,例如:
```json
{
"error": {
"code": "EMAIL_IN_USE",
"message": "该邮箱已注册"
}
}
```
建议状态码:
- **400**:校验失败
- **401**:未登录或 session 无效
- **403**:已登录但不满足策略(如未验证邮箱)
- **409**:资源冲突(邮箱已存在)
- **429**:限流
- **500**:不暴露内部细节;服务端结构化日志记录
响应体 **不得** 包含 `password_hash` 或明文 session id。
---
## 5. 「未验证」可配置门禁
### 5.1 原则
- **`requireUser`**:解析会话,无则 401。
- **`requireVerifiedFor(handlerId)`** 或等价:根据 **集中配置** 判断是否要求 `email_verified_at` 非空。
- **默认策略**:未在配置中声明的操作 **仅需登录**(避免默认过严导致全站不可用);敏感写操作 **显式** 声明需验证。
### 5.2 配置形态
- 使用「路由/能力 id → 是否需要已验证」的映射(对象、模块导出或 JSON)。
- 第一版至少示例:**`PATCH /api/me` 需已验证**(可配置关闭以联调);**`GET /api/me` 仅需登录**。
### 5.3 前端
- **401**:跳转 `/login`,可带 `redirect`。
- **403**(未验证):在 `/me` 使用提示条或轻量引导;不强制整页拦截只读,除非产品后续变更。
---
## 6. 邮件与占位行为
### 6.1 `Mailer` 抽象
- `sendVerificationEmail`、`sendPasswordResetEmail`(签名随实现细化)。
- 第一版:**`NoopMailer`**,不发起网络投递。
### 6.2 开发调试
- 环境变量 **`AUTH_DEBUG_LOG_TOKENS=true`** 时,将验证/重置用 **明文 token 写入结构化日志一次**;**生产默认关闭**。
### 6.3 无邮件生产
- `reset-password` / `verify-email` 仍可用;token 获取走 **运维/DB 应急流程**(文档化,非产品功能)。
---
## 7. 最小 Nuxt 前端
- 页面:`/login`、`/register`、`/me`(资料 + 登出);登出可在 `/me` 上以按钮完成。
- 路由中间件:`auth`(保护 `/me`);可选 `guest`(已登录访问登录/注册页时重定向 `/me`)。
- 数据请求:浏览器与 SSR 须保证 **携带 Cookie**(`credentials: 'include'` 或同源默认行为以实现时验证为准)。
- 客户端可做基础格式校验;**服务端校验为权威**。
---
## 8. OAuth 第二阶段(边界)
- 登录成功仍使用 **同一套 Redis Session + Cookie**。
- 用户主体在 **`users_table`**;外部身份在 **`linked_accounts`**。
- **禁止** OAuth 邮箱与已有账号 **静默合并**;须显式「连接账号」或拒绝策略(实现计划细化)。
- 回调路由占位:`/api/auth/oauth/{provider}/start`、`/api/auth/oauth/{provider}/callback`(路径以实现计划为准)。
- `email_verified_at` 是否因 OAuth 声明而自动写入:第二阶段按 provider 策略在实现计划中规定。
---
## 9. 测试、环境变量与上线检查
### 9.1 测试
- **单元**:哈希、token 校验、门禁配置解析。
- **集成**:Postgres + Redis;覆盖注册→登录→me、未验证 403、验证后通过、登出 401、重置密码吊销会话等(具体用例实现计划列出)。
- 可复用/扩展 `scripts/migrate-test.sh` 等现有脚本启动测试库。
### 9.2 环境变量(最小集)
| 变量 | 说明 |
|------|------|
| `DATABASE_URL` | 已有 |
| `REDIS_URL` | 新增 |
| `NODE_ENV` | `production` 行为见上文 |
| `AUTH_DEBUG_LOG_TOKENS` | 可选;开发用 |
| `SESSION_TTL_SECONDS` | 可选;缺省用代码默认 |
部署可选:`COOKIE_DOMAIN`、限流相关前缀变量(实现计划定义)。
### 9.3 上线检查单
- 迁移已执行;Redis 可用;HTTPS + `Secure` Cookie。
- 关闭 `AUTH_DEBUG_LOG_TOKENS`;限流与公开 API 审查完成。
- 日志不含密码与生产 session 明文。
### 9.4 日志
- 复用项目 logger;认证失败、限流、Redis 错误结构化记录。
---
## 10. 与现有代码库的关系
- 栈:**Nuxt 4、Nitro、Bun、Drizzle、Postgres**;Session **Redis**。
- 现有 `users_table` 与 `drizzle-pkg` 迁移流程延续;新表纳入同一包或约定目录。
- 本文档为实现的 **唯一需求来源**之一;冲突以本文档与后续已批准的变更记录为准。
---
## 11. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-04-12 | 初版定稿(brainstorming 各节确认合并) |

7
package.json

@ -12,17 +12,21 @@
"cp:db": "cp build-files/run.sh .output/run.sh && cp .env.example .output/.env && cp build-files/migrate.js .output/server/migrate.js",
"migrate:test": "sh scripts/migrate-test.sh",
"db:migrate": "bun --elide-lines=0 --filter drizzle-pkg migrate",
"db:generate": "bun --elide-lines=0 --filter drizzle-pkg generate --name",
"db:generate": "bun --elide-lines=0 --filter drizzle-pkg run generate --",
"db:seed": "bun --elide-lines=0 --filter drizzle-pkg seed",
"generate": "nuxt generate",
"preview": "nuxt preview",
"test": "bun test test/unit",
"test:integration": "bun test test/integration",
"postinstall": "nuxt prepare"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"dotenv": "17.4.1",
"drizzle-orm": "^0.45.2",
"drizzle-pkg": "workspace:*",
"drizzle-seed": "0.3.1",
"ioredis": "^5.10.1",
"log4js": "^6.9.1",
"logger": "workspace:*",
"nuxt": "4.4.2",
@ -31,6 +35,7 @@
"vue-router": "5.0.4"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/pg": "^8.20.0",
"drizzle-kit": "0.31.10",
"tsx": "4.21.0",

74
packages/drizzle-pkg/lib/schema/schema.ts

@ -1,8 +1,74 @@
import { integer, pgTable, varchar } from "drizzle-orm/pg-core";
import {
integer,
pgTable,
varchar,
timestamp,
text,
pgEnum,
uniqueIndex,
index,
} from "drizzle-orm/pg-core";
export const authChallengeTypeEnum = pgEnum("auth_challenge_type", [
"email_verify",
"password_reset",
]);
export const usersTable = pgTable("users_table", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar().notNull(),
name: varchar({ length: 255 }).notNull(),
age: integer().notNull(),
email: varchar().notNull().unique(),
});
email: varchar({ length: 320 }).notNull().unique(),
passwordHash: text("password_hash").notNull(),
emailVerifiedAt: timestamp("email_verified_at", {
withTimezone: true,
}),
sessionVersion: integer("session_version").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export const authChallengesTable = pgTable(
"auth_challenges",
{
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
type: authChallengeTypeEnum("type").notNull(),
tokenHash: varchar("token_hash", { length: 64 }).notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
consumedAt: timestamp("consumed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
tokenHashIdx: index("auth_challenges_token_hash_idx").on(t.tokenHash),
}),
);
export const linkedAccountsTable = pgTable(
"linked_accounts",
{
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
provider: varchar({ length: 64 }).notNull(),
providerUserId: varchar("provider_user_id", { length: 255 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
providerUserUnique: uniqueIndex("linked_accounts_provider_uid").on(
t.provider,
t.providerUserId,
),
}),
);

32
packages/drizzle-pkg/migrations/0001_auth_user.sql

@ -0,0 +1,32 @@
CREATE TYPE "public"."auth_challenge_type" AS ENUM('email_verify', 'password_reset');--> statement-breakpoint
CREATE TABLE "auth_challenges" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "auth_challenges_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"type" "auth_challenge_type" NOT NULL,
"token_hash" varchar(64) NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"consumed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "linked_accounts" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "linked_accounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"provider" varchar(64) NOT NULL,
"provider_user_id" varchar(255) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "users_table" ALTER COLUMN "name" SET DATA TYPE varchar(255);--> statement-breakpoint
ALTER TABLE "users_table" ALTER COLUMN "email" SET DATA TYPE varchar(320);--> statement-breakpoint
ALTER TABLE "users_table" ADD COLUMN "password_hash" text;--> statement-breakpoint
UPDATE "users_table" SET "password_hash" = '$2b$10$eUiiFSTi9m98IWSuXJ80jun3VctJ0pKL44rRwvHT.9WOfxvc7r6Ey' WHERE "password_hash" IS NULL;--> statement-breakpoint
ALTER TABLE "users_table" ALTER COLUMN "password_hash" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "users_table" ADD COLUMN "email_verified_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "users_table" ADD COLUMN "session_version" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "users_table" ADD COLUMN "created_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "users_table" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "auth_challenges" ADD CONSTRAINT "auth_challenges_user_id_users_table_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users_table"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "linked_accounts" ADD CONSTRAINT "linked_accounts_user_id_users_table_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users_table"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "auth_challenges_token_hash_idx" ON "auth_challenges" USING btree ("token_hash");--> statement-breakpoint
CREATE UNIQUE INDEX "linked_accounts_provider_uid" ON "linked_accounts" USING btree ("provider","provider_user_id");

306
packages/drizzle-pkg/migrations/meta/0001_snapshot.json

@ -0,0 +1,306 @@
{
"id": "d83420ac-013e-46cd-b8b4-82360fa63544",
"prevId": "ccacc841-1a4a-434b-bfaa-8d18a9a641b2",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.auth_challenges": {
"name": "auth_challenges",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "auth_challenges_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "auth_challenge_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"token_hash": {
"name": "token_hash",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"consumed_at": {
"name": "consumed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"auth_challenges_token_hash_idx": {
"name": "auth_challenges_token_hash_idx",
"columns": [
{
"expression": "token_hash",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"auth_challenges_user_id_users_table_id_fk": {
"name": "auth_challenges_user_id_users_table_id_fk",
"tableFrom": "auth_challenges",
"tableTo": "users_table",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.linked_accounts": {
"name": "linked_accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "linked_accounts_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"provider_user_id": {
"name": "provider_user_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"linked_accounts_provider_uid": {
"name": "linked_accounts_provider_uid",
"columns": [
{
"expression": "provider",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "provider_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"linked_accounts_user_id_users_table_id_fk": {
"name": "linked_accounts_user_id_users_table_id_fk",
"tableFrom": "linked_accounts",
"tableTo": "users_table",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users_table": {
"name": "users_table",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "users_table_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified_at": {
"name": "email_verified_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"session_version": {
"name": "session_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_table_email_unique": {
"name": "users_table_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.auth_challenge_type": {
"name": "auth_challenge_type",
"schema": "public",
"values": [
"email_verify",
"password_reset"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

7
packages/drizzle-pkg/migrations/meta/_journal.json

@ -8,6 +8,13 @@
"when": 1775935934813,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1776000315737,
"tag": "0001_auth_user",
"breakpoints": true
}
]
}

34
packages/drizzle-pkg/seed.ts

@ -1,25 +1,23 @@
import './env';
import { seed } from "drizzle-seed";
import { usersTable } from "./lib/schema/schema";
import { dbGlobal } from "./lib/db";
import { usersTable } from "./lib/schema/schema";
import bcrypt from "bcryptjs";
async function main() {
await seed(dbGlobal, { usersTable }).refine((f) => ({
usersTable: {
columns: {
name: f.fullName(),
age: f.int({ minValue: 18, maxValue: 60 }),
email: f.email(),
},
count: 10,
},
}));
console.log('Seed complete!');
process.exit(0);
const passwordHash = bcrypt.hashSync("seed-password-123", 10);
await dbGlobal.insert(usersTable).values(
Array.from({ length: 5 }).map((_, i) => ({
name: `Seed User ${i}`,
age: 20 + i,
email: `seed${i}@example.com`,
passwordHash,
})),
);
console.log('Seed complete!');
process.exit(0);
}
main().catch(e => {
console.error(e);
process.exit(1);
main().catch((e) => {
console.error(e);
process.exit(1);
});

7
server/api/auth/forgot-password.post.ts

@ -0,0 +1,7 @@
import { readBody } from "h3";
import { forgotPassword } from "../../services/auth";
export default defineEventHandler(async (event) => {
const body = await readBody<{ email?: string }>(event);
return forgotPassword(event, body.email ?? "");
});

10
server/api/auth/login.post.ts

@ -0,0 +1,10 @@
import { readBody } from "h3";
import { loginWithSession } from "../../services/auth";
export default defineEventHandler(async (event) => {
const body = await readBody<{ email?: string; password?: string }>(event);
return loginWithSession(event, {
email: body.email ?? "",
password: body.password ?? "",
});
});

3
server/api/auth/logout.post.ts

@ -0,0 +1,3 @@
import { logoutSession } from "../../services/auth";
export default defineEventHandler((event) => logoutSession(event));

3
server/api/auth/register.post.ts

@ -0,0 +1,3 @@
import { registerWithSession } from "../../services/auth";
export default defineEventHandler((event) => registerWithSession(event));

13
server/api/auth/reset-password.post.ts

@ -0,0 +1,13 @@
import { readBody } from "h3";
import { resetPassword } from "../../services/auth";
export default defineEventHandler(async (event) => {
const body = await readBody<{
token?: string;
new_password?: string;
}>(event);
return resetPassword(event, {
token: body.token ?? "",
new_password: body.new_password ?? "",
});
});

7
server/api/auth/verify-email.post.ts

@ -0,0 +1,7 @@
import { readBody } from "h3";
import { verifyEmail } from "../../services/auth";
export default defineEventHandler(async (event) => {
const body = await readBody<{ token?: string }>(event);
return verifyEmail(event, body.token ?? "");
});

15
server/api/hello.ts

@ -1,15 +0,0 @@
import { usersTable } from "drizzle-pkg/lib/schema/schema";
import { dbGlobal } from "drizzle-pkg/lib/db";
import log4js from "logger";
const logger = log4js.getLogger("APP")
export default defineEventHandler(async (event) => {
logger.info("hello: world");
const users = await dbGlobal.select().from(usersTable)
logger.info("users (formatted): %s \n", JSON.stringify(users, null, 2));
return {
hello: 'world',
users: users,
}
})

3
server/api/me.get.ts

@ -0,0 +1,3 @@
import { getCurrentUser } from "../services/auth";
export default defineEventHandler((event) => getCurrentUser(event));

7
server/api/me.patch.ts

@ -0,0 +1,7 @@
import { readBody } from "h3";
import { patchMe } from "../services/auth";
export default defineEventHandler(async (event) => {
const body = await readBody<{ name?: string; age?: number }>(event);
return patchMe(event, body ?? {});
});

5
server/plugins/04.redis.ts

@ -0,0 +1,5 @@
import { getRedis } from "../utils/redis";
export default defineNitroPlugin(() => {
getRedis();
});

17
server/plugins/05.api-error-json.ts

@ -0,0 +1,17 @@
import { send, setResponseStatus } from "h3";
/**
* 将 API 路由上 `createError({ data: { error } })` 的 body 以 JSON 返回(与规格一致)。
*/
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook("error", async (error, ctx) => {
const event = ctx.event;
if (!event?.path?.startsWith("/api/")) return;
const err = error as { statusCode?: number; data?: unknown };
const data = err.data as { error?: { code: string; message: string } } | undefined;
if (!data?.error) return;
if (event.node.res.headersSent) return;
setResponseStatus(event, err.statusCode ?? 500);
await send(event, JSON.stringify(data), "application/json");
});
});

306
server/services/auth.ts

@ -0,0 +1,306 @@
import { randomBytes } from "node:crypto";
import type { H3Event } from "h3";
import { readBody, getRequestIP } from "h3";
import { and, eq, gt, isNull, sql } from "drizzle-orm";
import { dbGlobal } from "drizzle-pkg/lib/db";
import {
authChallengesTable,
usersTable,
} from "drizzle-pkg/lib/schema/schema";
import log4js from "logger";
import { hashChallengeToken, randomUrlToken } from "../utils/challenge-token";
import { jsonError } from "../utils/errors";
import { noopMailer } from "../utils/mailer";
import { hashPassword, verifyPassword } from "../utils/password";
import { rateLimitOrThrow } from "../utils/rate-limit";
import {
clearSessionCookie,
readSessionIdFromCookie,
writeSessionCookie,
} from "../utils/session-cookie";
import { createSession, deleteSession, readSession } from "../utils/session-redis";
import { sessionTtlSeconds } from "../utils/session-ttl";
import { needsEmailVerified } from "../utils/verification-policy";
const logger = log4js.getLogger("AUTH");
function clientKey(event: H3Event): string {
return (
getRequestIP(event) || (event.node.socket?.remoteAddress ?? "") || "unknown"
);
}
function publicUser(row: typeof usersTable.$inferSelect) {
return {
id: row.id,
name: row.name,
age: row.age,
email: row.email,
emailVerified: Boolean(row.emailVerifiedAt),
};
}
async function requireSessionUser(event: H3Event) {
const sid = readSessionIdFromCookie(event);
if (!sid) jsonError(401, "UNAUTHORIZED", "请先登录");
const sess = await readSession(sid);
if (!sess) jsonError(401, "UNAUTHORIZED", "会话已失效");
const rows = await dbGlobal
.select()
.from(usersTable)
.where(eq(usersTable.id, sess.userId))
.limit(1);
const user = rows[0];
if (!user) {
await deleteSession(sid);
jsonError(401, "UNAUTHORIZED", "用户不存在");
}
if (user.sessionVersion !== sess.sessionVersion) {
await deleteSession(sid);
jsonError(401, "UNAUTHORIZED", "会话已失效");
}
return { user, sessionId: sid };
}
export async function registerWithSession(event: H3Event) {
await rateLimitOrThrow(`rl:register:${clientKey(event)}`, 30, 900);
const body = await readBody<{
email?: string;
password?: string;
name?: string;
age?: number;
}>(event);
const email = (body.email || "").trim().toLowerCase();
const password = body.password || "";
const name = (body.name || "").trim();
const age = body.age;
if (!email.includes("@")) jsonError(400, "INVALID_EMAIL", "邮箱格式无效");
if (password.length < 8) jsonError(400, "WEAK_PASSWORD", "密码至少 8 位");
if (!name) jsonError(400, "INVALID_NAME", "姓名必填");
if (typeof age !== "number" || age < 1 || age > 150) {
jsonError(400, "INVALID_AGE", "年龄无效");
}
const dup = await dbGlobal
.select({ id: usersTable.id })
.from(usersTable)
.where(eq(usersTable.email, email))
.limit(1);
if (dup.length) jsonError(409, "EMAIL_IN_USE", "该邮箱已注册");
const passwordHash = await hashPassword(password);
const inserted = await dbGlobal
.insert(usersTable)
.values({ email, passwordHash, name, age })
.returning();
const user = inserted[0];
if (!user) jsonError(500, "REGISTER_FAILED", "注册失败");
const verifyToken = randomUrlToken();
await dbGlobal.insert(authChallengesTable).values({
userId: user.id,
type: "email_verify",
tokenHash: hashChallengeToken(verifyToken),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
await noopMailer.sendVerificationEmail({ to: email, token: verifyToken });
if (process.env.AUTH_DEBUG_LOG_TOKENS === "true") {
logger.info(`verify email token (debug): ${verifyToken}`);
}
const oldSid = readSessionIdFromCookie(event);
if (oldSid) await deleteSession(oldSid);
const sessionId = randomBytes(32).toString("hex");
await createSession(sessionId, {
userId: user.id,
sessionVersion: user.sessionVersion,
createdAt: new Date().toISOString(),
});
writeSessionCookie(event, sessionId, sessionTtlSeconds());
const res: Record<string, unknown> = { user: publicUser(user) };
if (process.env.NODE_ENV === "test") {
res._testTokens = { verify: verifyToken };
}
return res;
}
export async function loginWithSession(
event: H3Event,
input: { email: string; password: string },
) {
await rateLimitOrThrow(`rl:login:${clientKey(event)}`, 40, 900);
const email = input.email.trim().toLowerCase();
const password = input.password;
if (!email || !password) jsonError(400, "INVALID_INPUT", "邮箱与密码必填");
const rows = await dbGlobal
.select()
.from(usersTable)
.where(eq(usersTable.email, email))
.limit(1);
const user = rows[0];
if (!user || !(await verifyPassword(password, user.passwordHash))) {
jsonError(401, "INVALID_CREDENTIALS", "邮箱或密码错误");
}
const oldSid = readSessionIdFromCookie(event);
if (oldSid) await deleteSession(oldSid);
const sessionId = randomBytes(32).toString("hex");
await createSession(sessionId, {
userId: user.id,
sessionVersion: user.sessionVersion,
createdAt: new Date().toISOString(),
});
writeSessionCookie(event, sessionId, sessionTtlSeconds());
return { user: publicUser(user) };
}
export async function logoutSession(event: H3Event) {
const sid = readSessionIdFromCookie(event);
if (sid) await deleteSession(sid);
clearSessionCookie(event);
return { ok: true as const };
}
export async function getCurrentUser(event: H3Event) {
const { user } = await requireSessionUser(event);
return { user: publicUser(user) };
}
export async function patchMe(
event: H3Event,
body: { name?: string; age?: number },
) {
const { user } = await requireSessionUser(event);
if (needsEmailVerified("patch-me") && !user.emailVerifiedAt) {
jsonError(403, "EMAIL_NOT_VERIFIED", "请先完成邮箱验证");
}
const name = body.name !== undefined ? String(body.name).trim() : user.name;
const age = body.age !== undefined ? body.age : user.age;
if (!name) jsonError(400, "INVALID_NAME", "姓名必填");
if (typeof age !== "number" || age < 1 || age > 150) {
jsonError(400, "INVALID_AGE", "年龄无效");
}
const updatedRows = await dbGlobal
.update(usersTable)
.set({ name, age, updatedAt: new Date() })
.where(eq(usersTable.id, user.id))
.returning();
const updated = updatedRows[0];
if (!updated) jsonError(500, "UPDATE_FAILED", "更新失败");
return { user: publicUser(updated) };
}
export async function forgotPassword(event: H3Event, emailRaw: string) {
await rateLimitOrThrow(`rl:forgot:${clientKey(event)}`, 20, 3600);
const email = emailRaw.trim().toLowerCase();
let resetToken: string | undefined;
const rows = await dbGlobal
.select()
.from(usersTable)
.where(eq(usersTable.email, email))
.limit(1);
const user = rows[0];
if (user) {
resetToken = randomUrlToken();
await dbGlobal.insert(authChallengesTable).values({
userId: user.id,
type: "password_reset",
tokenHash: hashChallengeToken(resetToken),
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
});
await noopMailer.sendPasswordResetEmail({ to: email, token: resetToken });
if (process.env.AUTH_DEBUG_LOG_TOKENS === "true") {
logger.info(`password reset token (debug): ${resetToken}`);
}
}
const base = { ok: true as const };
if (process.env.NODE_ENV === "test" && resetToken) {
return { ...base, _testTokens: { reset: resetToken } };
}
return base;
}
export async function resetPassword(
event: H3Event,
input: { token: string; new_password: string },
) {
const token = input.token;
const new_password = input.new_password;
if (!token || new_password.length < 8) {
jsonError(400, "INVALID_INPUT", "token 或新密码无效");
}
const h = hashChallengeToken(token);
const rows = await dbGlobal
.select()
.from(authChallengesTable)
.where(
and(
eq(authChallengesTable.tokenHash, h),
eq(authChallengesTable.type, "password_reset"),
isNull(authChallengesTable.consumedAt),
gt(authChallengesTable.expiresAt, new Date()),
),
)
.limit(1);
const ch = rows[0];
if (!ch) jsonError(400, "INVALID_TOKEN", "链接无效或已过期");
const passwordHash = await hashPassword(new_password);
await dbGlobal
.update(usersTable)
.set({
passwordHash,
sessionVersion: sql`${usersTable.sessionVersion} + 1`,
updatedAt: new Date(),
})
.where(eq(usersTable.id, ch.userId));
await dbGlobal
.update(authChallengesTable)
.set({ consumedAt: new Date() })
.where(eq(authChallengesTable.id, ch.id));
return { ok: true as const };
}
export async function verifyEmail(event: H3Event, tokenRaw: string) {
const token = tokenRaw.trim();
if (!token) jsonError(400, "INVALID_INPUT", "token 必填");
const h = hashChallengeToken(token);
const rows = await dbGlobal
.select()
.from(authChallengesTable)
.where(
and(
eq(authChallengesTable.tokenHash, h),
eq(authChallengesTable.type, "email_verify"),
isNull(authChallengesTable.consumedAt),
gt(authChallengesTable.expiresAt, new Date()),
),
)
.limit(1);
const ch = rows[0];
if (!ch) jsonError(400, "INVALID_TOKEN", "链接无效或已过期");
await dbGlobal
.update(usersTable)
.set({ emailVerifiedAt: new Date(), updatedAt: new Date() })
.where(eq(usersTable.id, ch.userId));
await dbGlobal
.update(authChallengesTable)
.set({ consumedAt: new Date() })
.where(eq(authChallengesTable.id, ch.id));
return { ok: true as const };
}

9
server/utils/challenge-token.ts

@ -0,0 +1,9 @@
import { createHash, randomBytes } from "node:crypto";
export function randomUrlToken(): string {
return randomBytes(32).toString("base64url");
}
export function hashChallengeToken(token: string): string {
return createHash("sha256").update(token, "utf8").digest("hex");
}

9
server/utils/errors.ts

@ -0,0 +1,9 @@
import { createError } from "h3";
/** 与规格一致的错误体;依赖 Nitro 将 `createError` 的 `data` 序列化进 JSON。 */
export function jsonError(status: number, code: string, message: string): never {
throw createError({
statusCode: status,
data: { error: { code, message } },
});
}

9
server/utils/mailer.ts

@ -0,0 +1,9 @@
export type Mailer = {
sendVerificationEmail(input: { to: string; token: string }): Promise<void>;
sendPasswordResetEmail(input: { to: string; token: string }): Promise<void>;
};
export const noopMailer: Mailer = {
async sendVerificationEmail() {},
async sendPasswordResetEmail() {},
};

15
server/utils/password.ts

@ -0,0 +1,15 @@
import bcrypt from "bcryptjs";
const ROUNDS = 10;
export async function hashPassword(plain: string): Promise<string> {
const salt = await bcrypt.genSalt(ROUNDS);
return bcrypt.hash(plain, salt);
}
export async function verifyPassword(
plain: string,
hash: string,
): Promise<boolean> {
return bcrypt.compare(plain, hash);
}

22
server/utils/rate-limit.ts

@ -0,0 +1,22 @@
import { createError } from "h3";
import { getRedis } from "./redis";
export async function rateLimitOrThrow(
key: string,
limit: number,
windowSeconds: number,
): Promise<void> {
const redis = getRedis();
const n = await redis.incr(key);
if (n === 1) {
await redis.expire(key, windowSeconds);
}
if (n > limit) {
throw createError({
statusCode: 429,
data: {
error: { code: "RATE_LIMITED", message: "请求过于频繁,请稍后再试" },
},
});
}
}

19
server/utils/redis.ts

@ -0,0 +1,19 @@
import Redis from "ioredis";
let client: Redis | null = null;
export function getRedis(): Redis {
if (client) return client;
const url = process.env.REDIS_URL;
if (!url) {
throw new Error("REDIS_URL is required");
}
client = new Redis(url, { maxRetriesPerRequest: 2 });
return client;
}
export async function closeRedis(): Promise<void> {
if (!client) return;
await client.quit();
client = null;
}

37
server/utils/session-cookie.ts

@ -0,0 +1,37 @@
import type { H3Event } from "h3";
import { getCookie, setCookie, deleteCookie } from "h3";
export const SESSION_COOKIE_NAME = "pp_session";
export function readSessionIdFromCookie(event: H3Event): string | undefined {
return getCookie(event, SESSION_COOKIE_NAME);
}
export function writeSessionCookie(
event: H3Event,
sessionId: string,
maxAgeSeconds: number,
): void {
const secure = process.env.NODE_ENV === "production";
const domain = process.env.COOKIE_DOMAIN?.trim() || undefined;
setCookie(event, SESSION_COOKIE_NAME, sessionId, {
httpOnly: true,
sameSite: "lax",
secure,
path: "/",
maxAge: maxAgeSeconds,
domain,
});
}
export function clearSessionCookie(event: H3Event): void {
const secure = process.env.NODE_ENV === "production";
const domain = process.env.COOKIE_DOMAIN?.trim() || undefined;
deleteCookie(event, SESSION_COOKIE_NAME, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure,
domain,
});
}

37
server/utils/session-redis.ts

@ -0,0 +1,37 @@
import { getRedis } from "./redis";
import { sessionTtlSeconds } from "./session-ttl";
export type SessionPayload = {
userId: number;
sessionVersion: number;
createdAt: string;
};
const sessionKey = (id: string) => `sess:${id}`;
export async function createSession(
sessionId: string,
payload: SessionPayload,
): Promise<void> {
const redis = getRedis();
const ttl = sessionTtlSeconds();
await redis.set(sessionKey(sessionId), JSON.stringify(payload), "EX", ttl);
}
export async function readSession(
sessionId: string,
): Promise<SessionPayload | null> {
const redis = getRedis();
const raw = await redis.get(sessionKey(sessionId));
if (!raw) return null;
try {
return JSON.parse(raw) as SessionPayload;
} catch {
return null;
}
}
export async function deleteSession(sessionId: string): Promise<void> {
const redis = getRedis();
await redis.del(sessionKey(sessionId));
}

5
server/utils/session-ttl.ts

@ -0,0 +1,5 @@
export function sessionTtlSeconds(): number {
const raw = process.env.SESSION_TTL_SECONDS;
const n = raw ? Number(raw) : NaN;
return Number.isFinite(n) && n > 0 ? n : 604800;
}

5
server/utils/verification-policy.ts

@ -0,0 +1,5 @@
const NEEDS_VERIFIED = new Set<string>(["patch-me"]);
export function needsEmailVerified(handlerId: string): boolean {
return NEEDS_VERIFIED.has(handlerId);
}

57
test/integration/auth.http.test.ts

@ -0,0 +1,57 @@
import { describe, it, expect } from "bun:test";
const BASE = process.env.TEST_BASE_URL ?? "http://127.0.0.1:3000";
function parseCookie(res: Response): string {
const raw = res.headers.get("set-cookie");
if (!raw) return "";
return raw.split(";")[0] ?? "";
}
(process.env.TEST_INTEGRATION ? describe : describe.skip)("auth over HTTP", () => {
it("register → me → verify → patch", async () => {
const email = `u${Date.now()}@example.com`;
const reg = await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email,
password: "password123",
name: "T",
age: 30,
}),
});
expect(reg.status).toBe(200);
const regJson = (await reg.json()) as {
user: { emailVerified: boolean };
_testTokens?: { verify?: string };
};
expect(regJson.user.emailVerified).toBe(false);
const cookie = parseCookie(reg);
expect(cookie.length).toBeGreaterThan(5);
const me1 = await fetch(`${BASE}/api/me`, { headers: { cookie } });
expect(me1.status).toBe(200);
const patch1 = await fetch(`${BASE}/api/me`, {
method: "PATCH",
headers: { cookie, "content-type": "application/json" },
body: JSON.stringify({ name: "T2" }),
});
expect(patch1.status).toBe(403);
const verify = await fetch(`${BASE}/api/auth/verify-email`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: regJson._testTokens?.verify }),
});
expect(verify.status).toBe(200);
const patch2 = await fetch(`${BASE}/api/me`, {
method: "PATCH",
headers: { cookie, "content-type": "application/json" },
body: JSON.stringify({ name: "T2" }),
});
expect(patch2.status).toBe(200);
});
});

15
test/unit/challenge-token.test.ts

@ -0,0 +1,15 @@
import { describe, expect, it } from "bun:test";
import {
hashChallengeToken,
randomUrlToken,
} from "../../server/utils/challenge-token";
describe("challenge-token", () => {
it("hash is stable", () => {
expect(hashChallengeToken("abc")).toBe(hashChallengeToken("abc"));
});
it("random has reasonable length", () => {
expect(randomUrlToken().length).toBeGreaterThan(20);
});
});

10
test/unit/password.test.ts

@ -0,0 +1,10 @@
import { describe, expect, it } from "bun:test";
import { hashPassword, verifyPassword } from "../../server/utils/password";
describe("password", () => {
it("hashes and verifies", async () => {
const h = await hashPassword("hunter2");
expect(await verifyPassword("hunter2", h)).toBe(true);
expect(await verifyPassword("wrong", h)).toBe(false);
});
});

17
test/unit/session-ttl.test.ts

@ -0,0 +1,17 @@
import { describe, expect, it, beforeEach } from "bun:test";
import { sessionTtlSeconds } from "../../server/utils/session-ttl";
describe("sessionTtlSeconds", () => {
beforeEach(() => {
delete process.env.SESSION_TTL_SECONDS;
});
it("defaults to 7d", () => {
expect(sessionTtlSeconds()).toBe(604800);
});
it("respects env", () => {
process.env.SESSION_TTL_SECONDS = "120";
expect(sessionTtlSeconds()).toBe(120);
});
});

11
test/unit/verification-policy.test.ts

@ -0,0 +1,11 @@
import { describe, expect, it } from "bun:test";
import { needsEmailVerified } from "../../server/utils/verification-policy";
describe("verification-policy", () => {
it("patch-me requires verified", () => {
expect(needsEmailVerified("patch-me")).toBe(true);
});
it("unknown defaults false", () => {
expect(needsEmailVerified("other")).toBe(false);
});
});
Loading…
Cancel
Save