Browse Source

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
main
npmrun 2 days ago
parent
commit
537e8d1060
  1. 3
      .gitignore
  2. 55
      app/pages/login/index.vue
  3. 122
      app/pages/register/index.vue
  4. 3
      bun.lock
  5. 368
      docs/superpowers/plans/2026-04-16-username-password-auth.md
  6. 207
      docs/superpowers/specs/2026-04-16-username-password-auth-design.md
  7. 1
      package.json
  8. 13
      packages/drizzle-pkg/database/pg/schema/auth.ts
  9. 2
      packages/drizzle-pkg/lib/schema/auth.ts
  10. 8
      packages/drizzle-pkg/migrations/0001_auth-sessions.sql
  11. 2
      packages/drizzle-pkg/migrations/0002_auth-sessions-quality-fixes.sql
  12. 142
      packages/drizzle-pkg/migrations/meta/0001_snapshot.json
  13. 158
      packages/drizzle-pkg/migrations/meta/0002_snapshot.json
  14. 14
      packages/drizzle-pkg/migrations/meta/_journal.json
  15. 57
      server/api/auth/login.post.ts
  16. 28
      server/api/auth/logout.post.ts
  17. 42
      server/api/auth/me.get.ts
  18. 45
      server/api/auth/register.post.ts
  19. 25
      server/service/auth/index.test.ts
  20. 235
      server/service/auth/index.ts
  21. 2
      server/utils/handler.ts

3
.gitignore

@ -22,3 +22,6 @@ logs
.env
.env.*
!.env.example
# Local git worktrees
.worktrees

55
app/pages/login/index.vue

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import { request, unwrapApiBody } from '../../utils/http/factory'
definePageMeta({
title: '登录',
@ -7,12 +8,21 @@ definePageMeta({
})
type LoginFormState = {
email: string
username: string
password: string
}
type LoginResult = {
user: {
id: number
username: string
}
}
const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,20}$/
const state = reactive<LoginFormState>({
email: '',
username: '',
password: '',
})
@ -23,10 +33,10 @@ const resultMessage = ref('')
const validate = (formState: LoginFormState): FormError[] => {
const errors: FormError[] = []
if (!formState.email) {
errors.push({ name: 'email', message: '请输入邮箱' })
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formState.email)) {
errors.push({ name: 'email', message: '邮箱格式不正确' })
if (!formState.username) {
errors.push({ name: 'username', message: '请输入用户名' })
} else if (!USERNAME_REGEX.test(formState.username)) {
errors.push({ name: 'username', message: '用户名需为 3-20 位字母、数字或下划线' })
}
if (!formState.password) {
@ -44,12 +54,23 @@ const onSubmit = async (_event: FormSubmitEvent<LoginFormState>) => {
loading.value = true
try {
await new Promise((resolve) => setTimeout(resolve, 800))
const res = unwrapApiBody(await request<LoginResult>('/api/auth/login', {
method: 'POST',
body: {
username: state.username,
password: state.password,
},
}))
resultType.value = 'success'
resultMessage.value = `登录成功,欢迎 ${state.email}`
} catch {
resultMessage.value = `登录成功,欢迎 ${res.user.username}`
} catch (error: unknown) {
const message = typeof error === 'object' && error !== null && 'statusMessage' in error
? String(error.statusMessage)
: '登录失败,请稍后重试'
resultType.value = 'error'
resultMessage.value = '登录失败,请稍后重试'
resultMessage.value = message
} finally {
loading.value = false
}
@ -62,17 +83,16 @@ const onSubmit = async (_event: FormSubmitEvent<LoginFormState>) => {
<template #header>
<div class="space-y-1">
<h1 class="text-xl font-semibold">欢迎登录</h1>
<p class="text-sm text-muted">请输入邮箱和密码继续</p>
<p class="text-sm text-muted">请输入用户名和密码继续</p>
</div>
</template>
<UForm :state="state" :validate="validate" class="space-y-4" @submit="onSubmit">
<UFormField label="邮箱" name="email" required>
<UFormField label="用户名" name="username" required>
<UInput
v-model="state.email"
type="email"
placeholder="you@example.com"
autocomplete="email"
v-model="state.username"
placeholder="请输入用户名"
autocomplete="username"
class="w-full"
/>
</UFormField>
@ -104,9 +124,6 @@ const onSubmit = async (_event: FormSubmitEvent<LoginFormState>) => {
<NuxtLink to="/register" class="text-primary hover:underline">
去注册
</NuxtLink>
<NuxtLink to="/forgot-password" class="text-muted hover:underline">
忘记密码
</NuxtLink>
</div>
</UCard>
</div>

122
app/pages/register/index.vue

@ -0,0 +1,122 @@
<script setup lang="ts">
import type { FormError, FormSubmitEvent } from '@nuxt/ui'
import { request, unwrapApiBody } from '../../utils/http/factory'
definePageMeta({
title: '注册',
layout: 'not-login',
})
type RegisterFormState = {
username: string
password: string
}
const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,20}$/
const state = reactive<RegisterFormState>({
username: '',
password: '',
})
const loading = ref(false)
const resultType = ref<'error' | ''>('')
const resultMessage = ref('')
const validate = (formState: RegisterFormState): FormError[] => {
const errors: FormError[] = []
if (!formState.username) {
errors.push({ name: 'username', message: '请输入用户名' })
} else if (!USERNAME_REGEX.test(formState.username)) {
errors.push({ name: 'username', message: '用户名需为 3-20 位字母、数字或下划线' })
}
if (!formState.password) {
errors.push({ name: 'password', message: '请输入密码' })
} else if (formState.password.length < 6) {
errors.push({ name: 'password', message: '密码至少 6 位' })
}
return errors
}
const onSubmit = async (_event: FormSubmitEvent<RegisterFormState>) => {
resultType.value = ''
resultMessage.value = ''
loading.value = true
try {
unwrapApiBody(await request('/api/auth/register', {
method: 'POST',
body: {
username: state.username,
password: state.password,
},
}))
await navigateTo('/login')
} catch (error: unknown) {
const message = typeof error === 'object' && error !== null && 'statusMessage' in error
? String(error.statusMessage)
: '注册失败,请稍后重试'
resultType.value = 'error'
resultMessage.value = message
} finally {
loading.value = false
}
}
</script>
<template>
<div class="max-w-md mx-auto py-10">
<UCard>
<template #header>
<div class="space-y-1">
<h1 class="text-xl font-semibold">创建账号</h1>
<p class="text-sm text-muted">请输入用户名和密码完成注册</p>
</div>
</template>
<UForm :state="state" :validate="validate" class="space-y-4" @submit="onSubmit">
<UFormField label="用户名" name="username" required>
<UInput
v-model="state.username"
placeholder="请输入用户名"
autocomplete="username"
class="w-full"
/>
</UFormField>
<UFormField label="密码" name="password" required>
<UInput
v-model="state.password"
type="password"
placeholder="请输入密码"
autocomplete="new-password"
class="w-full"
/>
</UFormField>
<UButton type="submit" block :loading="loading">
立即注册
</UButton>
</UForm>
<UAlert
v-if="resultType"
color="error"
title="操作失败"
:description="resultMessage"
class="mt-4"
/>
<div class="mt-4 flex items-center justify-between text-sm">
<NuxtLink to="/login" class="text-primary hover:underline">
已有账号去登录
</NuxtLink>
</div>
</UCard>
</div>
</template>

3
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=="],

368
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
<!-- expected -->
<!-- form uses username field (not email), submits to /api/auth/login -->
<!-- success message based on actual API response -->
```
- [ ] **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
<UFormField label="用户名" name="username" required>
<UInput v-model="state.username" autocomplete="username" />
</UFormField>
```
- [ ] **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
<!-- fields -->
<UFormField label="用户名" name="username" required />
<UFormField label="密码" name="password" required />
```
- [ ] **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
```

207
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 条用例
---
本设计文档仅覆盖当前主线目标:用户名密码注册登录,不包含任何扩展认证能力。

1
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",

13
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(),
@ -14,3 +14,14 @@ export const users = pgTable("users", {
.$onUpdate(() => sql`CURRENT_TIMESTAMP`)
.notNull(),
});
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),
]);

2
packages/drizzle-pkg/lib/schema/auth.ts

@ -1 +1 @@
export { users } from '../../database/pg/schema/auth'
export { users, sessions } from '../../database/pg/schema/auth'

8
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;

2
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");

142
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": {}
}
}

158
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": {}
}
}

14
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
}
]
}

57
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<LoginBody>(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);
}
});

28
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: "服务器繁忙,请稍后重试",
});
}
});

42
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: "服务器繁忙,请稍后重试",
});
}
});

45
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<RegisterBody>(event);
const user = await registerUser(body);
return R.success({
user,
});
} catch (err) {
throw toPublicError(err);
}
});

25
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);
});
});

235
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<AuthPayload>;
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<number>`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<MinimalUser> {
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<MinimalUser> {
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<MinimalUser | null> {
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,
};
}

2
server/utils/handler.ts

@ -32,7 +32,7 @@ export const defineWrappedResponseHandler = <T extends EventHandlerRequest, D>(
"\n",
error
);
return error
throw error
}
})
}
Loading…
Cancel
Save