From 9af8c98401b3ecd07710b05143b255297e10fa87 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Thu, 30 Apr 2026 11:32:46 +0800 Subject: [PATCH] feat(auth): implement user authentication and email verification Add registration, login, and logout flows with session management Integrate Resend for email verification tokens Create frontend auth views and update topbar state --- .env.example | 4 + DESIGN.md | 17 +- backend/db/schema.sql | 35 +++ backend/src/auth.ts | 338 +++++++++++++++++++++++++ backend/src/server.ts | 35 +++ docker-compose.yml | 3 + frontend/src/App.vue | 71 +++++- frontend/src/router/index.ts | 8 +- frontend/src/services/api.ts | 82 +++++- frontend/src/styles/main.css | 118 +++++++++ frontend/src/views/LoginView.vue | 71 ++++++ frontend/src/views/RegisterView.vue | 79 ++++++ frontend/src/views/VerifyEmailView.vue | 48 ++++ 13 files changed, 898 insertions(+), 11 deletions(-) create mode 100644 backend/src/auth.ts create mode 100644 frontend/src/views/LoginView.vue create mode 100644 frontend/src/views/RegisterView.vue create mode 100644 frontend/src/views/VerifyEmailView.vue diff --git a/.env.example b/.env.example index df82855..6b5d451 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,8 @@ POSTGRES_USER=pokopia POSTGRES_PASSWORD=pokopia DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia BACKEND_PORT=3001 +FRONTEND_ORIGIN=http://localhost:3000 +APP_ORIGIN=http://localhost:3000 VITE_API_BASE_URL=http://localhost:3001 +RESEND_API_KEY= +EMAIL_FROM="Pokopia Wiki " diff --git a/DESIGN.md b/DESIGN.md index 57fba2f..7ed5555 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -113,4 +113,19 @@ Eg: 名称:乱撒,二级分类:棉花 - 材料单详情页 - 基本信息 - 入手方式 - - 需要材料列表 \ No newline at end of file + - 需要材料列表 + +## 用户系统 + +- 用户可注册 + - 邮箱 + - 显示名 + - 密码 +- 用户注册后需要通过邮箱验证 + - 使用 Resend 发送验证邮件 + - 邮件内包含验证链接 +- 用户可登录 + - 仅允许已验证邮箱的用户登录 + - 登录后可获取当前用户信息 +- 用户可退出登录 +- API 只返回必要用户字段,不暴露密码、验证 token、会话 token 哈希或内部元数据 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 2d30436..80d2e29 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -3,6 +3,41 @@ CREATE TABLE IF NOT EXISTS environments ( name text NOT NULL UNIQUE ); +CREATE TABLE IF NOT EXISTS users ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + email text NOT NULL UNIQUE, + display_name text NOT NULL, + password_hash text NOT NULL, + email_verified_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (email = lower(email)), + CHECK (length(display_name) BETWEEN 1 AND 40) +); + +CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + used_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx + ON email_verification_tokens(user_id); + +CREATE TABLE IF NOT EXISTS user_sessions ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx + ON user_sessions(user_id); + CREATE TABLE IF NOT EXISTS skills ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL, diff --git a/backend/src/auth.ts b/backend/src/auth.ts new file mode 100644 index 0000000..0c11be5 --- /dev/null +++ b/backend/src/auth.ts @@ -0,0 +1,338 @@ +import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'; +import { promisify } from 'node:util'; +import type { PoolClient, QueryResultRow } from 'pg'; +import { pool, queryOne } from './db.ts'; + +const scrypt = promisify(scryptCallback); +const passwordKeyLength = 64; +const verificationTokenHours = 24; +const sessionDays = 30; + +type DbClient = PoolClient; + +type StatusError = Error & { statusCode: number }; + +type UserRow = QueryResultRow & { + id: number; + email: string; + display_name: string; + email_verified_at: string | null; +}; + +type LoginUserRow = UserRow & { + password_hash: string; +}; + +export type AuthUser = { + id: number; + email: string; + displayName: string; + emailVerified: boolean; +}; + +function statusError(message: string, statusCode: number): StatusError { + const error = new Error(message) as StatusError; + error.statusCode = statusCode; + return error; +} + +function cleanEmail(value: unknown): string { + if (typeof value !== 'string') { + throw statusError('请输入邮箱', 400); + } + + const email = value.trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + throw statusError('邮箱格式不正确', 400); + } + + return email; +} + +function cleanDisplayName(value: unknown): string { + if (typeof value !== 'string') { + throw statusError('请输入显示名', 400); + } + + const displayName = value.trim(); + if (displayName.length < 1 || displayName.length > 40) { + throw statusError('显示名长度需为 1 到 40 个字符', 400); + } + + return displayName; +} + +function cleanPassword(value: unknown): string { + if (typeof value !== 'string' || value.length < 8) { + throw statusError('密码至少需要 8 个字符', 400); + } + + return value; +} + +function cleanToken(value: unknown): string { + if (typeof value !== 'string' || value.trim().length < 32) { + throw statusError('验证链接无效或已过期', 400); + } + + return value.trim(); +} + +function toPublicUser(user: UserRow): AuthUser { + return { + id: user.id, + email: user.email, + displayName: user.display_name, + emailVerified: user.email_verified_at !== null + }; +} + +async function clientQueryOne( + client: DbClient, + sql: string, + params: unknown[] = [] +): Promise { + const result = await client.query(sql, params); + return result.rows[0] ?? null; +} + +async function withTransaction(callback: (client: DbClient) => Promise): Promise { + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + const result = await callback(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} + +async function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString('base64url'); + const key = (await scrypt(password, salt, passwordKeyLength)) as Buffer; + return `scrypt$${salt}$${key.toString('base64url')}`; +} + +async function verifyPassword(password: string, passwordHash: string): Promise { + const [algorithm, salt, storedKey] = passwordHash.split('$'); + if (algorithm !== 'scrypt' || !salt || !storedKey) { + return false; + } + + const storedBuffer = Buffer.from(storedKey, 'base64url'); + const key = (await scrypt(password, salt, storedBuffer.length)) as Buffer; + return key.length === storedBuffer.length && timingSafeEqual(key, storedBuffer); +} + +function createPlainToken(): string { + return randomBytes(32).toString('base64url'); +} + +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +function getEmailConfig() { + const apiKey = process.env.RESEND_API_KEY; + const from = process.env.EMAIL_FROM; + + if (!apiKey || !from) { + throw new Error('Email service is not configured'); + } + + return { apiKey, from }; +} + +function buildVerificationUrl(token: string): string { + const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'; + const url = new URL('/verify-email', origin); + url.searchParams.set('token', token); + return url.toString(); +} + +async function sendVerificationEmail(email: string, token: string): Promise { + const { apiKey, from } = getEmailConfig(); + const verificationUrl = buildVerificationUrl(token); + const response = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + from, + to: [email], + subject: '验证你的 Pokopia Wiki 邮箱', + html: `

请点击下面的链接完成邮箱验证:

验证邮箱

链接将在 ${verificationTokenHours} 小时后失效。

`, + text: `请打开以下链接完成 Pokopia Wiki 邮箱验证:${verificationUrl}\n链接将在 ${verificationTokenHours} 小时后失效。` + }) + }); + + if (!response.ok) { + const responseText = await response.text(); + throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`); + } +} + +export async function registerUser(payload: Record) { + const email = cleanEmail(payload.email); + const displayName = cleanDisplayName(payload.displayName); + const password = cleanPassword(payload.password); + const passwordHash = await hashPassword(password); + const verificationToken = createPlainToken(); + const verificationTokenHash = hashToken(verificationToken); + + await withTransaction(async (client) => { + const existingUser = await clientQueryOne( + client, + 'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1', + [email] + ); + + if (existingUser?.email_verified_at) { + throw statusError('该邮箱已注册', 409); + } + + const user = existingUser + ? await clientQueryOne( + client, + ` + UPDATE users + SET display_name = $1, password_hash = $2, updated_at = now() + WHERE id = $3 + RETURNING id, email, display_name, email_verified_at + `, + [displayName, passwordHash, existingUser.id] + ) + : await clientQueryOne( + client, + ` + INSERT INTO users (email, display_name, password_hash) + VALUES ($1, $2, $3) + RETURNING id, email, display_name, email_verified_at + `, + [email, displayName, passwordHash] + ); + + if (!user) { + throw new Error('Failed to save user'); + } + + await client.query('DELETE FROM email_verification_tokens WHERE user_id = $1 AND used_at IS NULL', [user.id]); + await client.query( + ` + INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, now() + ($3 * interval '1 hour')) + `, + [user.id, verificationTokenHash, verificationTokenHours] + ); + }); + + await sendVerificationEmail(email, verificationToken); + return { message: '请查收验证邮件' }; +} + +export async function verifyEmail(payload: Record) { + const token = cleanToken(payload.token); + const tokenHash = hashToken(token); + + return withTransaction(async (client) => { + const tokenRow = await clientQueryOne<{ id: number; user_id: number }>( + client, + ` + SELECT id, user_id + FROM email_verification_tokens + WHERE token_hash = $1 + AND used_at IS NULL + AND expires_at > now() + FOR UPDATE + `, + [tokenHash] + ); + + if (!tokenRow) { + throw statusError('验证链接无效或已过期', 400); + } + + const user = await clientQueryOne( + client, + ` + UPDATE users + SET email_verified_at = COALESCE(email_verified_at, now()), updated_at = now() + WHERE id = $1 + RETURNING id, email, display_name, email_verified_at + `, + [tokenRow.user_id] + ); + + if (!user) { + throw statusError('验证链接无效或已过期', 400); + } + + await client.query('UPDATE email_verification_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [ + user.id + ]); + + return { message: '邮箱已验证', user: toPublicUser(user) }; + }); +} + +export async function loginUser(payload: Record) { + const email = cleanEmail(payload.email); + const password = cleanPassword(payload.password); + const user = await queryOne( + 'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1', + [email] + ); + + if (!user || !(await verifyPassword(password, user.password_hash))) { + throw statusError('邮箱或密码不正确', 401); + } + + if (!user.email_verified_at) { + throw statusError('请先完成邮箱验证', 403); + } + + const sessionToken = createPlainToken(); + await pool.query( + ` + INSERT INTO user_sessions (user_id, token_hash, expires_at) + VALUES ($1, $2, now() + ($3 * interval '1 day')) + `, + [user.id, hashToken(sessionToken), sessionDays] + ); + + return { token: sessionToken, user: toPublicUser(user) }; +} + +export async function getUserBySessionToken(token: string): Promise { + if (token.length < 32) { + return null; + } + + const user = await queryOne( + ` + SELECT u.id, u.email, u.display_name, u.email_verified_at + FROM user_sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token_hash = $1 + AND s.expires_at > now() + `, + [hashToken(token)] + ); + + return user ? toPublicUser(user) : null; +} + +export async function logoutSession(token: string): Promise { + if (token.length < 32) { + return; + } + + await pool.query('DELETE FROM user_sessions WHERE token_hash = $1', [hashToken(token)]); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 4d9a7d4..01452e9 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,5 +1,6 @@ import cors from '@fastify/cors'; import Fastify from 'fastify'; +import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail } from './auth.ts'; import { initializeDatabase, pool } from './db.ts'; import { createConfig, @@ -35,6 +36,7 @@ const app = Fastify({ }); await app.register(cors, { + allowedHeaders: ['Authorization', 'Content-Type'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], origin: process.env.FRONTEND_ORIGIN ?? true }); @@ -64,6 +66,39 @@ app.setErrorHandler(async (error, _request, reply) => { app.get('/health', async () => ({ ok: true })); +function getBearerToken(authorization: string | undefined): string | null { + const [scheme, token] = authorization?.split(' ') ?? []; + return scheme === 'Bearer' && token ? token : null; +} + +app.post('/api/auth/register', async (request, reply) => + reply.code(201).send(await registerUser(request.body as Record)) +); + +app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record)); + +app.post('/api/auth/login', async (request) => loginUser(request.body as Record)); + +app.get('/api/auth/me', async (request, reply) => { + const token = getBearerToken(request.headers.authorization); + const user = token ? await getUserBySessionToken(token) : null; + + if (!user) { + return reply.code(401).send({ message: '请先登录' }); + } + + return { user }; +}); + +app.post('/api/auth/logout', async (request, reply) => { + const token = getBearerToken(request.headers.authorization); + if (token) { + await logoutSession(token); + } + + return reply.code(204).send(); +}); + app.get('/api/options', async () => getOptions()); app.get('/api/pokemon', async (request) => listPokemon(request.query as Record)); diff --git a/docker-compose.yml b/docker-compose.yml index 2680ce7..c62bdb7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,9 @@ services: DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia BACKEND_PORT: 3001 FRONTEND_ORIGIN: http://localhost:3000 + APP_ORIGIN: http://localhost:3000 + RESEND_API_KEY: ${RESEND_API_KEY:-} + EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki }" ports: - "3001:3001" depends_on: diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 35e4e6c..0415d99 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,21 +1,80 @@