From 4a42756e2e1e8553ca47d975589223c22a68d0b7 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sat, 2 May 2026 22:13:10 +0800 Subject: [PATCH] feat(auth): add password reset and remember me options Add password reset request and reset endpoints with email verification Add "Remember me" option to login for persistent sessions Create frontend views for forgot and reset password flows --- DESIGN.md | 11 +- backend/db/schema.sql | 12 ++ backend/src/auth.ts | 150 +++++++++++++++++++++- backend/src/server.ts | 19 ++- frontend/src/icons.ts | 1 + frontend/src/router/index.ts | 4 + frontend/src/services/api.ts | 38 ++++-- frontend/src/styles/main.css | 21 +++ frontend/src/views/ForgotPasswordView.vue | 59 +++++++++ frontend/src/views/LoginView.vue | 16 ++- frontend/src/views/ResetPasswordView.vue | 98 ++++++++++++++ system-wordings.ts | 53 +++++++- 12 files changed, 456 insertions(+), 26 deletions(-) create mode 100644 frontend/src/views/ForgotPasswordView.vue create mode 100644 frontend/src/views/ResetPasswordView.vue 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 }}