diff --git a/DESIGN.md b/DESIGN.md index 5968f42..dfd18ed 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -102,8 +102,15 @@ - 验证邮件包含一次性验证链接。 - 验证 token 只保存 hash,并带过期时间和使用状态。 - 只有邮箱已验证的用户可以登录。 +- 用户可请求重置密码: + - 重置请求只接收邮箱,并始终返回泛化成功信息,避免暴露邮箱是否已注册。 + - 重置邮件包含一次性重置链接。 + - 重置 token 只保存 hash,并带过期时间和使用状态。 + - 密码重置成功后不自动登录,并删除该用户已有 session。 +- 登录页提供 Remember me: + - 未勾选时前端将登录 token 保存在 `sessionStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 1 天。 + - 勾选时前端将登录 token 保存在 `localStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 30 天。 - 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。 -- 前端将登录 token 保存在 `localStorage` 的 `pokopia_auth_token`。 - 用户可退出登录,退出时删除对应 session。 - 对外用户字段只包含必要信息: - 当前用户:`id`、`email`、`displayName`、`emailVerified` @@ -534,6 +541,8 @@ API 暴露边界: - `POST /api/auth/register` - `POST /api/auth/verify-email` - `POST /api/auth/login` +- `POST /api/auth/request-password-reset` +- `POST /api/auth/reset-password` - `GET /api/auth/me` - `POST /api/auth/logout` diff --git a/backend/db/schema.sql b/backend/db/schema.sql index b6c4949..5ac6d35 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -130,6 +130,18 @@ CREATE TABLE IF NOT EXISTS email_verification_tokens ( CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx ON email_verification_tokens(user_id); +CREATE TABLE IF NOT EXISTS password_reset_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 password_reset_tokens_user_id_idx + ON password_reset_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, diff --git a/backend/src/auth.ts b/backend/src/auth.ts index f0e15ee..945061e 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -7,7 +7,9 @@ import { systemMessage } from './systemWordingQueries.ts'; const scrypt = promisify(scryptCallback); const passwordKeyLength = 64; const verificationTokenHours = 24; -const sessionDays = 30; +const passwordResetTokenHours = 1; +const rememberedSessionDays = 30; +const sessionOnlySessionDays = 1; const defaultLocale = 'en'; type DbClient = PoolClient; @@ -35,11 +37,19 @@ type AuthMessageKey = | 'emailAlreadyRegistered' | 'checkVerificationEmail' | 'emailVerified' + | 'checkPasswordResetEmail' + | 'passwordResetComplete' | 'invalidCredentials' | 'verifyEmailFirst' + | 'invalidResetToken' | 'emailSubject' | 'emailHtml' - | 'emailText'; + | 'emailText' + | 'passwordResetSubject' + | 'passwordResetHtml' + | 'passwordResetText'; + +type AuthTokenMessageKey = 'invalidToken' | 'invalidResetToken'; export type AuthUser = { id: number; @@ -65,11 +75,17 @@ function authMessage(locale: string, key: AuthMessageKey, params: Record { return value; } -async function cleanToken(value: unknown, locale: string): Promise { +async function cleanToken( + value: unknown, + locale: string, + messageKey: AuthTokenMessageKey = 'invalidToken' +): Promise { if (typeof value !== 'string' || value.trim().length < 32) { - throw statusError(await authMessage(locale, 'invalidToken'), 400); + throw statusError(await authMessage(locale, messageKey), 400); } return value.trim(); @@ -187,13 +207,21 @@ function getEmailConfig() { return { apiKey, from }; } -function buildVerificationUrl(token: string): string { +function buildTokenUrl(pathname: string, token: string): string { const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'; - const url = new URL('/verify-email', origin); + const url = new URL(pathname, origin); url.searchParams.set('token', token); return url.toString(); } +function buildVerificationUrl(token: string): string { + return buildTokenUrl('/verify-email', token); +} + +function buildPasswordResetUrl(token: string): string { + return buildTokenUrl('/reset-password', token); +} + async function sendVerificationEmail(email: string, token: string, locale: string): Promise { const { apiKey, from } = getEmailConfig(); const verificationUrl = buildVerificationUrl(token); @@ -221,6 +249,33 @@ async function sendVerificationEmail(email: string, token: string, locale: strin } } +async function sendPasswordResetEmail(email: string, token: string, locale: string): Promise { + const { apiKey, from } = getEmailConfig(); + const resetUrl = buildPasswordResetUrl(token); + const subject = await authMessage(locale, 'passwordResetSubject'); + const html = await authMessage(locale, 'passwordResetHtml', { url: resetUrl, hours: passwordResetTokenHours }); + const text = await authMessage(locale, 'passwordResetText', { url: resetUrl, hours: passwordResetTokenHours }); + 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, + html, + text + }) + }); + + 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, locale = defaultLocale) { const email = await cleanEmail(payload.email, locale); const displayName = await cleanDisplayName(payload.displayName, locale); @@ -324,9 +379,90 @@ export async function verifyEmail(payload: Record, locale = def }); } +export async function requestPasswordReset(payload: Record, locale = defaultLocale) { + const email = await cleanEmail(payload.email, locale); + const user = await queryOne( + 'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1', + [email] + ); + + if (user) { + const resetToken = createPlainToken(); + const resetTokenHash = hashToken(resetToken); + + await withTransaction(async (client) => { + await client.query('DELETE FROM password_reset_tokens WHERE user_id = $1 AND used_at IS NULL', [user.id]); + await client.query( + ` + INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, now() + ($3 * interval '1 hour')) + `, + [user.id, resetTokenHash, passwordResetTokenHours] + ); + }); + + try { + await sendPasswordResetEmail(email, resetToken, locale); + } catch (error) { + console.error('Password reset email failed', error); + } + } + + return { message: await authMessage(locale, 'checkPasswordResetEmail') }; +} + +export async function resetPassword(payload: Record, locale = defaultLocale) { + const token = await cleanToken(payload.token, locale, 'invalidResetToken'); + const password = await cleanPassword(payload.password, locale); + const passwordHash = await hashPassword(password); + const tokenHash = hashToken(token); + + return withTransaction(async (client) => { + const tokenRow = await clientQueryOne<{ id: number; user_id: number }>( + client, + ` + SELECT id, user_id + FROM password_reset_tokens + WHERE token_hash = $1 + AND used_at IS NULL + AND expires_at > now() + FOR UPDATE + `, + [tokenHash] + ); + + if (!tokenRow) { + throw statusError(await authMessage(locale, 'invalidResetToken'), 400); + } + + const user = await clientQueryOne( + client, + ` + UPDATE users + SET password_hash = $1, updated_at = now() + WHERE id = $2 + RETURNING id, email, display_name, email_verified_at + `, + [passwordHash, tokenRow.user_id] + ); + + if (!user) { + throw statusError(await authMessage(locale, 'invalidResetToken'), 400); + } + + await client.query('UPDATE password_reset_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [ + user.id + ]); + await client.query('DELETE FROM user_sessions WHERE user_id = $1', [user.id]); + + return { message: await authMessage(locale, 'passwordResetComplete') }; + }); +} + export async function loginUser(payload: Record, locale = defaultLocale) { const email = await cleanEmail(payload.email, locale); const password = await cleanPassword(payload.password, locale); + const sessionDays = payload.rememberMe === true ? rememberedSessionDays : sessionOnlySessionDays; const user = await queryOne( 'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1', [email] diff --git a/backend/src/server.ts b/backend/src/server.ts index f3ca3f6..b17a1f1 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,7 +1,16 @@ import cors from '@fastify/cors'; import Fastify from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify'; -import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail, type AuthUser } from './auth.ts'; +import { + getUserBySessionToken, + loginUser, + logoutSession, + registerUser, + requestPasswordReset, + resetPassword, + verifyEmail, + type AuthUser +} from './auth.ts'; import { initializeDatabase, pool } from './db.ts'; import { cleanLocale, @@ -170,6 +179,14 @@ app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body a app.post('/api/auth/login', async (request) => loginUser(request.body as Record, requestLocale(request))); +app.post('/api/auth/request-password-reset', async (request) => + requestPasswordReset(request.body as Record, requestLocale(request)) +); + +app.post('/api/auth/reset-password', async (request) => + resetPassword(request.body as Record, requestLocale(request)) +); + app.get('/api/auth/me', async (request, reply) => { const token = getBearerToken(request.headers.authorization); const user = token ? await getUserBySessionToken(token) : null; diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index ef6e330..f7e3c3c 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -20,6 +20,7 @@ export const iconEvent: AppIcon = 'mdi:calendar-star'; export const iconHabitat: AppIcon = 'mdi:pine-tree'; export const iconInfo: AppIcon = 'mdi:information-outline'; export const iconItem: AppIcon = 'mdi:bag-personal-outline'; +export const iconKey: AppIcon = 'mdi:key-outline'; export const iconLife: AppIcon = 'mdi:post-outline'; export const iconClothes: AppIcon = 'mdi:tshirt-crew-outline'; export const iconLogin: AppIcon = 'mdi:login'; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2cceb96..3d482fa 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -11,8 +11,10 @@ import DailyChecklistView from '../views/DailyChecklistView.vue'; import LifeView from '../views/LifeView.vue'; import ComingSoonView from '../views/ComingSoonView.vue'; import AdminView from '../views/AdminView.vue'; +import ForgotPasswordView from '../views/ForgotPasswordView.vue'; import LoginView from '../views/LoginView.vue'; import RegisterView from '../views/RegisterView.vue'; +import ResetPasswordView from '../views/ResetPasswordView.vue'; import VerifyEmailView from '../views/VerifyEmailView.vue'; import { api, getAuthToken, setAuthToken } from '../services/api'; @@ -45,6 +47,8 @@ export const router = createRouter({ { path: '/life', component: LifeView }, { path: '/admin', component: AdminView, meta: { requiresVerified: true } }, { path: '/login', component: LoginView }, + { path: '/forgot-password', component: ForgotPasswordView }, + { path: '/reset-password', component: ResetPasswordView }, { path: '/register', component: RegisterView }, { path: '/verify-email', component: VerifyEmailView } ], diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 532d962..85eed52 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -277,6 +277,7 @@ export interface AuthUser { export interface LoginPayload { email: string; password: string; + rememberMe?: boolean; } export interface RegisterPayload extends LoginPayload { @@ -418,26 +419,39 @@ export function buildQuery(params: Record): return query ? `?${query}` : ''; } -export function getAuthToken(): string | null { - if (typeof localStorage === 'undefined') { +function authStorage(type: 'local' | 'session'): Storage | null { + if (typeof window === 'undefined') { return null; } - return localStorage.getItem(authTokenKey); + return type === 'local' ? window.localStorage : window.sessionStorage; } -export function setAuthToken(token: string | null): void { - if (typeof localStorage === 'undefined') { - return; - } +export function getAuthToken(): string | null { + const sessionToken = authStorage('session')?.getItem(authTokenKey); + return sessionToken ?? authStorage('local')?.getItem(authTokenKey) ?? null; +} + +export function setAuthToken(token: string | null, options: { persistent?: boolean } = {}): void { + const local = authStorage('local'); + const session = authStorage('session'); if (token) { - localStorage.setItem(authTokenKey, token); + if (options.persistent === false) { + session?.setItem(authTokenKey, token); + local?.removeItem(authTokenKey); + } else { + local?.setItem(authTokenKey, token); + session?.removeItem(authTokenKey); + } } else { - localStorage.removeItem(authTokenKey); + local?.removeItem(authTokenKey); + session?.removeItem(authTokenKey); } - window.dispatchEvent(new Event(authChangeEvent)); + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event(authChangeEvent)); + } } export function onAuthTokenChange(callback: () => void): () => void { @@ -548,6 +562,10 @@ export const api = { verifyEmail: (token: string) => sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }), login: (payload: LoginPayload) => sendJson('/api/auth/login', 'POST', payload), + requestPasswordReset: (payload: { email: string }) => + sendJson<{ message: string }>('/api/auth/request-password-reset', 'POST', payload), + resetPassword: (payload: { token: string; password: string }) => + sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload), me: () => getJson<{ user: AuthUser }>('/api/auth/me'), logout: () => postEmpty('/api/auth/logout'), options: () => getJson('/api/options'), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 402fdbe..fb32244 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -3784,6 +3784,27 @@ button:disabled, gap: 14px; } +.auth-options { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--muted); + font-size: 14px; + font-weight: 800; +} + +.auth-options__remember { + margin: 0; + min-width: 0; +} + +.auth-options a { + color: var(--pokemon-blue-deep); + font-weight: 900; + white-space: nowrap; +} + .auth-switch { margin: 0; color: var(--muted); diff --git a/frontend/src/views/ForgotPasswordView.vue b/frontend/src/views/ForgotPasswordView.vue new file mode 100644 index 0000000..a66bb12 --- /dev/null +++ b/frontend/src/views/ForgotPasswordView.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index a007d56..d7327e7 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -13,6 +13,7 @@ const router = useRouter(); const { t } = useI18n(); const email = ref(''); const password = ref(''); +const rememberMe = ref(false); const busy = ref(false); const errorMessage = ref(''); @@ -23,9 +24,10 @@ async function submitLogin() { try { const response = await api.login({ email: email.value, - password: password.value + password: password.value, + rememberMe: rememberMe.value }); - setAuthToken(response.token); + setAuthToken(response.token, { persistent: rememberMe.value }); const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/') @@ -44,7 +46,7 @@ async function submitLogin() {
- +
@@ -58,6 +60,14 @@ async function submitLogin() {
+
+ + {{ t('auth.forgotPassword') }} +
+ {{ errorMessage }}