From 537e8d106086e990adb735286bac867e9d7d5459 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Thu, 16 Apr 2026 20:01:57 +0800 Subject: [PATCH] feat: add username-password auth flow Implement the username-password register and login flow with cookie-backed sessions, auth APIs, and login/register pages. Include the supporting auth schema, migration files, service validation fixes, and planning/design docs for the scoped delivery. Made-with: Cursor --- .gitignore | 3 + app/pages/login/index.vue | 55 +-- app/pages/register/index.vue | 122 +++++++ bun.lock | 3 + .../plans/2026-04-16-username-password-auth.md | 368 +++++++++++++++++++++ .../2026-04-16-username-password-auth-design.md | 207 ++++++++++++ package.json | 1 + packages/drizzle-pkg/database/pg/schema/auth.ts | 15 +- packages/drizzle-pkg/lib/schema/auth.ts | 2 +- .../drizzle-pkg/migrations/0001_auth-sessions.sql | 8 + .../0002_auth-sessions-quality-fixes.sql | 2 + .../drizzle-pkg/migrations/meta/0001_snapshot.json | 142 ++++++++ .../drizzle-pkg/migrations/meta/0002_snapshot.json | 158 +++++++++ packages/drizzle-pkg/migrations/meta/_journal.json | 14 + server/api/auth/login.post.ts | 57 ++++ server/api/auth/logout.post.ts | 28 ++ server/api/auth/me.get.ts | 42 +++ server/api/auth/register.post.ts | 45 +++ server/service/auth/index.test.ts | 25 ++ server/service/auth/index.ts | 235 ++++++++++++- server/utils/handler.ts | 2 +- 21 files changed, 1505 insertions(+), 29 deletions(-) create mode 100644 app/pages/register/index.vue create mode 100644 docs/superpowers/plans/2026-04-16-username-password-auth.md create mode 100644 docs/superpowers/specs/2026-04-16-username-password-auth-design.md create mode 100644 packages/drizzle-pkg/migrations/0001_auth-sessions.sql create mode 100644 packages/drizzle-pkg/migrations/0002_auth-sessions-quality-fixes.sql create mode 100644 packages/drizzle-pkg/migrations/meta/0001_snapshot.json create mode 100644 packages/drizzle-pkg/migrations/meta/0002_snapshot.json create mode 100644 server/api/auth/login.post.ts create mode 100644 server/api/auth/logout.post.ts create mode 100644 server/api/auth/me.get.ts create mode 100644 server/api/auth/register.post.ts create mode 100644 server/service/auth/index.test.ts diff --git a/.gitignore b/.gitignore index 4a7f73a..0728c8a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ logs .env .env.* !.env.example + +# Local git worktrees +.worktrees diff --git a/app/pages/login/index.vue b/app/pages/login/index.vue index 7c0c9ff..c5c5caf 100644 --- a/app/pages/login/index.vue +++ b/app/pages/login/index.vue @@ -1,5 +1,6 @@ + + diff --git a/bun.lock b/bun.lock index 2e8960f..f59001e 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "person-panel", "dependencies": { "@nuxt/ui": "^4.6.1", + "bcryptjs": "^3.0.3", "better-sqlite3": "^12.9.0", "dotenv": "17.4.1", "drizzle-orm": "0.45.2", @@ -799,6 +800,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=="], + "better-sqlite3": ["better-sqlite3@12.9.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ=="], "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], diff --git a/docs/superpowers/plans/2026-04-16-username-password-auth.md b/docs/superpowers/plans/2026-04-16-username-password-auth.md new file mode 100644 index 0000000..de1b550 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-username-password-auth.md @@ -0,0 +1,368 @@ +# Username Password Auth Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build only username/password register-login with HttpOnly Cookie session, plus logout and current-user APIs. + +**Architecture:** Keep auth logic in `server/service/auth/index.ts`, expose four thin API handlers under `server/api/auth/`, and persist sessions in PostgreSQL via Drizzle schema. Frontend only updates `app/pages/login/index.vue` and adds `app/pages/register/index.vue` to call real APIs; no extra auth features are added. + +**Tech Stack:** Nuxt 4 + Nitro server routes, Drizzle ORM (PostgreSQL), Bun scripts, Nuxt UI form components. + +--- + +## File Structure Map + +- Modify: `packages/drizzle-pkg/database/pg/schema/auth.ts` (add `sessions` table) +- Modify: `packages/drizzle-pkg/lib/schema/auth.ts` (export `sessions`) +- Create: `server/api/auth/register.post.ts` +- Create: `server/api/auth/login.post.ts` +- Create: `server/api/auth/logout.post.ts` +- Create: `server/api/auth/me.get.ts` +- Modify: `server/service/auth/index.ts` (register/login/logout/me service functions) +- Modify: `app/pages/login/index.vue` (username+password real API) +- Create: `app/pages/register/index.vue` + +--- + +### Task 1: Prepare DB session model + +**Files:** +- Modify: `packages/drizzle-pkg/database/pg/schema/auth.ts` +- Modify: `packages/drizzle-pkg/lib/schema/auth.ts` +- Test: `packages/drizzle-pkg/database/pg/schema/auth.ts` (type-check + migration generation output) + +- [ ] **Step 1: Write the failing schema expectation** + +```ts +// Expected new table in auth schema: +// export const sessions = pgTable("sessions", { ... }) +// +// Expected exported symbol: +// export { users, sessions } from '../../database/pg/schema/auth' +``` + +- [ ] **Step 2: Verify current state is missing sessions** + +Run: `rg "sessions" packages/drizzle-pkg/database/pg/schema/auth.ts packages/drizzle-pkg/lib/schema/auth.ts` +Expected: no `sessions` table export in current output. + +- [ ] **Step 3: Add minimal schema implementation** + +```ts +// packages/drizzle-pkg/database/pg/schema/auth.ts +import { sql } from "drizzle-orm"; +import { integer, pgTable, timestamp, varchar } from "drizzle-orm/pg-core"; + +export const sessions = pgTable("sessions", { + id: varchar().primaryKey(), + userId: integer().notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); +``` + +```ts +// packages/drizzle-pkg/lib/schema/auth.ts +export { users, sessions } from "../../database/pg/schema/auth"; +``` + +- [ ] **Step 4: Generate migration and verify** + +Run: `bun run db:generate -- auth-sessions` +Expected: new migration created for `sessions` table with correct columns. + +- [ ] **Step 5: Commit** + +```bash +git add packages/drizzle-pkg/database/pg/schema/auth.ts packages/drizzle-pkg/lib/schema/auth.ts packages/drizzle-pkg/database/pg/migrations +git commit -m "feat: add sessions schema for auth" +``` + +--- + +### Task 2: Implement auth service (register/login/logout/me) + +**Files:** +- Modify: `server/service/auth/index.ts` +- Test: `server/service/auth/index.ts` (manual function-level behavior check via API handlers in next task) + +- [ ] **Step 1: Write the failing behavior checklist** + +```ts +// register(username, password) +// - validates username /^[a-zA-Z0-9_]{3,20}$/ +// - validates password length >= 6 +// - throws on duplicate username +// +// login(username, password) +// - validates credentials +// - returns session payload (sessionId, expiresAt, user) +// +// logout(sessionId) +// - deletes session record +// +// getMe(sessionId) +// - returns null when missing/expired +// - returns { id, username } when valid +``` + +- [ ] **Step 2: Confirm current service cannot satisfy checklist** + +Run: `rg "register|login|logout|getMe" server/service/auth/index.ts` +Expected: these functions are absent. + +- [ ] **Step 3: Add minimal implementation** + +```ts +// server/service/auth/index.ts (key shape) +export async function registerUser(input: { username: string; password: string }) { /* validate + insert */ } +export async function loginUser(input: { username: string; password: string }) { /* verify + create session */ } +export async function logoutUser(sessionId: string) { /* delete session */ } +export async function getCurrentUser(sessionId: string) { /* validate session + fetch user */ } +``` + +Implementation notes in this step: +- password hashing/comparison: `bun add bcryptjs` then use `hash` / `compare`. +- session id generation: `crypto.randomUUID()`. +- session expiry: `new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)`. +- return only minimal user fields. + +- [ ] **Step 4: Run type check/dev build sanity** + +Run: `bun run build` +Expected: build completes without TypeScript errors from auth service changes. + +- [ ] **Step 5: Commit** + +```bash +git add server/service/auth/index.ts package.json bun.lock +git commit -m "feat: implement auth service for username-password flow" +``` + +--- + +### Task 3: Add auth API routes with HttpOnly Cookie + +**Files:** +- Create: `server/api/auth/register.post.ts` +- Create: `server/api/auth/login.post.ts` +- Create: `server/api/auth/logout.post.ts` +- Create: `server/api/auth/me.get.ts` +- Test: these four route files through local HTTP calls + +- [ ] **Step 1: Write failing API contract examples** + +```ts +// register.post.ts: accepts { username, password }, returns success json +// login.post.ts: accepts { username, password }, sets cookie "pp_session" +// me.get.ts: reads cookie, returns current user or unauthorized +// logout.post.ts: clears cookie and invalidates session +``` + +- [ ] **Step 2: Verify routes do not yet exist** + +Run: `ls server/api/auth` +Expected: folder/files missing (or missing required four handlers). + +- [ ] **Step 3: Implement handlers with thin controller pattern** + +```ts +// login.post.ts (core shape) +const body = await readBody(event); +const result = await loginUser(body); +setCookie(event, "pp_session", result.sessionId, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 60 * 60 * 24 * 7, +}); +return { code: 1, message: "登录成功", data: result.user }; +``` + +```ts +// me.get.ts (core shape) +const sessionId = getCookie(event, "pp_session"); +const user = await getCurrentUser(sessionId ?? ""); +if (!user) throw createError({ statusCode: 401, statusMessage: "未登录" }); +return { code: 1, message: "ok", data: user }; +``` + +- [ ] **Step 4: Run API smoke checks** + +Run: +`bun run dev` then in another terminal: + +```bash +curl -i -X POST http://localhost:3000/api/auth/register \ + -H "content-type: application/json" \ + -d '{"username":"demo_user","password":"123456"}' + +curl -i -c cookie.txt -X POST http://localhost:3000/api/auth/login \ + -H "content-type: application/json" \ + -d '{"username":"demo_user","password":"123456"}' + +curl -i -b cookie.txt http://localhost:3000/api/auth/me +curl -i -b cookie.txt -X POST http://localhost:3000/api/auth/logout +``` + +Expected: register/login/me/logout all return success on happy path; `Set-Cookie` appears on login. + +- [ ] **Step 5: Commit** + +```bash +git add server/api/auth +git commit -m "feat: add auth api routes with cookie session" +``` + +--- + +### Task 4: Update login page to username/password real login + +**Files:** +- Modify: `app/pages/login/index.vue` +- Test: login page manual browser check + +- [ ] **Step 1: Write failing UI expectation** + +```vue + + + +``` + +- [ ] **Step 2: Confirm current UI still email + mock delay** + +Run: `rg "email|setTimeout|you@example.com" app/pages/login/index.vue` +Expected: current file still uses email and simulated success. + +- [ ] **Step 3: Implement minimal page changes** + +```ts +type LoginFormState = { username: string; password: string }; +const res = await $fetch("/api/auth/login", { method: "POST", body: state }); +resultType.value = "success"; +resultMessage.value = `登录成功,欢迎 ${res.data.username}`; +``` + +```vue + + + +``` + +- [ ] **Step 4: Verify behavior in browser** + +Run: `bun run dev` and open `/login` +Expected: validation matches username/password rules; successful login calls backend and shows success. + +- [ ] **Step 5: Commit** + +```bash +git add app/pages/login/index.vue +git commit -m "feat: wire login page to username-password auth api" +``` + +--- + +### Task 5: Add register page and complete acceptance checklist + +**Files:** +- Create: `app/pages/register/index.vue` +- Test: end-to-end acceptance via browser and curl + +- [ ] **Step 1: Write failing acceptance checklist** + +```md +- register success +- duplicate username rejected +- login success sets cookie +- invalid credentials rejected +- me returns user when logged in +- logout clears session +``` + +- [ ] **Step 2: Confirm register page is missing** + +Run: `ls app/pages/register/index.vue` +Expected: file not found. + +- [ ] **Step 3: Implement minimal register page** + +```ts +// app/pages/register/index.vue (core submit) +await $fetch("/api/auth/register", { method: "POST", body: state }); +await navigateTo("/login"); +``` + +```vue + + + +``` + +- [ ] **Step 4: Execute full acceptance checks** + +Run: +- `bun run dev` +- Browser: `/register` -> `/login` happy path +- Browser/API: repeat register for same username should fail +- `curl -b cookie.txt http://localhost:3000/api/auth/me` after logout should return unauthorized + +Expected: all six acceptance points pass exactly as spec defines. + +- [ ] **Step 5: Commit** + +```bash +git add app/pages/register/index.vue +git commit -m "feat: add register page for username-password flow" +``` + +--- + +### Task 6: Final verification and integration commit + +**Files:** +- Modify: all files from Tasks 1-5 (no new scope) +- Test: migration + build + manual auth flow + +- [ ] **Step 1: Run migration** + +Run: `bun run db:migrate` +Expected: migrations apply successfully, including `sessions` table. + +- [ ] **Step 2: Run production build check** + +Run: `bun run build` +Expected: build succeeds. + +- [ ] **Step 3: Re-run smoke auth flow** + +```bash +# happy path +curl -i -X POST http://localhost:3000/api/auth/register -H "content-type: application/json" -d '{"username":"final_user","password":"123456"}' +curl -i -c cookie.txt -X POST http://localhost:3000/api/auth/login -H "content-type: application/json" -d '{"username":"final_user","password":"123456"}' +curl -i -b cookie.txt http://localhost:3000/api/auth/me +curl -i -b cookie.txt -X POST http://localhost:3000/api/auth/logout +curl -i -b cookie.txt http://localhost:3000/api/auth/me +``` + +Expected: first `me` success, final `me` unauthorized. + +- [ ] **Step 4: Create integration commit** + +```bash +git add . +git commit -m "feat: implement username-password auth with cookie sessions" +``` + +- [ ] **Step 5: Record delivered scope** + +```md +Delivered only: +- register/login/logout/me +- username/password auth +- HttpOnly cookie session +Not delivered: +- forgot password, oauth, captcha, sms, email auth +``` diff --git a/docs/superpowers/specs/2026-04-16-username-password-auth-design.md b/docs/superpowers/specs/2026-04-16-username-password-auth-design.md new file mode 100644 index 0000000..c6740dd --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-username-password-auth-design.md @@ -0,0 +1,207 @@ +# 用户名密码登录注册设计文档 + +日期:2026-04-16 +范围:仅实现用户名 + 密码的注册、登录、登出、登录态查询 +目标:按主线交付可用认证闭环,不扩展额外功能 + +## 1. 需求边界 + +### 1.1 必做功能 + +- 用户注册(用户名 + 密码) +- 用户登录(用户名 + 密码) +- 登录后通过 HttpOnly Cookie 维持会话 +- 登出清理会话 +- 获取当前登录用户信息(`/api/auth/me`) + +### 1.2 明确不做 + +- 邮箱/手机登录 +- 验证码 +- 忘记密码 +- 第三方登录 +- 复杂权限系统 +- 刷新 token、多设备管理 +- 非必要 UI 优化 + +## 2. 约束与规则 + +### 2.1 字段规则 + +- `username`:3-20 位,仅允许字母、数字、下划线(正则:`^[a-zA-Z0-9_]{3,20}$`) +- `password`:最少 6 位 + +### 2.2 会话方式 + +- 登录成功后由后端写入 `HttpOnly Cookie` +- Cookie 建议配置: + - `httpOnly: true` + - `sameSite: 'lax'` + - `secure: true`(仅生产环境) + - `path: '/'` + - `maxAge: 60 * 60 * 24 * 7`(7 天) + +## 3. 接口设计 + +### 3.1 `POST /api/auth/register` + +请求体: + +```json +{ + "username": "demo_user", + "password": "123456" +} +``` + +行为: + +1. 校验参数格式 +2. 检查用户名是否已存在 +3. 生成密码哈希并写入 `users` +4. 返回注册成功 + +错误: + +- 参数错误 +- 用户名已存在 + +### 3.2 `POST /api/auth/login` + +请求体: + +```json +{ + "username": "demo_user", + "password": "123456" +} +``` + +行为: + +1. 校验参数格式 +2. 按用户名查用户 +3. 校验密码哈希 +4. 创建 session +5. 写入 `HttpOnly Cookie` +6. 返回登录成功及最小用户信息 + +错误: + +- 参数错误 +- 用户名或密码错误(统一提示) + +### 3.3 `POST /api/auth/logout` + +行为: + +1. 从 Cookie 读取 session 标识 +2. 删除或失效 session +3. 清空 Cookie +4. 返回登出成功 + +### 3.4 `GET /api/auth/me` + +行为: + +1. 从 Cookie 读取 session +2. 校验 session 是否存在且未过期 +3. 查询用户并返回最小信息(`id`、`username`) + +错误: + +- 未登录或会话失效 + +## 4. 数据模型设计 + +### 4.1 复用表:`users` + +基于现有结构,认证主链仅依赖: + +- `id` +- `username`(唯一) +- `password`(哈希后存储) + +### 4.2 新增表:`sessions` + +建议字段: + +- `id`:session 主键(随机字符串/UUID) +- `userId`:关联 `users.id` +- `expiresAt`:过期时间 +- `createdAt`:创建时间 + +说明: + +- Cookie 仅保存 session 标识,不保存用户敏感信息 +- `me` 通过 session 回查用户 + +## 5. 代码落点(最小改动) + +### 5.1 后端 + +- `server/service/auth/index.ts` + - 实现:`register`、`login`、`logout`、`getMe` +- `server/api/auth/register.post.ts` +- `server/api/auth/login.post.ts` +- `server/api/auth/logout.post.ts` +- `server/api/auth/me.get.ts` +- `packages/drizzle-pkg/database/pg/schema/auth.ts` + - 新增 `sessions` 表 +- `packages/drizzle-pkg/lib/schema/auth.ts` + - 导出 `sessions` +- 数据库迁移文件(按项目迁移流程新增) + +### 5.2 前端 + +- `app/pages/login/index.vue` + - 由邮箱改为用户名输入 + - 从模拟提交改为调用 `POST /api/auth/login` +- `app/pages/register/index.vue` + - 新增注册页,调用 `POST /api/auth/register` + +## 6. 业务流程 + +### 6.1 注册 + +前端提交 -> 后端校验 -> 用户名唯一检查 -> 哈希密码入库 -> 返回成功 + +### 6.2 登录 + +前端提交 -> 后端校验 -> 用户验证 -> session 入库 -> 写 Cookie -> 返回成功 + +### 6.3 登录态读取 + +请求到达 -> 读取 Cookie -> 校验 session -> 查询用户 -> 返回最小用户信息 + +### 6.4 登出 + +读取 Cookie -> 删除 session -> 清空 Cookie -> 返回成功 + +## 7. 错误与安全策略 + +- 密码只存哈希,不存明文 +- 登录失败统一提示“用户名或密码错误” +- 参数错误与业务错误分离 +- `me` 与 `logout` 对无效 session 返回未授权 + +## 8. 验收标准 + +- 可成功注册新用户 +- 重复用户名注册被拒绝 +- 正确凭据可登录并写入 HttpOnly Cookie +- 错误凭据登录失败且错误信息统一 +- `GET /api/auth/me` 在已登录状态下返回用户信息 +- `POST /api/auth/logout` 后再次访问 `me` 返回未登录 + +## 9. 实施顺序建议 + +1. 数据表与迁移(`sessions`) +2. `server/service/auth` 业务函数 +3. 认证 API 路由 +4. 登录页与注册页对接 +5. 手工验收 6 条用例 + +--- + +本设计文档仅覆盖当前主线目标:用户名密码注册登录,不包含任何扩展认证能力。 diff --git a/package.json b/package.json index eb0778a..d6296a6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@nuxt/ui": "^4.6.1", + "bcryptjs": "^3.0.3", "better-sqlite3": "^12.9.0", "dotenv": "17.4.1", "drizzle-orm": "0.45.2", diff --git a/packages/drizzle-pkg/database/pg/schema/auth.ts b/packages/drizzle-pkg/database/pg/schema/auth.ts index db1b2e9..d5dd346 100644 --- a/packages/drizzle-pkg/database/pg/schema/auth.ts +++ b/packages/drizzle-pkg/database/pg/schema/auth.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { integer, pgTable, timestamp, varchar } from "drizzle-orm/pg-core"; +import { index, integer, pgTable, timestamp, varchar } from "drizzle-orm/pg-core"; export const users = pgTable("users", { id: integer().primaryKey(), @@ -13,4 +13,15 @@ export const users = pgTable("users", { .defaultNow() .$onUpdate(() => sql`CURRENT_TIMESTAMP`) .notNull(), -}); \ No newline at end of file +}); + +export const sessions = pgTable("sessions", { + id: varchar().primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => [ + index("sessions_user_id_idx").on(table.userId), +]); \ No newline at end of file diff --git a/packages/drizzle-pkg/lib/schema/auth.ts b/packages/drizzle-pkg/lib/schema/auth.ts index a342588..6b43ce3 100644 --- a/packages/drizzle-pkg/lib/schema/auth.ts +++ b/packages/drizzle-pkg/lib/schema/auth.ts @@ -1 +1 @@ -export { users } from '../../database/pg/schema/auth' \ No newline at end of file +export { users, sessions } from '../../database/pg/schema/auth' \ No newline at end of file diff --git a/packages/drizzle-pkg/migrations/0001_auth-sessions.sql b/packages/drizzle-pkg/migrations/0001_auth-sessions.sql new file mode 100644 index 0000000..a7a05b4 --- /dev/null +++ b/packages/drizzle-pkg/migrations/0001_auth-sessions.sql @@ -0,0 +1,8 @@ +CREATE TABLE "sessions" ( + "id" varchar PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/drizzle-pkg/migrations/0002_auth-sessions-quality-fixes.sql b/packages/drizzle-pkg/migrations/0002_auth-sessions-quality-fixes.sql new file mode 100644 index 0000000..d449591 --- /dev/null +++ b/packages/drizzle-pkg/migrations/0002_auth-sessions-quality-fixes.sql @@ -0,0 +1,2 @@ +ALTER TABLE "sessions" ALTER COLUMN "expires_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +CREATE INDEX "sessions_user_id_idx" ON "sessions" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/drizzle-pkg/migrations/meta/0001_snapshot.json b/packages/drizzle-pkg/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..de6938d --- /dev/null +++ b/packages/drizzle-pkg/migrations/meta/0001_snapshot.json @@ -0,0 +1,142 @@ +{ + "id": "b2828e1e-04fb-495c-9a25-233d062ee460", + "prevId": "25840823-aa2a-4e32-a6b6-70bb1e27348e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "nickname": { + "name": "nickname", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/drizzle-pkg/migrations/meta/0002_snapshot.json b/packages/drizzle-pkg/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..3acec7a --- /dev/null +++ b/packages/drizzle-pkg/migrations/meta/0002_snapshot.json @@ -0,0 +1,158 @@ +{ + "id": "3caf12bf-ffef-4a49-b1ed-9af7f01551f4", + "prevId": "b2828e1e-04fb-495c-9a25-233d062ee460", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "nickname": { + "name": "nickname", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/drizzle-pkg/migrations/meta/_journal.json b/packages/drizzle-pkg/migrations/meta/_journal.json index b60f9b1..fb1dc16 100644 --- a/packages/drizzle-pkg/migrations/meta/_journal.json +++ b/packages/drizzle-pkg/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1776329125490, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1776336996454, + "tag": "0001_auth-sessions", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1776337212081, + "tag": "0002_auth-sessions-quality-fixes", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts new file mode 100644 index 0000000..721a3fc --- /dev/null +++ b/server/api/auth/login.post.ts @@ -0,0 +1,57 @@ +import { AuthFailedError, AuthValidationError, loginUser } from "../../service/auth"; + +type LoginBody = { + username: string; + password: string; +}; + +const SESSION_COOKIE_NAME = "pp_session"; +const SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; + +function hasStatusCode(err: unknown): err is { statusCode: number } { + return typeof err === "object" && err !== null && "statusCode" in err + && typeof (err as { statusCode?: unknown }).statusCode === "number"; +} + +function toPublicError(err: unknown) { + if (hasStatusCode(err)) { + return err; + } + if (err instanceof AuthValidationError) { + return createError({ + statusCode: 400, + statusMessage: err.message, + }); + } + if (err instanceof AuthFailedError) { + return createError({ + statusCode: 401, + statusMessage: err.message, + }); + } + return createError({ + statusCode: 500, + statusMessage: "服务器繁忙,请稍后重试", + }); +} + +export default defineWrappedResponseHandler(async (event) => { + try { + const body = await readBody(event); + const result = await loginUser(body); + + setCookie(event, SESSION_COOKIE_NAME, result.sessionId, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: SESSION_MAX_AGE_SECONDS, + }); + + return R.success({ + user: result.user, + }); + } catch (err) { + throw toPublicError(err); + } +}); diff --git a/server/api/auth/logout.post.ts b/server/api/auth/logout.post.ts new file mode 100644 index 0000000..a459ce5 --- /dev/null +++ b/server/api/auth/logout.post.ts @@ -0,0 +1,28 @@ +import { logoutUser } from "../../service/auth"; + +const SESSION_COOKIE_NAME = "pp_session"; + +export default defineWrappedResponseHandler(async (event) => { + try { + const sessionId = getCookie(event, SESSION_COOKIE_NAME); + if (sessionId) { + await logoutUser(sessionId); + } + + deleteCookie(event, SESSION_COOKIE_NAME, { + path: "/", + }); + + return R.success({ + success: true, + }); + } catch (err) { + if (err && typeof err === "object" && "statusCode" in err) { + throw err; + } + throw createError({ + statusCode: 500, + statusMessage: "服务器繁忙,请稍后重试", + }); + } +}); diff --git a/server/api/auth/me.get.ts b/server/api/auth/me.get.ts new file mode 100644 index 0000000..d53f7c7 --- /dev/null +++ b/server/api/auth/me.get.ts @@ -0,0 +1,42 @@ +import { getCurrentUser } from "../../service/auth"; + +const SESSION_COOKIE_NAME = "pp_session"; +const UNAUTHORIZED_MESSAGE = "未登录或会话已失效"; + +export default defineWrappedResponseHandler(async (event) => { + try { + const sessionId = getCookie(event, SESSION_COOKIE_NAME); + if (!sessionId) { + deleteCookie(event, SESSION_COOKIE_NAME, { + path: "/", + }); + throw createError({ + statusCode: 401, + statusMessage: UNAUTHORIZED_MESSAGE, + }); + } + + const user = await getCurrentUser(sessionId); + if (!user) { + deleteCookie(event, SESSION_COOKIE_NAME, { + path: "/", + }); + throw createError({ + statusCode: 401, + statusMessage: UNAUTHORIZED_MESSAGE, + }); + } + + return R.success({ + user, + }); + } catch (err) { + if (err && typeof err === "object" && "statusCode" in err) { + throw err; + } + throw createError({ + statusCode: 500, + statusMessage: "服务器繁忙,请稍后重试", + }); + } +}); diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts new file mode 100644 index 0000000..b31d6c7 --- /dev/null +++ b/server/api/auth/register.post.ts @@ -0,0 +1,45 @@ +import { AuthConflictError, AuthValidationError, registerUser } from "../../service/auth"; + +type RegisterBody = { + username: string; + password: string; +}; + +function hasStatusCode(err: unknown): err is { statusCode: number } { + return typeof err === "object" && err !== null && "statusCode" in err + && typeof (err as { statusCode?: unknown }).statusCode === "number"; +} + +function toPublicError(err: unknown) { + if (hasStatusCode(err)) { + return err; + } + if (err instanceof AuthValidationError) { + return createError({ + statusCode: 400, + statusMessage: err.message, + }); + } + if (err instanceof AuthConflictError) { + return createError({ + statusCode: 409, + statusMessage: err.message, + }); + } + return createError({ + statusCode: 500, + statusMessage: "服务器繁忙,请稍后重试", + }); +} + +export default defineWrappedResponseHandler(async (event) => { + try { + const body = await readBody(event); + const user = await registerUser(body); + return R.success({ + user, + }); + } catch (err) { + throw toPublicError(err); + } +}); diff --git a/server/service/auth/index.test.ts b/server/service/auth/index.test.ts new file mode 100644 index 0000000..c13c618 --- /dev/null +++ b/server/service/auth/index.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; + +process.env.DATABASE_URL ??= "postgres://localhost:5432/person_panel_test"; + +import { AuthValidationError, loginUser, registerUser } from "./index"; + +describe("auth payload validation", () => { + test("registerUser rejects payloads with a missing password as validation errors", async () => { + await expect( + registerUser({ + username: "valid_user", + password: undefined as never, + }), + ).rejects.toBeInstanceOf(AuthValidationError); + }); + + test("loginUser rejects payloads with a missing password as validation errors", async () => { + await expect( + loginUser({ + username: "valid_user", + password: undefined as never, + }), + ).rejects.toBeInstanceOf(AuthValidationError); + }); +}); diff --git a/server/service/auth/index.ts b/server/service/auth/index.ts index d421fa5..a3d7327 100644 --- a/server/service/auth/index.ts +++ b/server/service/auth/index.ts @@ -1,12 +1,235 @@ import { dbGlobal } from "drizzle-pkg/lib/db"; -import { users } from "drizzle-pkg/lib/schema/auth"; -import { eq } from "drizzle-orm"; +import { sessions, users } from "drizzle-pkg/lib/schema/auth"; +import { and, eq, gt, sql } from "drizzle-orm"; import log4js from "logger"; +import { randomUUID } from "crypto"; +import { compare, hash } from "bcryptjs"; const logger = log4js.getLogger("AUTH") -export async function getUsers(id: number) { - const usersList = await dbGlobal.select().from(users) - logger.info("users (formatted): %s \n", JSON.stringify(usersList, null, 2)); - return usersList; +const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,20}$/; +const MIN_PASSWORD_LENGTH = 6; +const SESSION_EXPIRE_MS = 7 * 24 * 60 * 60 * 1000; + +type AuthPayload = { + username: string; + password: string; +}; + +type MinimalUser = { + id: number; + username: string; +}; + +export class AuthValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthValidationError"; + } +} + +export class AuthConflictError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthConflictError"; + } +} + +export class AuthFailedError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthFailedError"; + } +} + +function validateCredentials(payload: unknown): asserts payload is AuthPayload { + if (typeof payload !== "object" || payload === null) { + throw new AuthValidationError("用户名和密码必须是字符串"); + } + + const { username, password } = payload as Partial; + + if (typeof username !== "string" || typeof password !== "string") { + throw new AuthValidationError("用户名和密码必须是字符串"); + } + + if (!USERNAME_REGEX.test(username)) { + throw new AuthValidationError("用户名格式不正确"); + } + if (password.length < MIN_PASSWORD_LENGTH) { + throw new AuthValidationError("密码长度至少 6 位"); + } +} + +function authFailedError() { + return new AuthFailedError("用户名或密码错误"); +} + +function unwrapDbError(err: unknown): unknown { + if (!(err instanceof Error)) { + return err; + } + if (!("cause" in err)) { + return err; + } + const cause = (err as { cause?: unknown }).cause; + return cause ?? err; +} + +async function createSession(userId: number) { + const sessionId = randomUUID(); + const expiresAt = new Date(Date.now() + SESSION_EXPIRE_MS); + await dbGlobal.insert(sessions).values({ + id: sessionId, + userId, + expiresAt, + }); + return { sessionId, expiresAt }; +} + +async function getNextUserId() { + const [row] = await dbGlobal + .select({ + maxId: sql`COALESCE(MAX(${users.id}), 0)`, + }) + .from(users); + return (row?.maxId ?? 0) + 1; +} + +function isPgUniqueViolation(err: unknown) { + const dbError = unwrapDbError(err); + if (!(dbError instanceof Error)) { + return false; + } + return "code" in dbError && (dbError as { code?: string }).code === "23505"; +} + +function getPgConstraint(err: unknown) { + const dbError = unwrapDbError(err); + if (!(dbError instanceof Error)) { + return ""; + } + if (!("constraint" in dbError)) { + return ""; + } + return ((dbError as { constraint?: string }).constraint ?? "").toLowerCase(); +} + +function isUsernameConflict(err: unknown) { + return isPgUniqueViolation(err) && getPgConstraint(err).includes("username"); +} + +function isUserIdConflict(err: unknown) { + if (!isPgUniqueViolation(err)) { + return false; + } + const constraint = getPgConstraint(err); + return constraint.includes("pkey") || constraint.includes("id"); +} + +async function insertUserWithRetry(username: string, passwordHash: string): Promise { + const maxRetry = 5; + for (let attempt = 0; attempt < maxRetry; attempt++) { + const userId = await getNextUserId(); + try { + const [newUser] = await dbGlobal + .insert(users) + .values({ + id: userId, + username, + password: passwordHash, + }) + .returning({ + id: users.id, + username: users.username, + }); + return newUser as MinimalUser; + } catch (err) { + if (isUsernameConflict(err)) { + throw new AuthConflictError("用户名已存在"); + } + if (isUserIdConflict(err) && attempt < maxRetry - 1) { + continue; + } + throw err; + } + } + throw new Error("创建用户失败,请稍后重试"); +} + +export async function registerUser(payload: AuthPayload): Promise { + validateCredentials(payload); + const { username, password } = payload; + + const passwordHash = await hash(password, 10); + const newUser = await insertUserWithRetry(username, passwordHash); + logger.info("user registered: %s", username); + + return newUser; +} + +export async function loginUser(payload: AuthPayload) { + validateCredentials(payload); + const { username, password } = payload; + + const [user] = await dbGlobal + .select({ + id: users.id, + username: users.username, + password: users.password, + }) + .from(users) + .where(eq(users.username, username)); + + if (!user) { + throw authFailedError(); + } + + const isMatch = await compare(password, user.password); + if (!isMatch) { + throw authFailedError(); + } + + const { sessionId, expiresAt } = await createSession(user.id); + logger.info("user login: %s", username); + + return { + user: { + id: user.id, + username: user.username, + } satisfies MinimalUser, + sessionId, + expiresAt, + }; +} + +export async function logoutUser(sessionId: string) { + await dbGlobal.delete(sessions).where(eq(sessions.id, sessionId)); + logger.info("session logout"); + return true; +} + +export async function getCurrentUser(sessionId: string): Promise { + const now = new Date(); + const [row] = await dbGlobal + .select({ + userId: users.id, + username: users.username, + expiresAt: sessions.expiresAt, + }) + .from(sessions) + .innerJoin(users, eq(sessions.userId, users.id)) + .where(and(eq(sessions.id, sessionId), gt(sessions.expiresAt, now))); + + if (!row) { + await dbGlobal + .delete(sessions) + .where(and(eq(sessions.id, sessionId), sql`${sessions.expiresAt} <= NOW()`)); + return null; + } + + return { + id: row.userId, + username: row.username, + }; } \ No newline at end of file diff --git a/server/utils/handler.ts b/server/utils/handler.ts index f5f9854..02cd641 100644 --- a/server/utils/handler.ts +++ b/server/utils/handler.ts @@ -32,7 +32,7 @@ export const defineWrappedResponseHandler = ( "\n", error ); - return error + throw error } }) } \ No newline at end of file