Browse Source
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: Cursormain
21 changed files with 1505 additions and 29 deletions
@ -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> |
|||
@ -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 |
|||
``` |
|||
@ -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 +1 @@ |
|||
export { users } from '../../database/pg/schema/auth' |
|||
export { users, sessions } from '../../database/pg/schema/auth' |
|||
@ -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; |
|||
@ -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"); |
|||
@ -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": {} |
|||
} |
|||
} |
|||
@ -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": {} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
}); |
|||
@ -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: "服务器繁忙,请稍后重试", |
|||
}); |
|||
} |
|||
}); |
|||
@ -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: "服务器繁忙,请稍后重试", |
|||
}); |
|||
} |
|||
}); |
|||
@ -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); |
|||
} |
|||
}); |
|||
@ -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); |
|||
}); |
|||
}); |
|||
@ -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, |
|||
}; |
|||
} |
|||
Loading…
Reference in new issue