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