Compare commits
13 Commits
main
...
feat/auth-
| Author | SHA1 | Date |
|---|---|---|
|
|
ba28a7c321 | 6 months ago |
|
|
38ab5a14aa | 6 months ago |
|
|
36e1579867 | 6 months ago |
|
|
11f83ad807 | 6 months ago |
|
|
3c09e11c3a | 6 months ago |
|
|
3401ccb2e7 | 6 months ago |
|
|
4d00645adb | 6 months ago |
|
|
a26976d325 | 6 months ago |
|
|
46f4a6f6ea | 6 months ago |
|
|
cd068d5863 | 6 months ago |
|
|
63f4c4b2da | 6 months ago |
|
|
59d524fbd2 | 6 months ago |
|
|
f33b4c9043 | 6 months ago |
43 changed files with 2801 additions and 40 deletions
@ -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= |
|||
|
|||
@ -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 } }); |
|||
} |
|||
}); |
|||
@ -0,0 +1,9 @@ |
|||
export default defineNuxtRouteMiddleware(async () => { |
|||
const fetch = useRequestFetch(); |
|||
try { |
|||
await fetch("/api/me", { credentials: "include" }); |
|||
return navigateTo("/me"); |
|||
} catch { |
|||
return; |
|||
} |
|||
}); |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
File diff suppressed because it is too large
@ -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 各节确认合并) | |
|||
@ -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, |
|||
), |
|||
}), |
|||
); |
|||
|
|||
@ -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"); |
|||
@ -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": {} |
|||
} |
|||
} |
|||
@ -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 ?? ""); |
|||
}); |
|||
@ -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 ?? "", |
|||
}); |
|||
}); |
|||
@ -0,0 +1,3 @@ |
|||
import { logoutSession } from "../../services/auth"; |
|||
|
|||
export default defineEventHandler((event) => logoutSession(event)); |
|||
@ -0,0 +1,3 @@ |
|||
import { registerWithSession } from "../../services/auth"; |
|||
|
|||
export default defineEventHandler((event) => registerWithSession(event)); |
|||
@ -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 ?? "", |
|||
}); |
|||
}); |
|||
@ -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 ?? ""); |
|||
}); |
|||
@ -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, |
|||
} |
|||
}) |
|||
@ -0,0 +1,3 @@ |
|||
import { getCurrentUser } from "../services/auth"; |
|||
|
|||
export default defineEventHandler((event) => getCurrentUser(event)); |
|||
@ -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 ?? {}); |
|||
}); |
|||
@ -0,0 +1,5 @@ |
|||
import { getRedis } from "../utils/redis"; |
|||
|
|||
export default defineNitroPlugin(() => { |
|||
getRedis(); |
|||
}); |
|||
@ -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"); |
|||
}); |
|||
}); |
|||
@ -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 }; |
|||
} |
|||
@ -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"); |
|||
} |
|||
@ -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 } }, |
|||
}); |
|||
} |
|||
@ -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() {}, |
|||
}; |
|||
@ -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); |
|||
} |
|||
@ -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: "请求过于频繁,请稍后再试" }, |
|||
}, |
|||
}); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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, |
|||
}); |
|||
} |
|||
@ -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)); |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -0,0 +1,5 @@ |
|||
const NEEDS_VERIFIED = new Set<string>(["patch-me"]); |
|||
|
|||
export function needsEmailVerified(handlerId: string): boolean { |
|||
return NEEDS_VERIFIED.has(handlerId); |
|||
} |
|||
@ -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); |
|||
}); |
|||
}); |
|||
@ -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); |
|||
}); |
|||
}); |
|||
@ -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); |
|||
}); |
|||
}); |
|||
@ -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); |
|||
}); |
|||
}); |
|||
@ -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…
Reference in new issue