diff --git a/.agents/skills/use-existing-components/SKILL.md b/.agents/skills/use-existing-components/SKILL.md index 9b9a33f..25b1139 100644 --- a/.agents/skills/use-existing-components/SKILL.md +++ b/.agents/skills/use-existing-components/SKILL.md @@ -12,7 +12,7 @@ **在开发中,如非必要,禁止使用 `localStorage` 和 `sessionStorage`,避免用户篡改数据。** -- 敏感数据(如 token、用户信息)必须使用 **内存存储**(参考 `../../../src/infra/Http.ts` 中的 `TokenManager` 类),不持久化到浏览器存储 +- 认证状态使用服务端 Session + HttpOnly `SESSIONID` Cookie;前端敏感数据(用户信息、权限标识)使用 **内存存储**(参考 `src/contexts/AuthContext.tsx`),不持久化到浏览器存储 - 确需持久化的非敏感配置(如主题偏好、布局宽度),可例外使用 `localStorage`(参考 `ThemeContext` 和 `LayoutWidthContext` 的实现) - **禁止**将权限标识、用户角色、付费状态等安全相关数据放入 `localStorage`/`sessionStorage`,必须通过 API 实时校验 diff --git a/AGENTS.md b/AGENTS.md index 881d2eb..0b9fdd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,9 +8,10 @@ 实现任何 UI 时,**必须优先使用 `src/components/` 下已封装的自定义组件**,禁止使用 antd 原生组件或手写原生 HTML 元素替代。完整组件清单和使用指南见 `.claude/skills/use-existing-components/SKILL.md`。 ### 2. 数据存储:内存优先 -- **敏感数据**(token、用户信息、权限标识)必须使用内存存储,**禁止**使用 `localStorage`/`sessionStorage`,防止用户篡改 +- **认证状态**由服务端 Session + HttpOnly `SESSIONID` Cookie 管理,前端不读取、不保存 token +- **敏感数据**(用户信息、权限标识)必须使用内存存储,**禁止**使用 `localStorage`/`sessionStorage`,防止用户篡改 - **非敏感配置**(主题偏好、布局宽度)方可例外使用 `localStorage` -- 参考:`src/infra/Http.ts` 中的 `TokenManager`(内存存储)vs `ThemeContext`(localStorage 例外) +- 参考:`src/contexts/AuthContext.tsx` 的内存用户态 vs `ThemeContext`(localStorage 例外) ### 3. 组件开发流程 ``` @@ -40,8 +41,9 @@ npm run format # Prettier 格式化 ## 项目架构要点 - **Provider 嵌套顺序**:BrowserRouter → Theme → Auth → LayoutWidth → SiteSettings → Message → Confirm -- **Token 认证**:从 Cookie 提取 S_TOKEN,存于内存 TokenManager,HTTP 拦截器自动附加 Bearer header -- **WebSocket**:`notificationWS` 单例,生命周期绑定 AuthContext(登录连/登出断),支持自动重连 +- **Session 认证**:服务端设置 HttpOnly `SESSIONID`,Axios 统一 `withCredentials: true`,不再附加 Bearer header +- **登录方式**:密码 + TOTP、Passkey(WebAuthn)、邮箱验证码恢复;登录后统一通过 `/user/info` 恢复用户态 +- **WebSocket**:`notificationWS` 单例,生命周期绑定 AuthContext(登录连/登出断),握手依赖同源 Cookie,支持自动重连 - **路由**:`/auth` 和 `/admin/*` 免维护模式守卫,其余路由受 `MaintenanceGuard` 保护 - **样式**:CSS Modules + CSS 自定义属性(`data-theme` 驱动明暗主题,`data-width-mode` 驱动页面宽度) - **HTTP**:`src/infra/Http.ts` 封装,拦截器做了完整的中文业务状态码映射 diff --git a/CLAUDE.md b/CLAUDE.md index 14a53c0..d55c2b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,9 +26,10 @@ BrowserRouter → ThemeProvider → AuthProvider → LayoutWidthProvider → Sit ### 认证与 HTTP 层 -- Token 使用 **内存存储**(`TokenManager` 类,见 `src/infra/Http.ts`),不使用 localStorage/Cookie 直接读写,避免 XSS 风险 -- Token 来源:登录时从响应的 `Set-Cookie` header 中提取 `S_TOKEN`,或从 `document.cookie` 回退读取 -- 所有 API 请求通过 `src/infra/Http.ts` 的 `http` 实例发送,拦截器自动附加 `Authorization: Bearer ` header +- 认证使用 **服务端 Session + HttpOnly `SESSIONID` Cookie**;前端不读取、不保存 token +- 所有 API 请求通过 `src/infra/Http.ts` 的 `http` 实例发送,统一 `withCredentials: true`,不再附加 `Authorization` header +- 页面启动时通过 `/user/info` 恢复用户态;匿名 1101 需要跳过全局会话失效跳转 +- 支持密码 + TOTP、Passkey(WebAuthn)、邮箱验证码恢复登录 - 后端返回统一结构 `{ code, data, message/msg, success }`,响应拦截器做了完整的业务状态码映射(400-504 均有中文错误提示) - API Service 层位于 `src/services/`,每个 service 文件封装一类业务接口 diff --git a/README.en.md b/README.en.md index 3533b24..7499576 100644 --- a/README.en.md +++ b/README.en.md @@ -100,10 +100,9 @@ Vite loads environment files according to the current mode, including `.env.deve | Variable | Purpose | | -------------------------- | --------------------------------------------------------------------------------- | -| `VITE_API_BASE_URL` | Direct API URL, or the target URL for the Vite development proxy | -| `VITE_WS_URL` | Base URL of the WebSocket service | -| `VITE_USE_PROXY` | When set to the string `"true"`, HTTP requests use the same-origin `/api/v1` path | -| `VITE_REQUIRE_CREDENTIALS` | When set to the string `"true"`, enables Axios `withCredentials` | +| `VITE_API_BASE_URL` | Direct API URL, or the target URL for the Vite development proxy | +| `VITE_WS_URL` | Base URL of the WebSocket service | +| `VITE_USE_PROXY` | When set to the string `"true"`, HTTP requests use the same-origin `/api/v1` path | ### Request Modes @@ -192,10 +191,10 @@ The `/rd` layout additionally mounts `RdOrgProvider` for the current organizatio ### Authentication and Storage -- The application restores the token from the `S_TOKEN` Cookie at startup. -- During runtime, the token is managed by `AuthContext` and the in-memory `TokenManager`. -- The Axios request interceptor adds the Bearer token automatically. -- Sensitive state such as tokens, user data, roles, and permissions must not be written to `localStorage` or `sessionStorage`. +- Authentication uses a server-side session with an HttpOnly `SESSIONID` Cookie; the frontend never reads or stores tokens. +- At startup the app restores the user state through `/user/info`, and Axios sends Cookie credentials on every request. +- Supported sign-in methods include password + TOTP, Passkey (WebAuthn), and email-code account recovery. +- Sensitive state such as user data, roles, and permissions must not be written to `localStorage` or `sessionStorage`. - Non-sensitive data such as themes, layout preferences, public configuration caches, and content drafts may be persisted when appropriate. - Frontend permission checks only control the UI; the backend must enforce actual permissions. diff --git a/README.md b/README.md index 4678a72..8be3bf5 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,9 @@ npm run dev | 变量 | 作用 | | -------------------------- | --------------------------------------------------------- | -| `VITE_API_BASE_URL` | API 直连地址,或 Vite 开发代理的目标地址 | -| `VITE_WS_URL` | WebSocket 服务基础地址 | -| `VITE_USE_PROXY` | 为字符串 `"true"` 时,HTTP 请求使用同源 `/api/v1` | -| `VITE_REQUIRE_CREDENTIALS` | 为字符串 `"true"` 时,为 Axios 请求启用 `withCredentials` | +| `VITE_API_BASE_URL` | API 直连地址,或 Vite 开发代理的目标地址 | +| `VITE_WS_URL` | WebSocket 服务基础地址 | +| `VITE_USE_PROXY` | 为字符串 `"true"` 时,HTTP 请求使用同源 `/api/v1` | ### 请求模式 @@ -192,10 +191,10 @@ BrowserRouter ### 认证与存储 -- 页面启动时从 `S_TOKEN` Cookie 恢复 Token。 -- 运行期间由 `AuthContext` 和内存 `TokenManager` 管理 Token。 -- Axios 请求拦截器自动附加 Bearer Token。 -- Token、用户信息、角色和权限等敏感状态不得写入 `localStorage` 或 `sessionStorage`。 +- 认证使用服务端 Session + HttpOnly `SESSIONID` Cookie;前端不读取、不保存 token。 +- 页面启动时通过 `/user/info` 恢复用户态,Axios 统一携带 Cookie 凭据。 +- 支持密码 + TOTP、Passkey(WebAuthn)、邮箱验证码恢复登录。 +- 用户信息、角色和权限等敏感状态不得写入 `localStorage` 或 `sessionStorage`。 - 主题、布局、公开配置缓存和内容草稿等非敏感数据可以按需持久化。 - 所有前端权限判断只用于界面控制,后端仍必须执行实际权限校验。 diff --git a/docs/README.md b/docs/README.md index dacadf8..e67864c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # 接口与数据协议(前端新增界面) -本目录收录本次新增的 5 个用户界面所需的接口与数据协议。这些界面当前均使用 `src` 内的本地 mock 数据渲染,下列协议用于指导后端对接时提供一致的契约。 +本目录收录用户界面相关的接口与数据协议。部分界面仍使用 `src` 内的本地 mock 数据渲染;账户安全已接入服务端会话、登录设备与 Passkey 接口。 涉及界面: @@ -17,7 +17,7 @@ 所有接口基于 `../src/infra/Http.ts` 中的 `HttpClient`,遵循以下约定: - **Base URL**:`VITE_USE_PROXY=true` 时为 `/api/v1`,否则为 `VITE_API_BASE_URL`(如 `http://8.148.82.38:8078`)。 -- **鉴权**:请求拦截器自动附加 `Authorization: Bearer `,token 来自内存态 `TokenManager`(取自 `S_TOKEN` Cookie),**不**使用 localStorage。 +- **鉴权**:服务端 Session + HttpOnly `SESSIONID` Cookie;Axios 统一 `withCredentials: true`,前端不读取、不保存 token。 - **响应包络(HttpResponse)**: ```ts interface HttpResponse { diff --git a/docs/account-security.md b/docs/account-security.md index c52ca93..37f26bb 100644 --- a/docs/account-security.md +++ b/docs/account-security.md @@ -1,84 +1,115 @@ # 账户安全(Account Security) 入口:个人中心(`/personal?tab=security`)→ 账户安全 Tab。 -对应组件:`src/pages/user/SecurityTab.tsx`(外壳 `src/pages/user/AccountSecurity.tsx` 已废弃,仅保留 Tab)。 - -## 数据模型 - -```ts -interface Device { - id: string; - name: string; // 设备描述,如 "Chrome · macOS" - location: string; // 登录地点,如 "杭州" - lastActive: string; // 最近活跃,如 "刚刚" / "2 小时前" - current: boolean; // 是否为当前设备 - mobile: boolean; // 是否移动端 -} +对应组件:`src/pages/user/SecurityTab.tsx`。 -interface SecuritySettings { - twoFactor: boolean; // 两步验证开关 - loginAlert: boolean; // 异地登录提醒开关 -} -``` +## 认证约定 -## 接口 +- 认证使用服务端 Session + HttpOnly `SESSIONID` Cookie。 +- 前端不读取、不保存 token;所有请求由 `HttpClient` 统一携带 Cookie。 +- 会话失效时后端返回 `errno=1101`,前端清理用户态并跳转 `/auth`。 -### GET /api/v1/user/security/settings -获取安全开关状态。 +## 登录会话 -**响应 data** -```json -{ "twoFactor": false, "loginAlert": true } -``` +### GET /api/v1/user/device/list -### PUT /api/v1/user/security/2fa -开启 / 关闭两步验证。 +获取当前用户的登录会话列表。响应 `data`: -**请求体** ```json -{ "enabled": true } +{ + "total": 1, + "list": [ + { + "id": "", + "device_id": "", + "device_name": "Chrome · macOS", + "platform": "macOS", + "ip": "203.0.113.10", + "is_current": true, + "login_time": 1790000000, + "last_active_time": 1790000000 + } + ] +} ``` -**响应 data** -```json -{ "twoFactor": true } -``` +### POST /api/v1/user/device/kick -### PUT /api/v1/user/security/login-alert -开启 / 关闭异地登录提醒。 +强制下线指定会话。表单字段: -**请求体** -```json -{ "enabled": false } -``` +| 字段 | 必填 | 说明 | +| ----------- | ---- | ------------------------------- | +| `device_id` | 是 | 列表中的会话 hash,不是旧设备表 ID | + +当前设备不显示下线按钮;后端仍会校验会话归属。 + +## Passkey(WebAuthn) + +### POST /api/v1/auth/passkey/list + +列出当前用户的 Passkey。响应 `data`: -**响应 data** ```json -{ "loginAlert": false } +{ + "total": 1, + "list": [ + { + "id": 1, + "name": "MacBook Touch ID", + "alg": -7, + "aaguid": "...", + "transports": "internal,hybrid", + "backup_eligible": 1, + "backup_state": 0, + "created_at": 1790000000, + "last_used_at": 1790000000 + } + ] +} ``` -### GET /api/v1/user/devices -获取当前账号登录设备列表。 +### POST /api/v1/auth/passkey/register/begin + +表单字段 `name` 可选;返回 WebAuthn `PublicKeyCredentialCreationOptions`。 + +### POST /api/v1/auth/passkey/register/finish + +请求体为 `PublicKeyCredential.toJSON()`,并附带 `name`: -**响应 data** ```json -[ - { - "id": "1", - "name": "Chrome · macOS", - "location": "杭州", - "lastActive": "刚刚", - "current": true, - "mobile": false - } -] +{ + "id": "...", + "rawId": "...", + "type": "public-key", + "response": { + "clientDataJSON": "...", + "attestationObject": "...", + "transports": ["internal", "hybrid"] + }, + "name": "MacBook Touch ID" +} ``` -### DELETE /api/v1/user/devices/:id -强制下线指定设备(对应前端 `revoke` 二次确认)。 +### POST /api/v1/auth/passkey/delete + +表单字段 `id`(Passkey 记录 ID)软删除当前用户的凭据。 + +## 两步验证(2FA) -**响应**:成功返回空 `data`;`errno` 非 0 表示失败(如设备不存在)。 +后端已有接口: + +| 接口 | 说明 | +| --------------------------- | -------------------------------------- | +| `POST /user/2fa/enable` | 生成 TOTP secret 和 `otp_uri` | +| `POST /user/2fa/confirm` | 校验动态码并返回一次性恢复码 | +| `POST /user/2fa/disable` | 校验动态码后关闭 2FA | +| `POST /user/2fa/verify` | 登录第二步验证 | +| `POST /user/2fa/recovery` | 使用一次性恢复码完成登录 | + +> 当前 `/user/info` 尚未返回 `totp_enabled`,安全页暂不提供 2FA 开关;登录页已支持 2FA 验证步骤。 ## 备注 -- 修改密码不在本界面(平台采用内存态 Token + HttpOnly Cookie,密码修改由认证服务单独处理)。 -- 所有写操作需携带有效 `Bearer` token,否则响应 `errno=1101` 触发重新登录。 + +- 修改密码由认证服务单独处理;修改成功后后端会撤销全部旧会话。 +- 邮箱验证码恢复登录会撤销全部旧会话,并签发新的 `SESSIONID`。 +- 所有写操作依赖同源 Cookie 中的有效会话;前端权限判断仅用于界面控制。 diff --git a/docs/auth-websocket-login-incident.md b/docs/auth-websocket-login-incident.md index 77c048c..5426552 100644 --- a/docs/auth-websocket-login-incident.md +++ b/docs/auth-websocket-login-incident.md @@ -1,5 +1,8 @@ # 登录与 WebSocket 认证故障复盘 +> 历史文档:本文记录的是旧 token 认证时期的问题。当前前端已改为服务端 Session + HttpOnly `SESSIONID` Cookie, +> WebSocket 握手依赖同源 Cookie,不再使用 `TokenManager` / `refresh_token` / `S_TOKEN_TIME`。 + ## 故障概述 生产环境曾出现登录后立即失效、页面跳转到 `/auth`,以及浏览器控制台持续输出以下错误的问题: diff --git a/docs/index.html b/docs/index.html index 7e3b919..25fc802 100644 --- a/docs/index.html +++ b/docs/index.html @@ -313,7 +313,7 @@

管理后台

🔐

系统能力

-

内存态 Token 认证、WebSocket 实时通知、维护模式守卫、明暗主题、页面宽度切换、分片上传。

+

服务端会话 + Passkey 认证、WebSocket 实时通知、维护模式守卫、明暗主题、页面宽度切换、分片上传。

diff --git a/src/auth/CookieHelper.ts b/src/auth/CookieHelper.ts deleted file mode 100644 index fe0464a..0000000 --- a/src/auth/CookieHelper.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Cookie 工具 - */ - -/** - * 从 Cookie 中读取指定 key 的值 - * - * 用 indexOf 而非 split("=") 取值:Cookie 值本身允许含 "="(base64 填充、JWT 分段等), - * split 会在第一个 "=" 处切断导致 token 被静默截断。 - */ -export function getCookie(key: string): string | null { - const cookies = document.cookie.split(";"); - for (const cookie of cookies) { - const trimmed = cookie.trim(); - const eq = trimmed.indexOf("="); - if (eq < 0) { - continue; - } - if (trimmed.slice(0, eq) !== key) { - continue; - } - return decodeURIComponent(trimmed.slice(eq + 1)); - } - return null; -} - -/** - * 从 Cookie 中提取 S_TOKEN - */ -export function getTokenFromCookie(): string | null { - return getCookie("S_TOKEN"); -} - -/** - * 清除会话认证 Cookie(S_TOKEN / S_UID / S_TOKEN_TIME) - * 注意:DEVICE_ID 为 10 年设备标识,保留不删 - */ -export function clearAuthCookies(): void { - const authCookieNames = ["S_TOKEN", "S_UID", "S_TOKEN_TIME"]; - authCookieNames.forEach((name) => { - document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; - }); -} diff --git a/src/auth/TokenManager.ts b/src/auth/SessionEvents.ts similarity index 52% rename from src/auth/TokenManager.ts rename to src/auth/SessionEvents.ts index a81ca5b..573150e 100644 --- a/src/auth/TokenManager.ts +++ b/src/auth/SessionEvents.ts @@ -1,43 +1,15 @@ /** - * Token 管理器 — 纯内存存储,避免 localStorage 安全风险 + * 会话失效事件总线 + * + * 认证已经改为服务端 Session + HttpOnly SESSIONID Cookie,前端不再持有 token。 + * 这里只负责把 1101 等会话失效事件通知给 AuthContext / SessionNotifier。 */ export type SessionInvalidReason = "expired" | "kicked"; -class TokenManager { - private token: string | null = null; - private listeners: ((token: string | null) => void)[] = []; +class SessionEvents { private sessionInvalidatedListeners: ((reason: SessionInvalidReason) => void)[] = []; - setToken(token: string | null) { - this.token = token; - this.notifyListeners(); - } - - getToken(): string | null { - return this.token; - } - - clearToken() { - this.token = null; - this.notifyListeners(); - } - - addListener(listener: (token: string | null) => void) { - this.listeners.push(listener); - } - - removeListener(listener: (token: string | null) => void) { - const index = this.listeners.indexOf(listener); - if (index > -1) { - this.listeners.splice(index, 1); - } - } - - private notifyListeners() { - this.listeners.forEach((listener) => listener(this.token)); - } - emitSessionInvalidated(reason: SessionInvalidReason) { this.sessionInvalidatedListeners.forEach((listener) => listener(reason)); } @@ -54,11 +26,11 @@ class TokenManager { } } -export const tokenManager = new TokenManager(); +export const sessionEvents = new SessionEvents(); /** * 根据服务端 errstr/msg 判定会话失效原因 - * 命中"被顶/被踢/已在其他设备"关键字 → kicked,否则视为 token 过期 + * 命中"被顶/被踢/已在其他设备"关键字 → kicked,否则视为会话过期 */ export const detectInvalidReason = (text: string): SessionInvalidReason => /kick|kicked|other device|异地|被顶|踢下线|已在其他设备|已被登录/i.test(text) ? "kicked" : "expired"; diff --git a/src/components/appShell/FrontLayout.tsx b/src/components/appShell/FrontLayout.tsx index 169b0f9..1f5742d 100644 --- a/src/components/appShell/FrontLayout.tsx +++ b/src/components/appShell/FrontLayout.tsx @@ -19,7 +19,8 @@ const FrontLayout: React.FC = () => { const isProfile = location.pathname.startsWith("/profile"); const isAssignment = location.pathname.startsWith("/assignment"); const isMessages = location.pathname.startsWith("/messages"); - const fit = isHome || isHelp || isEditor || isOrg || isProfile || isAssignment || isMessages; + const isProduct = location.pathname.startsWith("/products"); + const fit = isHome || isHelp || isEditor || isOrg || isProfile || isAssignment || isMessages || isProduct; if (mode === "classic") { return ( diff --git a/src/components/auth/SessionNotifier.tsx b/src/components/auth/SessionNotifier.tsx index 6f8e783..33e5257 100644 --- a/src/components/auth/SessionNotifier.tsx +++ b/src/components/auth/SessionNotifier.tsx @@ -1,11 +1,11 @@ import React, { useEffect } from "react"; -import { tokenManager, type SessionInvalidReason } from "@/auth/TokenManager.ts"; +import { sessionEvents, type SessionInvalidReason } from "@/auth/SessionEvents.ts"; import i18n from "@/i18n"; import message from "@/components/message/Message"; /** * 会话失效全局提示组件 - * 挂在 MessageProvider 内部,订阅 tokenManager 的会话失效事件并弹出提示 + * 挂在 MessageProvider 内部,订阅 sessionEvents 的会话失效事件并弹出提示 * (AuthContext 位于 MessageProvider 之上,无法直接调用 message,故单独承载) */ const SessionNotifier: React.FC = () => { @@ -13,9 +13,9 @@ const SessionNotifier: React.FC = () => { const handleInvalidated = (reason: SessionInvalidReason) => { message.error(reason === "kicked" ? i18n.t("auth:session.kicked") : i18n.t("auth:session.expired")); }; - tokenManager.addSessionInvalidatedListener(handleInvalidated); + sessionEvents.addSessionInvalidatedListener(handleInvalidated); return () => { - tokenManager.removeSessionInvalidatedListener(handleInvalidated); + sessionEvents.removeSessionInvalidatedListener(handleInvalidated); }; }, []); diff --git a/src/components/navbar/Navbar.tsx b/src/components/navbar/Navbar.tsx index f4d2140..cf214ba 100644 --- a/src/components/navbar/Navbar.tsx +++ b/src/components/navbar/Navbar.tsx @@ -11,6 +11,7 @@ import { FaTimes, FaRegComments, FaQuestionCircle, + FaCube, } from "react-icons/fa"; import { useTranslation } from "react-i18next"; import styles from "./Navbar.module.css"; @@ -30,7 +31,7 @@ const Navbar: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); // 获取认证状态 - const { user, isAuthenticated, logout, token, loading } = useAuth(); + const { user, isAuthenticated, logout, loading } = useAuth(); const { settings: siteSettings } = useSiteSettings(); const { t } = useTranslation("nav"); const { t: tCommon } = useTranslation("common"); @@ -74,7 +75,7 @@ const Navbar: React.FC = () => { }; window.addEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll); - }, [user, isAuthenticated, token]); + }, [user, isAuthenticated]); // 点击外部关闭用户菜单 useEffect(() => { @@ -133,11 +134,18 @@ const Navbar: React.FC = () => { if (path === "/messages") { return location.pathname === "/messages"; } + if (path === "/products") { + return location.pathname.startsWith("/products"); + } return location.pathname === path; }; const getNavLinks = () => { - const links = [...navLinks, { label: t("help"), icon: , path: "/help" }]; + const links = [ + ...navLinks, + ...(import.meta.env.DEV ? [{ label: "产品", icon: , path: "/products" }] : []), + { label: t("help"), icon: , path: "/help" }, + ]; return links; }; diff --git a/src/components/sideNav/SideNav.tsx b/src/components/sideNav/SideNav.tsx index 94752fe..470d933 100644 --- a/src/components/sideNav/SideNav.tsx +++ b/src/components/sideNav/SideNav.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { FaHome, FaBuilding, FaQuestionCircle } from "react-icons/fa"; +import { FaHome, FaBuilding, FaQuestionCircle, FaCube } from "react-icons/fa"; import styles from "./SideNav.module.css"; import ThemeToggle from "../themeToggle/ThemeToggle"; import LayoutModeToggle from "../layoutModeToggle/LayoutModeToggle"; @@ -31,6 +31,7 @@ const SideNav: React.FC = ({ open = false, onClose }) => { const navItems = [ { label: t("home"), icon: , path: "/index" }, { label: t("organizationsShort"), icon: , path: "/organizations/list" }, + ...(import.meta.env.DEV ? [{ label: "产品", icon: , path: "/products" }] : []), { label: t("help"), icon: , path: "/help" }, ]; @@ -41,6 +42,9 @@ const SideNav: React.FC = ({ open = false, onClose }) => { if (path === "/organizations/list") { return location.pathname.startsWith("/organizations") || location.pathname.startsWith("/organization"); } + if (path === "/products") { + return location.pathname.startsWith("/products"); + } return location.pathname === path; }; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 5dd62ee..ecce988 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,8 +1,8 @@ import React, { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; import { AuthService } from "../services/AuthService.ts"; -import { tokenManager } from "../auth/TokenManager.ts"; -import { getTokenFromCookie, clearAuthCookies } from "../auth/CookieHelper.ts"; +import { PasskeyService } from "../services/PasskeyService.ts"; +import { sessionEvents } from "../auth/SessionEvents.ts"; import { notificationWS, chatWS } from "../services/WsInstances.ts"; import { setFaviconBadge } from "../utils/Favicon.ts"; import { resetNotificationState } from "../utils/NotificationState.ts"; @@ -26,11 +26,19 @@ export interface User { follower_count?: number; } +export interface LoginResult { + requires2fa: boolean; + tempToken?: string; +} + // 认证上下文类型 interface AuthContextType { user: User | null; - token: string | null; - login: (authId: string, password: string) => Promise; + login: (authId: string, password: string) => Promise; + verifyTwoFactor: (tempToken: string, code: string) => Promise; + verifyTwoFactorRecovery: (tempToken: string, recoveryCode: string) => Promise; + loginWithPasskey: (authId?: string) => Promise; + recoverWithEmail: (email: string, authCode: string) => Promise; logout: () => Promise; isAuthenticated: boolean; loading: boolean; @@ -41,10 +49,26 @@ interface AuthContextType { // 创建认证上下文 const AuthContext = createContext(undefined); +function normalizeUser(data: Record): User { + return { + ...(data as User), + role: Number(data.role) || 1, + account: data.account || data.username || "", + login_time: data.login_time || new Date().toISOString(), + status: data.status ?? data.is_deleted ?? 0, + }; +} + +function getErrorMessage(error: any, fallback: string): string { + if (error?.name === "NotAllowedError") { + return i18n.t("auth:message.passkeyCancelled"); + } + return error?.msg || error?.message || fallback; +} + // 认证提供者组件 export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [user, setUser] = useState(null); - const [token, setToken] = useState(null); const [loading, setLoading] = useState(true); const [loginError, setLoginError] = useState(null); const navigate = useNavigate(); @@ -55,72 +79,57 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => chatWS.disconnect(); }, []); - const extractToken = (response: any): string | null => { - const candidates = [ - response?.token, - response?.access_token, - response?.data?.token, - response?.data?.access_token, - response?.data?.data?.token, - response?.data?.data?.access_token, - response?.data?.data?.data?.token, - response?.data?.data?.data?.access_token, - ]; - - for (const t of candidates) { - if (typeof t === "string" && t.trim()) { - return t; - } - } - return null; - }; - const clearAuthRuntimeState = useCallback(() => { disconnectWebSockets(); setUser(null); - setToken(null); - tokenManager.clearToken(); - clearAuthCookies(); resetNotificationState(); setFaviconBadge(0); }, [disconnectWebSockets]); - // 初始化认证状态 + const clearAuthState = useCallback(() => { + clearAuthRuntimeState(); + }, [clearAuthRuntimeState]); + + const completeLogin = useCallback(async (generation: number) => { + try { + const userResponse = await AuthService.getUserInfo(undefined, { skipSessionInvalidation: true }); + if (generation !== authGenerationRef.current) { + return; + } + if (userResponse.errno !== 0 || !userResponse.data) { + throw new Error(i18n.t("auth:message.userInfoInvalid")); + } + setUser(normalizeUser(userResponse.data)); + } catch (error) { + if (generation === authGenerationRef.current) { + // 登录接口可能已经设置了 Cookie,但用户信息校验失败;尽量撤销服务端会话,避免半登录。 + try { + await AuthService.logout(); + } catch { + // 忽略清理失败,调用方会继续处理原始错误 + } + } + throw error; + } + }, []); + + // 初始化认证状态:只通过 HttpOnly Cookie 判断,不再读取任何 token。 useEffect(() => { const generation = ++authGenerationRef.current; const initAuth = async () => { try { - const cookieToken = getTokenFromCookie(); - - if (!cookieToken) { - // 无 token → 直接标记未认证,不发请求 - clearAuthRuntimeState(); + const userResponse = await AuthService.getUserInfo(undefined, { skipSessionInvalidation: true }); + if (generation !== authGenerationRef.current) { return; } - - // 有 token → 设置后再 getUserInfo - setToken(cookieToken); - tokenManager.setToken(cookieToken); - - try { - const userResponse = await AuthService.getUserInfo(); - - if ( - generation === authGenerationRef.current && - tokenManager.getToken() === cookieToken && - userResponse.data && - userResponse.errno === 0 - ) { - const userData = userResponse.data; - userData.role = Number(userData.role) || 1; - setUser(userData as User); - } else if (generation === authGenerationRef.current) { - clearAuthRuntimeState(); - } - } catch (_userError) { - if (generation === authGenerationRef.current) { - clearAuthRuntimeState(); - } + if (userResponse.errno === 0 && userResponse.data) { + setUser(normalizeUser(userResponse.data)); + } else { + setUser(null); + } + } catch { + if (generation === authGenerationRef.current) { + setUser(null); } } finally { if (generation === authGenerationRef.current) { @@ -137,29 +146,28 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => }; }, []); - // 会话失效(token 过期 / 被顶下线)→ 清除登录态并跳转登录页 + // 会话失效(过期 / 被顶下线)→ 清除登录态并跳转登录页 useEffect(() => { const handleSessionInvalidated = () => { authGenerationRef.current++; - clearAuthRuntimeState(); + clearAuthState(); setLoading(false); if (!window.location.pathname.startsWith("/auth")) { navigate("/auth"); } }; - tokenManager.addSessionInvalidatedListener(handleSessionInvalidated); + sessionEvents.addSessionInvalidatedListener(handleSessionInvalidated); return () => { - tokenManager.removeSessionInvalidatedListener(handleSessionInvalidated); + sessionEvents.removeSessionInvalidatedListener(handleSessionInvalidated); }; - }, [clearAuthRuntimeState, navigate]); + }, [clearAuthState, navigate]); // 计算是否已认证(必须在 WebSocket useEffect 之前声明) - const isAuthenticated = Boolean(user && token); + const isAuthenticated = Boolean(user); - // WebSocket 连接管理 — 在 AuthProvider 层持久化,不会随路由切换重连 + // WebSocket 连接管理 — 浏览器会自动携带同源 SESSIONID Cookie useEffect(() => { - const activeToken = tokenManager.getToken(); - if (isAuthenticated && user && token && activeToken === token) { + if (isAuthenticated && user) { notificationWS.connect(user.id); // 普通用户无私信权限,不建立聊天连接(后端同样拒绝,避免重连循环) if (canChat(user.role)) { @@ -172,23 +180,21 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => chatWS.disconnect(); setFaviconBadge(0); // 退出登录或未认证时清除 favicon 角标 } - }, [isAuthenticated, token, user]); + }, [isAuthenticated, user]); - // WebSocket 错误不负责轮换 token。token 只由主动续期计时器更新, - // 避免页面首次建立 WS 后收到 1101 就导致每次刷新页面都调用 refresh_token。 + // WebSocket 错误不负责刷新会话。HTTP 请求收到 1101 时仍由 Http.ts 统一处理。 useEffect(() => { // 聊天 WS 失败(如无权限/账号异常)绝不影响登录态,仅停止重连避免循环。 const unsubChatError = chatWS.onServerError(() => { chatWS.disconnect(); }); const unsubError = notificationWS.onServerError((err) => { - // 账号状态异常(1103),刷新 token 无意义,直接登出 + // 账号状态异常(1103),直接按会话失效处理。 if (err.errno === 1103) { - clearAuthRuntimeState(); + sessionEvents.emitSessionInvalidated("expired"); return; } - // 1101 只代表该 WebSocket 会话被服务端拒绝,不能据此轮换全局 token。 - // HTTP 请求收到 1101 时仍由 Http.ts 统一清理登录态;主动续期由下方计时器负责。 + // 1101 只代表该 WebSocket 会话被服务端拒绝,不能据此判定 HTTP 会话已失效。 if (err.errno === 1101) { notificationWS.disconnect(); } @@ -197,85 +203,37 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => unsubError(); unsubChatError(); }; - }, [clearAuthRuntimeState]); + }, []); - // 登录方法 - const login = async (authId: string, password: string) => { + const login = async (authId: string, password: string): Promise => { return loginWithRetry(authId, password, false); }; - const loginWithRetry = async (authId: string, password: string, retried: boolean) => { + const loginWithRetry = async (authId: string, password: string, retried: boolean): Promise => { const generation = ++authGenerationRef.current; try { setLoginError(null); setLoading(true); disconnectWebSockets(); setUser(null); - setToken(null); - tokenManager.clearToken(); resetNotificationState(); setFaviconBadge(0); const response = await AuthService.login(authId, password); if (generation !== authGenerationRef.current) { - return; + return { requires2fa: false }; } - if (response.errno === 0) { - let userToken = extractToken(response); - - if (!userToken) { - userToken = getTokenFromCookie(); - } - - if (!userToken) { - throw new Error(i18n.t("auth:message.tokenMissing")); - } - - tokenManager.setToken(userToken); - setToken(userToken); - - // 获取最新的用户信息 - try { - const userResponse = await AuthService.getUserInfo(); - - if ( - generation === authGenerationRef.current && - tokenManager.getToken() === userToken && - userResponse.data && - userResponse.errno === 0 - ) { - const updatedUser = userResponse.data; - updatedUser.role = Number(updatedUser.role) || 1; - setUser(updatedUser as User); - } else { - throw new Error(i18n.t("auth:message.userInfoInvalid")); - } - } catch (_userError) { - if (generation !== authGenerationRef.current || tokenManager.getToken() !== userToken) { - return; - } - // 如果获取用户信息失败,使用登录响应中的用户信息 - if ((response.data as any)?.user) { - const fallbackUserData = (response.data as any).user; - // 转换为User类型 - const fallbackUser: User = { - ...fallbackUserData, - account: fallbackUserData.username || fallbackUserData.account || "", - login_time: fallbackUserData.login_time || new Date().toISOString(), - status: fallbackUserData.status || "active", - }; - setUser(fallbackUser); - } else { - throw _userError; - } - } - } else { - console.warn("⚠️ 登录响应状态异常:", response); + const data = response.data; + if (data?.require_2fa && data.temp_token) { + return { requires2fa: true, tempToken: data.temp_token }; } + + await completeLogin(generation); + return { requires2fa: false }; } catch (error: any) { if (generation !== authGenerationRef.current) { - return; + return { requires2fa: false }; } // 2010 USER_ALREADY_LOGIN:本设备已有登录态,先登出再重试一次登录 if (!retried && error?.errno === 2010) { @@ -284,11 +242,99 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } catch { // 登出失败不阻断重试 } - clearAuthRuntimeState(); + clearAuthState(); return loginWithRetry(authId, password, true); } - setLoginError(error.message || i18n.t("auth:message.loginFailed")); - clearAuthRuntimeState(); + setLoginError(getErrorMessage(error, i18n.t("auth:message.loginFailed"))); + clearAuthState(); + throw error; + } finally { + if (generation === authGenerationRef.current) { + setLoading(false); + } + } + }; + + const verifyTwoFactor = async (tempToken: string, code: string) => { + const generation = ++authGenerationRef.current; + try { + setLoginError(null); + setLoading(true); + await AuthService.verify2fa(tempToken, code); + await completeLogin(generation); + } catch (error: any) { + if (generation === authGenerationRef.current) { + setLoginError(getErrorMessage(error, i18n.t("auth:message.twoFactorFailed"))); + clearAuthState(); + } + throw error; + } finally { + if (generation === authGenerationRef.current) { + setLoading(false); + } + } + }; + + const verifyTwoFactorRecovery = async (tempToken: string, recoveryCode: string) => { + const generation = ++authGenerationRef.current; + try { + setLoginError(null); + setLoading(true); + await AuthService.verify2faRecovery(tempToken, recoveryCode); + await completeLogin(generation); + } catch (error: any) { + if (generation === authGenerationRef.current) { + setLoginError(getErrorMessage(error, i18n.t("auth:message.twoFactorFailed"))); + clearAuthState(); + } + throw error; + } finally { + if (generation === authGenerationRef.current) { + setLoading(false); + } + } + }; + + const loginWithPasskey = async (authId?: string) => { + const generation = ++authGenerationRef.current; + try { + setLoginError(null); + setLoading(true); + disconnectWebSockets(); + setUser(null); + resetNotificationState(); + setFaviconBadge(0); + await PasskeyService.login(authId); + await completeLogin(generation); + } catch (error: any) { + if (generation === authGenerationRef.current) { + setLoginError(getErrorMessage(error, i18n.t("auth:message.passkeyLoginFailed"))); + clearAuthState(); + } + throw error; + } finally { + if (generation === authGenerationRef.current) { + setLoading(false); + } + } + }; + + const recoverWithEmail = async (email: string, authCode: string) => { + const generation = ++authGenerationRef.current; + try { + setLoginError(null); + setLoading(true); + disconnectWebSockets(); + setUser(null); + resetNotificationState(); + setFaviconBadge(0); + await AuthService.recoverWithEmail(email, authCode); + await completeLogin(generation); + } catch (error: any) { + if (generation === authGenerationRef.current) { + setLoginError(getErrorMessage(error, i18n.t("auth:message.recoveryFailed"))); + clearAuthState(); + } throw error; } finally { if (generation === authGenerationRef.current) { @@ -302,13 +348,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const generation = ++authGenerationRef.current; try { setLoading(true); - // 调用后端登出接口 await AuthService.logout(); if (generation !== authGenerationRef.current) { return; } - // 清除内存状态 - clearAuthRuntimeState(); + clearAuthState(); setLoginError(null); } catch (error) { if (generation !== authGenerationRef.current) { @@ -316,7 +360,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } console.error("登出失败:", error); // 即使后端登出失败,也清除本地状态 - clearAuthRuntimeState(); + clearAuthState(); } finally { if (generation === authGenerationRef.current) { setLoading(false); @@ -331,8 +375,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const value: AuthContextType = { user, - token, login, + verifyTwoFactor, + verifyTwoFactorRecovery, + loginWithPasskey, + recoverWithEmail, logout, isAuthenticated, loading, diff --git a/src/hooks/UseAiSummary.test.tsx b/src/hooks/UseAiSummary.test.tsx index cbf6008..c280872 100644 --- a/src/hooks/UseAiSummary.test.tsx +++ b/src/hooks/UseAiSummary.test.tsx @@ -2,22 +2,17 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { act } from "react"; import { createRoot } from "react-dom/client"; import useAiSummary from "./UseAiSummary.ts"; -import { tokenManager } from "../auth/TokenManager.ts"; // react-dom 的 act() 需要显式标记测试环境(React 19 从 "react" 导出 act) (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; /** - * AI-SSE token 泄露回归测试(R0 安全项)。 + * AI-SSE Cookie 认证回归测试。 * - * 背景:请求开始的那段诊断日志曾打印 `token.substring(0, 8)` —— 长时凭据的前缀同样属于泄露, - * 足以用于日志检索与口令猜测辅助。现在只打印布尔值。 - * 这里锁死:无论是否走 Vite proxy(决定是否真的带 Authorization 头), - * 控制台里都不能出现 token 的任何片段(含前缀)。 + * 认证已经改为 HttpOnly SESSIONID Cookie,SSE 请求必须显式 credentials: include, + * 且不能再出现 Authorization 头或任何 token 片段。 */ -const TOKEN = "SECRET_AI_TOKEN_7f3c9a"; - /** 构造一个立刻结束、可选带一个 chunk 的假 SSE 响应 */ function fakeSseResponse(chunks: string[] = []): Response { const encoder = new TextEncoder(); @@ -89,64 +84,38 @@ async function captureConsole(run: () => void): Promise { return lines; } -describe("AI 总结 SSE 日志脱敏", () => { +describe("AI 总结 SSE Cookie 认证", () => { let fetchMock: ReturnType; const originalProxy = import.meta.env.VITE_USE_PROXY; beforeEach(() => { fetchMock = vi.fn(async () => fakeSseResponse(['data: {"type":"chunk","content":"你好"}\n\n'])); vi.stubGlobal("fetch", fetchMock); - tokenManager.setToken(TOKEN); - // 走 proxy 分支:这条路径会真的把 token 放进 Authorization 头,覆盖面更大 import.meta.env.VITE_USE_PROXY = "true"; }); afterEach(() => { vi.unstubAllGlobals(); - tokenManager.clearToken(); import.meta.env.VITE_USE_PROXY = originalProxy; }); - it("日志绝不出现 token 原文,也不出现任意长度前缀", async () => { + it("日志说明使用 Cookie 认证,且不出现 Authorization", async () => { const view = renderHook(); const lines = await captureConsole(() => view.start()); - - expect(lines.length).toBeGreaterThan(0); const joined = lines.join("\n"); - // 完整 token - expect(joined).not.toContain(TOKEN); - // 当初泄露的形态就是 8 位前缀;这里从 6 位起逐个长度断言。 - // 不测 1~5 位:单/双字符前缀("S"、"SE")会与日志里的 "SSE"、"size" 等正常词撞上, - // 产生假阳性而非真泄露。 - for (let n = 6; n <= TOKEN.length; n += 1) { - expect(joined).not.toContain(TOKEN.slice(0, n)); - } - // 仍应打印「是否携带 token」这一诊断位,防止「干脆不打印」也算通过 - expect(joined).toContain("携带 token"); + expect(joined).toContain("Cookie (SESSIONID)"); + expect(joined).not.toContain("Authorization"); view.unmount(); }); - it("请求头仍正确携带 Bearer token(脱敏不能牺牲功能)", async () => { + it("SSE 请求使用 credentials: include,且不附加 Authorization", async () => { const view = renderHook(); await captureConsole(() => view.start()); expect(fetchMock).toHaveBeenCalledTimes(1); const init = fetchMock.mock.calls[0][1] as RequestInit; - expect((init.headers as Record).Authorization).toBe(`Bearer ${TOKEN}`); - view.unmount(); - }); - - it("未登录时日志明确显示未携带 token,且不写 undefined", async () => { - tokenManager.clearToken(); - const view = renderHook(); - const lines = await captureConsole(() => view.start()); - - const joined = lines.join("\n"); - expect(joined).toContain("携带 token : false"); - expect(joined).not.toContain("undefined"); - - const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(init.credentials).toBe("include"); expect((init.headers as Record).Authorization).toBeUndefined(); view.unmount(); }); diff --git a/src/hooks/UseAiSummary.ts b/src/hooks/UseAiSummary.ts index d0e235a..ce0a440 100644 --- a/src/hooks/UseAiSummary.ts +++ b/src/hooks/UseAiSummary.ts @@ -1,5 +1,4 @@ import { useState, useRef, useCallback, useEffect } from "react"; -import { tokenManager } from "../auth/TokenManager.ts"; import i18n from "@/i18n"; /** @@ -50,7 +49,6 @@ function useAiSummary(articleId?: string | number) { abortRef.current = controller; const useProxy = import.meta.env.VITE_USE_PROXY === "true"; - const token = tokenManager.getToken(); // SSE stream via Vite proxy in dev (no CORS), direct via nginx in prod. // A dedicated proxy rule in vite.config.ts handles /api/v1/article/ai-summary @@ -64,11 +62,6 @@ function useAiSummary(articleId?: string | number) { "Content-Type": "application/x-www-form-urlencoded", Accept: "text/event-stream", }; - // 走 Vite proxy 时带上 token header(同源无 CORS 问题); - // 直连后端时不带 Authorization,避免触发 CORS preflight - if (token && useProxy) { - headers["Authorization"] = `Bearer ${token}`; - } // ---- 日志:请求开始 ---- console.log(`${ts()} [AI-SSE] ╔══════════════════════════════════════╗`); @@ -78,7 +71,7 @@ function useAiSummary(articleId?: string | number) { console.log(`${ts()} [AI-SSE] ║ articleId : ${articleId}`); console.log(`${ts()} [AI-SSE] ║ proxy 模式 : ${useProxy}`); console.log(`${ts()} [AI-SSE] ║ 路由方式 : ${useProxy ? "Vite proxy → 后端" : "直连后端"}`); - console.log(`${ts()} [AI-SSE] ║ 携带 token : ${!!token}`); + console.log(`${ts()} [AI-SSE] ║ 认证方式 : Cookie (SESSIONID)`); console.log(`${ts()} [AI-SSE] ║ ⚠️ 如果下面长时间没有响应日志,检查终端里 Vite 的 proxy 日志`); console.log(`${ts()} [AI-SSE] ╚══════════════════════════════════════╝`); @@ -95,6 +88,7 @@ function useAiSummary(articleId?: string | number) { method: "POST", headers, body: formData.toString(), + credentials: "include", signal: controller.signal, }) .then(async (response) => { diff --git a/src/i18n/locales/en-US/Auth.ts b/src/i18n/locales/en-US/Auth.ts index ba191b2..e0d02f0 100644 --- a/src/i18n/locales/en-US/Auth.ts +++ b/src/i18n/locales/en-US/Auth.ts @@ -11,15 +11,20 @@ const auth = { backToLogin: "Back to login", or: "or", githubLogin: "Continue with GitHub", + passkeyLogin: "Sign in with a passkey", title: { login: "Welcome back", register: "Create account", forgotPassword: "Reset password", + recovery: "Recover account", + twoFactor: "Two-factor verification", }, description: { login: "Enter your account details to log in", register: "Create a new account and start your journey", forgotPassword: "Enter your email and we'll send a code to reset your password", + recovery: "Recover access with an email code. Other devices will be signed out.", + twoFactor: "Enter the code from your authenticator app, or use a one-time recovery code", }, fields: { usernameOrEmail: "Username or Email", @@ -30,6 +35,8 @@ const auth = { newPassword: "New Password", confirmPassword: "Confirm Password", confirmNewPassword: "Confirm New Password", + twoFactorCode: "Verification code", + recoveryCode: "Recovery code", }, placeholders: { usernameOrEmail: "Enter username or email", @@ -40,6 +47,8 @@ const auth = { newPassword: "Enter new password", confirmPassword: "Confirm password", confirmNewPassword: "Confirm new password", + twoFactorCode: "Enter the 6-digit code", + recoveryCode: "Enter a one-time recovery code", }, validation: { usernameOrEmailRequired: "Please enter username or email", @@ -58,6 +67,13 @@ const auth = { newPasswordRequired: "Please enter new password", newPasswordMin: "Password must be at least 6 characters", confirmNewPasswordRequired: "Please confirm new password", + twoFactorCodeRequired: "Please enter the verification code", + recoveryCodeRequired: "Please enter a recovery code", + }, + hints: { + twoFactorCode: "The code is generated by your authenticator app and changes every 30 seconds.", + recoveryCode: "Each recovery code can only be used once. Keep it somewhere safe.", + recoveryWarning: "Account recovery revokes all existing sessions. Make sure your email account is secure.", }, message: { unsupportedCodeType: "Unsupported verification code type", @@ -72,6 +88,13 @@ const auth = { resetFailed: "Password reset failed", tokenMissing: "Logged in but no auth token was received", userInfoInvalid: "Logged in but user info verification failed", + passkeyLoginFailed: "Passkey sign-in failed", + passkeyRegisterFailed: "Failed to register passkey", + passkeyUnsupported: "This browser or environment does not support passkeys. Use HTTPS or localhost.", + passkeyCancelled: "Passkey operation was cancelled", + twoFactorFailed: "Two-factor verification failed. Check the code or sign in again.", + recoverySuccess: "Account recovered, redirecting...", + recoveryFailed: "Account recovery failed", }, submit: { login: "Log In", @@ -80,6 +103,10 @@ const auth = { loggingIn: "Logging in...", registering: "Signing up...", resetting: "Resetting...", + verify: "Verify and sign in", + verifying: "Verifying...", + recovery: "Recover account", + recovering: "Recovering...", }, sendCode: { send: "Get code", @@ -103,6 +130,10 @@ const auth = { haveAccount: "Already have an account?", loginNow: "Log in now", rememberPassword: "Remembered password?", + recoveryPrompt: "Can't use password or passkey?", + emailRecovery: "Recover by email", + useRecoveryCode: "Use a recovery code", + useTotpCode: "Use authenticator code", }, } as const; diff --git a/src/i18n/locales/en-US/user/Security.ts b/src/i18n/locales/en-US/user/Security.ts index d1aa328..b6c2d28 100644 --- a/src/i18n/locales/en-US/user/Security.ts +++ b/src/i18n/locales/en-US/user/Security.ts @@ -25,6 +25,36 @@ const section = { revoke: "Revoke", }, unknownDevice: "Unknown device", + passkeys: { + title: "Passkeys", + desc: "Sign in with fingerprint, face, or device PIN. Passkeys are phishing-resistant and safer than passwords.", + register: "Register passkey", + loading: "Loading passkeys...", + errorTitle: "Failed to load passkeys", + reload: "Reload", + empty: "No passkeys registered yet", + fetchFailed: "Failed to get passkeys", + unsupported: "This browser or environment does not support passkeys. Use HTTPS or localhost.", + registerSuccess: "Passkey registered", + registerFailed: "Failed to register passkey", + cancelled: "Passkey operation was cancelled", + deleteTitle: "Delete passkey", + deletePrefix: 'Are you sure you want to delete "', + deleteSuffix: '"? You will no longer be able to use it to sign in.', + delete: "Delete", + cancel: "Cancel", + deleteSuccess: "Passkey deleted", + deleteFailed: "Failed to delete passkey", + deleting: "Deleting...", + unnamed: "Unnamed passkey", + synced: "Synced", + createdAt: "Created {{time}}", + lastUsedAt: "Last used {{time}}", + neverUsed: "Never used", + modalTitle: "Register passkey", + modalHint: "Give this device an easy-to-recognize name, such as “MacBook Touch ID”.", + namePlaceholder: "Passkey name (optional)", + }, revoke: { title: "Revoke Device", contentPrefix: 'Are you sure you want to force "', diff --git a/src/i18n/locales/zh-CN/Auth.ts b/src/i18n/locales/zh-CN/Auth.ts index 4c1e868..24b4e7a 100644 --- a/src/i18n/locales/zh-CN/Auth.ts +++ b/src/i18n/locales/zh-CN/Auth.ts @@ -11,15 +11,20 @@ const auth = { backToLogin: "返回登录", or: "或", githubLogin: "使用 GitHub 登录", + passkeyLogin: "使用通行密钥登录", title: { login: "欢迎回来", register: "创建账号", forgotPassword: "重置密码", + recovery: "恢复登录", + twoFactor: "两步验证", }, description: { login: "请输入您的账号信息登录", register: "创建新账号,开始您的旅程", forgotPassword: "输入您的邮箱,我们将发送验证码重置密码", + recovery: "使用邮箱验证码恢复登录,成功后其他设备会被下线", + twoFactor: "请输入身份验证器中的动态验证码,或使用一次性恢复码", }, fields: { usernameOrEmail: "用户名或邮箱", @@ -30,6 +35,8 @@ const auth = { newPassword: "新密码", confirmPassword: "确认密码", confirmNewPassword: "确认新密码", + twoFactorCode: "动态验证码", + recoveryCode: "恢复码", }, placeholders: { usernameOrEmail: "请输入用户名或邮箱", @@ -40,6 +47,8 @@ const auth = { newPassword: "请输入新密码", confirmPassword: "请确认密码", confirmNewPassword: "请确认新密码", + twoFactorCode: "请输入 6 位动态验证码", + recoveryCode: "请输入一次性恢复码", }, validation: { usernameOrEmailRequired: "请输入用户名或邮箱", @@ -58,6 +67,13 @@ const auth = { newPasswordRequired: "请输入新密码", newPasswordMin: "密码长度至少6位", confirmNewPasswordRequired: "请确认新密码", + twoFactorCodeRequired: "请输入动态验证码", + recoveryCodeRequired: "请输入恢复码", + }, + hints: { + twoFactorCode: "动态验证码由身份验证器生成,每 30 秒更新一次。", + recoveryCode: "恢复码只能使用一次,请妥善保管。", + recoveryWarning: "恢复登录会撤销该账号的全部旧会话,请确认邮箱安全。", }, message: { unsupportedCodeType: "不支持的验证码类型", @@ -72,6 +88,13 @@ const auth = { resetFailed: "重置密码失败", tokenMissing: "登录成功但未获取到认证令牌", userInfoInvalid: "登录成功但用户信息校验失败", + passkeyLoginFailed: "通行密钥登录失败", + passkeyRegisterFailed: "通行密钥注册失败", + passkeyUnsupported: "当前浏览器或环境不支持通行密钥,请使用 HTTPS 或 localhost 访问", + passkeyCancelled: "已取消通行密钥操作", + twoFactorFailed: "两步验证失败,请检查验证码或重新登录", + recoverySuccess: "恢复登录成功,正在跳转...", + recoveryFailed: "恢复登录失败", }, submit: { login: "登录", @@ -80,6 +103,10 @@ const auth = { loggingIn: "登录中...", registering: "注册中...", resetting: "重置中...", + verify: "验证并登录", + verifying: "验证中...", + recovery: "恢复登录", + recovering: "恢复中...", }, sendCode: { send: "获取验证码", @@ -103,6 +130,10 @@ const auth = { haveAccount: "已有账号?", loginNow: "立即登录", rememberPassword: "想起密码了?", + recoveryPrompt: "无法使用密码或通行密钥?", + emailRecovery: "邮箱恢复", + useRecoveryCode: "使用恢复码", + useTotpCode: "使用动态验证码", }, } as const; diff --git a/src/i18n/locales/zh-CN/user/Security.ts b/src/i18n/locales/zh-CN/user/Security.ts index 2f36f3b..1923c31 100644 --- a/src/i18n/locales/zh-CN/user/Security.ts +++ b/src/i18n/locales/zh-CN/user/Security.ts @@ -25,6 +25,36 @@ const section = { revoke: "下线", }, unknownDevice: "未知设备", + passkeys: { + title: "通行密钥(Passkey)", + desc: "使用指纹、面容或设备 PIN 免密码登录,比密码更安全,也不受钓鱼攻击影响。", + register: "注册通行密钥", + loading: "正在加载通行密钥...", + errorTitle: "通行密钥加载失败", + reload: "重新加载", + empty: "还没有注册通行密钥", + fetchFailed: "通行密钥获取失败", + unsupported: "当前浏览器或环境不支持通行密钥,请使用 HTTPS 或 localhost 访问", + registerSuccess: "通行密钥注册成功", + registerFailed: "通行密钥注册失败", + cancelled: "已取消通行密钥操作", + deleteTitle: "删除通行密钥", + deletePrefix: '确定要删除 "', + deleteSuffix: '" 吗?删除后该设备将无法用它登录。', + delete: "删除", + cancel: "取消", + deleteSuccess: "通行密钥已删除", + deleteFailed: "通行密钥删除失败", + deleting: "删除中...", + unnamed: "未命名通行密钥", + synced: "已同步", + createdAt: "创建于 {{time}}", + lastUsedAt: "最近使用 {{time}}", + neverUsed: "从未使用", + modalTitle: "注册通行密钥", + modalHint: "为这台设备设置一个容易辨认的名称,例如“MacBook Touch ID”。", + namePlaceholder: "通行密钥名称(可选)", + }, revoke: { title: "下线设备", contentPrefix: '确定要将 "', diff --git a/src/infra/Http.test.ts b/src/infra/Http.test.ts index 69d09e9..4485e03 100644 --- a/src/infra/Http.test.ts +++ b/src/infra/Http.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, beforeEach } from "vitest"; import { HttpClient, HttpError, setBusinessErrorHandler } from "./Http.ts"; -import { tokenManager } from "../auth/TokenManager.ts"; it("postForm preserves falsy values, encodes special text and omits only nullish fields", async () => { let sent = ""; @@ -45,12 +44,10 @@ const unauthorizedAdapter: import("axios").AxiosRequestConfig["adapter"] = async describe("HTTP 未授权(1101)处理", () => { beforeEach(() => { - tokenManager.clearToken(); setBusinessErrorHandler(null); }); it("收到 1101 时抛出 HttpError", async () => { - tokenManager.setToken("tok-123"); const client = new HttpClient({ baseURL: "http://test.local" }); await expect(client.get("/x", { adapter: unauthorizedAdapter })).rejects.toMatchObject({ errno: 1101, @@ -95,41 +92,33 @@ describe("HTTP 未授权(1101)处理", () => { notified += 1; } }); - tokenManager.setToken("tok-123"); const client = new HttpClient({ baseURL: "http://test.local" }); await expect(client.get("/x", { adapter: unauthorizedAdapter })).rejects.toBeInstanceOf(HttpError); expect(notified).toBe(1); }); - it("请求拦截器为已登录请求附加 Bearer token", async () => { - tokenManager.setToken("tok-456"); - let captured: import("axios").AxiosRequestConfig | undefined; - const client = new HttpClient({ baseURL: "http://test.local" }); - await client.get("/ok", { - adapter: async (config) => { - captured = config; - return { - data: { errno: 0, success: true, data: { ok: true } }, - status: 200, - statusText: "OK", - headers: {}, - config, - }; - }, + it("启动探测可跳过 1101 全局处理", async () => { + let notified = 0; + setBusinessErrorHandler((errno) => { + if (errno === 1101) { + notified += 1; + } }); - expect(captured?.headers?.Authorization).toBe("Bearer tok-456"); + const client = new HttpClient({ baseURL: "http://test.local" }); + await expect(client.get("/user/info", { adapter: unauthorizedAdapter, skipSessionInvalidation: true })).rejects.toBeInstanceOf( + HttpError, + ); + expect(notified).toBe(0); }); - it("不覆盖调用方显式提供的 Authorization", async () => { - tokenManager.setToken("tok-456"); + it("请求不再附加 Authorization,认证依赖 Cookie", async () => { let captured: import("axios").AxiosRequestConfig | undefined; const client = new HttpClient({ baseURL: "http://test.local" }); await client.get("/ok", { - headers: { Authorization: "Basic explicit" }, adapter: async (config) => { captured = config; return { - data: { errno: 0, success: true, data: null }, + data: { errno: 0, success: true, data: { ok: true } }, status: 200, statusText: "OK", headers: {}, @@ -137,6 +126,7 @@ describe("HTTP 未授权(1101)处理", () => { }; }, }); - expect(captured?.headers?.Authorization).toBe("Basic explicit"); + expect(captured?.headers?.Authorization).toBeUndefined(); + expect(captured?.withCredentials).toBe(true); }); }); diff --git a/src/infra/Http.ts b/src/infra/Http.ts index ba9d037..b65b456 100644 --- a/src/infra/Http.ts +++ b/src/infra/Http.ts @@ -1,7 +1,6 @@ import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, AxiosError } from "axios"; import i18n from "@/i18n"; import { getErrorMsg } from "../utils/ErrorCodes.ts"; -import { tokenManager } from "../auth/TokenManager.ts"; /** * HTTP 请求响应接口 @@ -35,6 +34,11 @@ export interface HttpRequestConfig extends AxiosRequestConfig { loading?: boolean; showError?: boolean; timeout?: number; + /** + * 跳过全局 1101 会话失效处理。 + * 仅用于启动时探测 /user/info:匿名用户不应被当成会话过期而跳转登录页。 + */ + skipSessionInvalidation?: boolean; } /** @@ -107,7 +111,6 @@ class HttpClient { constructor(config: { baseURL?: string; timeout?: number } = {}) { const useProxy = import.meta.env.VITE_USE_PROXY === "true"; - const requireCredentials = import.meta.env.VITE_REQUIRE_CREDENTIALS === "true"; this.baseURL = config.baseURL || (useProxy ? "/api/v1" : `${import.meta.env.VITE_API_BASE_URL}`); this.timeout = config.timeout || 10000; @@ -115,7 +118,8 @@ class HttpClient { this.instance = axios.create({ baseURL: this.baseURL, timeout: this.timeout, - withCredentials: requireCredentials, + // 认证完全依赖 HttpOnly SESSIONID Cookie;跨域直连时也必须携带凭据。 + withCredentials: true, headers: { "Content-Type": "application/json", }, @@ -125,19 +129,6 @@ class HttpClient { } private setupInterceptors(): void { - this.instance.interceptors.request.use( - (config) => { - const token = tokenManager.getToken(); - if (token && config.headers && !config.headers.Authorization) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; - }, - (error) => { - return Promise.reject(error); - }, - ); - this.instance.interceptors.response.use( (response: AxiosResponse) => { const { data } = response; @@ -151,7 +142,8 @@ class HttpClient { if (failed) { const fallbackMessage = getResponseMessage(data) || i18n.t("services:http.requestFailed"); const mappedMessage = errno === undefined ? fallbackMessage : getErrorMsg(errno, fallbackMessage); - if (errno !== undefined) { + const requestConfig = response.config as HttpRequestConfig; + if (errno !== undefined && !requestConfig.skipSessionInvalidation) { businessErrorHandler?.(errno, data); } throw new HttpError(mappedMessage, response.status, response.config as HttpRequestConfig, { @@ -195,7 +187,10 @@ class HttpClient { if (responseErrno !== undefined) { message = getErrorMsg(responseErrno, fallbackMsg); - businessErrorHandler?.(responseErrno, data); + const requestConfig = error.config as HttpRequestConfig | undefined; + if (!requestConfig?.skipSessionInvalidation) { + businessErrorHandler?.(responseErrno, data); + } } else { message = fallbackMsg || i18n.t("services:http.requestFailedStatus", { status }); } diff --git a/src/infra/Websocket.test.ts b/src/infra/Websocket.test.ts index 33061f9..38d516b 100644 --- a/src/infra/Websocket.test.ts +++ b/src/infra/Websocket.test.ts @@ -1,17 +1,11 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { WebSocketClient } from "./Websocket.ts"; -import { tokenManager } from "../auth/TokenManager.ts"; /** - * token 脱敏回归测试(R0 安全项)。 + * WebSocket 建连回归测试。 * - * 背景:此前 `connect()` 会把含完整 token 的建连 URL 直接打进 console - * (旁边那个 `token: "***"` 只是装饰,真正泄露的是 URL 本身)。 - * 这里锁死两件事: - * 1. 传输方式不变——URL 里**仍要**带 token,因为后端鉴权依赖该 query 参数; - * 2. 日志/控制台里**绝不能**出现 token 与 token_time 的原文。 - * 两者必须同时成立,任何只满足其一的改法(比如为了脱敏把 token 从 URL 里摘掉) - * 都会让这里变红。 + * 新认证体系使用 HttpOnly SESSIONID Cookie,浏览器在 WebSocket 握手时自动携带。 + * URL 只保留 uid(用于后端 onClose 兜底),不能再出现 token/token_time。 */ /** 一个只记录入参、不真正建连的 WebSocket 替身 */ @@ -42,8 +36,6 @@ class MockWebSocket { } } -const TOKEN = "S_TOKEN_VALUE_SUPER_SECRET_9f3a"; -const TOKEN_TIME = "1756000000000"; /** 收集一次 connect 期间 console 的全部输出文本 */ function captureConsole(client: WebSocketClient, uid: string | number | undefined): string[] { const lines: string[] = []; @@ -62,76 +54,53 @@ function captureConsole(client: WebSocketClient, uid: string | number | undefine return lines; } -describe("WebSocket 建连 token 处理", () => { +describe("WebSocket Cookie 建连", () => { beforeEach(() => { MockWebSocket.instances = []; - tokenManager.clearToken(); (globalThis as { WebSocket?: unknown }).WebSocket = MockWebSocket; - // jsdom 下直接写 document.cookie 即可,过期时间设到未来避免被丢弃 - document.cookie = `S_TOKEN=${TOKEN}; path=/`; - document.cookie = `S_TOKEN_TIME=${TOKEN_TIME}; path=/`; - tokenManager.setToken(TOKEN); }); afterEach(() => { vi.restoreAllMocks(); }); - it("建连 URL 仍携带 token(后端鉴权依赖该 query 参数,传输方式未变)", () => { + it("建连 URL 只携带 uid,不再携带 token/token_time", () => { const client = new WebSocketClient("/ws/v1/notification"); captureConsole(client, 42); expect(MockWebSocket.instances).toHaveLength(1); - const url = MockWebSocket.instances[0].url; - expect(url).toContain(`token=${TOKEN}`); - expect(url).toContain(`token_time=${TOKEN_TIME}`); - expect(url).toContain("uid=42"); + const url = new URL(MockWebSocket.instances[0].url); + expect(url.searchParams.get("uid")).toBe("42"); + expect(url.searchParams.has("token")).toBe(false); + expect(url.searchParams.has("token_time")).toBe(false); }); - it("控制台输出绝不出现 token / token_time 原文", () => { + it("没有内存 token 也可以建连(认证依赖 Cookie)", () => { const client = new WebSocketClient("/ws/v1/notification"); - const lines = captureConsole(client, 42); - - expect(lines.length).toBeGreaterThan(0); - const joined = lines.join("\n"); - expect(joined).not.toContain(TOKEN); - expect(joined).not.toContain(TOKEN_TIME); - // 脱敏占位必须真的出现,防止「干脆不打印」也算通过 - expect(joined).toContain("***"); + expect(client.connect(7)).toBe(true); + expect(MockWebSocket.instances).toHaveLength(1); }); - it("脱敏不误伤 uid:uid 仍完整出现在日志里", () => { + it("日志包含 uid 和 Cookie 认证标记,且不出现 token", () => { const client = new WebSocketClient("/ws/v1/notification"); const lines = captureConsole(client, 42); - expect(lines.join("\n")).toContain("42"); - }); - - it("内存中没有 token 时拒绝建连,即使 Cookie 中存在 token", () => { - tokenManager.clearToken(); - const client = new WebSocketClient("/ws/v1/notification"); - const lines = captureConsole(client, 7); + const joined = lines.join("\n"); - expect(MockWebSocket.instances).toHaveLength(0); - expect(client.connect(7)).toBe(false); - expect(lines).toHaveLength(0); - expect(lines.join("\n")).not.toContain("***"); + expect(joined).toContain("42"); + expect(joined).toContain("cookie"); + expect(joined).not.toContain("token="); }); - it("Cookie 值含 = 时不截断(base64 填充 / JWT 分段场景)", () => { - // 含两个 "=" 的 token:split("=") 会把值切在第一处,只剩 "PADDED_" - const padded = "PADDED_BASE64_TOKEN_=="; - tokenManager.setToken(padded); - + it("重复 connect 不会重复建连", () => { const client = new WebSocketClient("/ws/v1/notification"); - captureConsole(client, 9); + captureConsole(client, 1); + const second = client.connect(1); - const url = MockWebSocket.instances[0].url; - // 必须带上完整值(URLSearchParams 会转义,故用解码后的参数断言) - const token = new URL(url).searchParams.get("token"); - expect(token).toBe(padded); + expect(second).toBe(true); + expect(MockWebSocket.instances).toHaveLength(1); }); - it("重连不会绕过脱敏:每次 connect 都重新脱敏", () => { + it("重连仍只携带 uid", () => { const client = new WebSocketClient("/ws/v1/notification"); captureConsole(client, 1); // 手动置为 CLOSED,绕开「已连接/连接中就不重复建连」的短路 @@ -139,8 +108,10 @@ describe("WebSocket 建连 token 处理", () => { const second = captureConsole(client, 1); expect(MockWebSocket.instances).toHaveLength(2); - expect(second.join("\n")).not.toContain(TOKEN); - expect(second.join("\n")).toContain("***"); + const url = new URL(MockWebSocket.instances[1].url); + expect(url.searchParams.get("uid")).toBe("1"); + expect(url.searchParams.get("token")).toBeNull(); + expect(second.join("\n")).not.toContain("token="); }); it("旧连接异步关闭时不会为新连接调度重连", () => { diff --git a/src/infra/Websocket.ts b/src/infra/Websocket.ts index 76b64d9..16a1af7 100644 --- a/src/infra/Websocket.ts +++ b/src/infra/Websocket.ts @@ -1,6 +1,4 @@ import { getErrorMsg } from "../utils/ErrorCodes.ts"; -import { tokenManager } from "../auth/TokenManager.ts"; -import { getCookie } from "../auth/CookieHelper.ts"; type MessageHandler = (data: unknown) => void; type EventHandler = (event?: Event) => void; @@ -96,34 +94,20 @@ export class WebSocketClient { return true; } - const token = tokenManager.getToken(); - if (!token) { - return false; - } this.intentionalClose = false; this.clearReconnectTimer(); const generation = ++this.connectionGeneration; + // 认证完全依赖同源 HttpOnly SESSIONID Cookie。 + // uid 仅用于日志和后端 onClose 兜底,不参与鉴权。 const params = new URLSearchParams(); - const tokenTime = getCookie("S_TOKEN_TIME"); if (this.uid !== undefined && this.uid !== null && this.uid !== "") { params.set("uid", String(this.uid)); } - params.set("token", token); - if (tokenTime) { - params.set("token_time", tokenTime); - } - const connectUrl = `${this.basePath}?${params.toString()}`; - - const logParams = new URLSearchParams(params); - if (logParams.has("token")) { - logParams.set("token", "***"); - } - if (logParams.has("token_time")) { - logParams.set("token_time", "***"); - } - console.log("[WS] 正在连接:", `${this.basePath}?${logParams.toString()}`, { uid: this.uid }); + const query = params.toString(); + const connectUrl = query ? `${this.basePath}?${query}` : this.basePath; + console.log("[WS] 正在连接:", connectUrl, { uid: this.uid, auth: "cookie" }); const socket = new WebSocket(connectUrl); this.ws = socket; diff --git a/src/pages/auth/AuthPage.tsx b/src/pages/auth/AuthPage.tsx index c90cce4..1a9fa7f 100644 --- a/src/pages/auth/AuthPage.tsx +++ b/src/pages/auth/AuthPage.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; -import { Eye, EyeOff, Mail, User, Lock, ArrowLeft, Check, AlertCircle } from "lucide-react"; +import { Eye, EyeOff, Mail, User, Lock, ArrowLeft, Check, AlertCircle, Fingerprint, KeyRound } from "lucide-react"; import { useNavigate, useLocation } from "react-router-dom"; import styles from "./AuthPage.module.css"; // 导入 CSS Modules import Button from "@/components/button/Button"; @@ -9,9 +9,10 @@ import Footer from "@/components/footer/Footer"; import { useAuth } from "@/contexts/AuthContext"; import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { AuthService } from "@/services/AuthService.ts"; +import { PasskeyService } from "@/services/PasskeyService.ts"; // 定义表单类型 -type FormType = "login" | "register" | "forgotPassword"; +type FormType = "login" | "register" | "forgotPassword" | "recovery"; // 规整登录后回跳地址:仅允许站内相对路径,且不把登录页自身当作目标(避免 /auth?redirect=... 嵌套) const resolveRedirect = (raw: string | null): string => { @@ -100,6 +101,18 @@ const validateForm = (type: FormType, formData: any, t: TFunction<"auth">) => { } else if (formData.newPassword !== formData.confirmNewPassword) { errors.confirmNewPassword = t("validation.passwordMismatch"); } + } else if (type === "recovery") { + if (!formData.email) { + errors.email = t("validation.emailRequired"); + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + errors.email = t("validation.emailInvalid"); + } + + if (!formData.auth_code) { + errors.auth_code = t("validation.codeRequired"); + } else if (formData.auth_code.length < 4) { + errors.auth_code = t("validation.codeMin"); + } } return errors; @@ -107,11 +120,12 @@ const validateForm = (type: FormType, formData: any, t: TFunction<"auth">) => { const AuthPage: React.FC = () => { // 获取认证状态 - const { login } = useAuth(); + const { login, verifyTwoFactor, verifyTwoFactorRecovery, loginWithPasskey, recoverWithEmail, isAuthenticated } = useAuth(); const { settings } = useSiteSettings(); const { t } = useTranslation("auth"); const navigate = useNavigate(); const location = useLocation(); + const passkeySupported = PasskeyService.isSupported(); // 状态管理 const [formType, setFormType] = useState("login"); @@ -133,6 +147,8 @@ const AuthPage: React.FC = () => { const [showNewPassword, setShowNewPassword] = useState(false); const [showConfirmNewPassword, setShowConfirmNewPassword] = useState(false); const [countdown, setCountdown] = useState(0); + const [twoFactor, setTwoFactor] = useState<{ tempToken: string; useRecoveryCode: boolean } | null>(null); + const [twoFactorInput, setTwoFactorInput] = useState(""); const [message, setMessage] = useState<{ text: string; type: "success" | "error"; @@ -157,6 +173,21 @@ const AuthPage: React.FC = () => { navigate(`${location.pathname}${query ? `?${query}` : ""}`, { replace: true }); }, [location.pathname, location.search, navigate, t]); + // GitHub 回调成功后后端已设置 SESSIONID,AuthContext 会恢复用户;落在 /auth 时补一次跳转。 + useEffect(() => { + const params = new URLSearchParams(location.search); + if (params.get("oauth") !== "1" || !isAuthenticated) { + return; + } + navigate(resolveRedirect(params.get("redirect")), { replace: true }); + }, [isAuthenticated, location.search, navigate]); + + const goAfterLogin = () => { + const params = new URLSearchParams(location.search); + const redirect = resolveRedirect(params.get("redirect")); + navigate(redirect); + }; + // 处理输入变化 const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -197,6 +228,8 @@ const AuthPage: React.FC = () => { await AuthService.sendRegisterCode(email); } else if (formType === "forgotPassword") { await AuthService.sendPasswordResetCode(email); + } else if (formType === "recovery") { + await AuthService.sendRecoveryCode(email); } else { throw new Error(t("message.unsupportedCodeType")); } @@ -238,13 +271,14 @@ const AuthPage: React.FC = () => { try { if (formType === "login") { - await login(formData.usernameOrEmail, formData.password); + const result = await login(formData.usernameOrEmail, formData.password); + if (result.requires2fa) { + setTwoFactor({ tempToken: result.tempToken ?? "", useRecoveryCode: false }); + setTwoFactorInput(""); + return; + } setMessage({ text: t("message.loginSuccess"), type: "success" }); - const params = new URLSearchParams(location.search); - const redirect = resolveRedirect(params.get("redirect")); - setTimeout(() => { - navigate(redirect); - }, 200); + setTimeout(goAfterLogin, 200); } else if (formType === "register") { await AuthService.register({ account: formData.account, @@ -258,6 +292,10 @@ const AuthPage: React.FC = () => { await AuthService.forgetPassword(formData.email, formData.newPassword, formData.code); setMessage({ text: t("message.resetSuccess"), type: "success" }); setTimeout(() => setFormType("login"), 200); + } else if (formType === "recovery") { + await recoverWithEmail(formData.email, formData.auth_code); + setMessage({ text: t("message.recoverySuccess"), type: "success" }); + setTimeout(goAfterLogin, 200); } } catch (error: any) { setMessage({ @@ -268,7 +306,9 @@ const AuthPage: React.FC = () => { ? t("message.loginFailed") : formType === "register" ? t("message.registerFailed") - : t("message.resetFailed")), + : formType === "recovery" + ? t("message.recoveryFailed") + : t("message.resetFailed")), type: "error", }); } finally { @@ -283,11 +323,75 @@ const AuthPage: React.FC = () => { window.location.href = AuthService.githubLoginUrl(redirect); }; + const handlePasskeyLogin = async () => { + setIsSubmitting(true); + setMessage(null); + try { + await loginWithPasskey(formData.usernameOrEmail.trim() || undefined); + setMessage({ text: t("message.loginSuccess"), type: "success" }); + setTimeout(goAfterLogin, 200); + } catch (error: any) { + setMessage({ + text: + error?.name === "NotAllowedError" + ? t("message.passkeyCancelled") + : error.msg || error.message || t("message.passkeyLoginFailed"), + type: "error", + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleTwoFactorSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!twoFactor) { + return; + } + if (!twoFactorInput.trim()) { + setMessage({ + text: twoFactor.useRecoveryCode ? t("validation.recoveryCodeRequired") : t("validation.twoFactorCodeRequired"), + type: "error", + }); + return; + } + + setIsSubmitting(true); + setMessage(null); + try { + if (twoFactor.useRecoveryCode) { + await verifyTwoFactorRecovery(twoFactor.tempToken, twoFactorInput.trim()); + } else { + await verifyTwoFactor(twoFactor.tempToken, twoFactorInput.trim()); + } + setMessage({ text: t("message.loginSuccess"), type: "success" }); + setTimeout(goAfterLogin, 200); + } catch (error: any) { + setMessage({ + text: error.msg || error.message || t("message.twoFactorFailed"), + type: "error", + }); + // 后端在第一次校验时会消费掉 temp_token,失败后必须重新走密码登录。 + setTwoFactor(null); + setTwoFactorInput(""); + } finally { + setIsSubmitting(false); + } + }; + + const backToPasswordLogin = () => { + setTwoFactor(null); + setTwoFactorInput(""); + setMessage(null); + }; + // 切换表单视图 const switchForm = (type: FormType) => { setFormType(type); setErrors({}); setMessage(null); + setTwoFactor(null); + setTwoFactorInput(""); // 重置相关表单字段 setFormData((prev) => ({ ...prev, @@ -310,6 +414,8 @@ const AuthPage: React.FC = () => { return t("title.register"); case "forgotPassword": return t("title.forgotPassword"); + case "recovery": + return t("title.recovery"); default: return ""; } @@ -324,11 +430,93 @@ const AuthPage: React.FC = () => { return t("description.register"); case "forgotPassword": return t("description.forgotPassword"); + case "recovery": + return t("description.recovery"); default: return ""; } }; + if (twoFactor) { + return ( +
+
+
+
+
+ + +
+

{t("title.twoFactor")}

+

{t("description.twoFactor")}

+
+ + {message && ( +
+ {message.type === "success" ? : } + {message.text} +
+ )} + +
+
+ +
+ + setTwoFactorInput(event.target.value)} + className={styles.input} + placeholder={ + twoFactor.useRecoveryCode + ? t("placeholders.recoveryCode") + : t("placeholders.twoFactorCode") + } + autoComplete="one-time-code" + autoFocus + /> +
+

+ {twoFactor.useRecoveryCode ? t("hints.recoveryCode") : t("hints.twoFactorCode")} +

+
+ + + +
+ +
+
+
+
+
+
+
+ ); + } + return (
@@ -422,6 +610,20 @@ const AuthPage: React.FC = () => {

)}
+ + + {!passkeySupported &&

{t("message.passkeyUnsupported")}

} )} @@ -699,6 +901,69 @@ const AuthPage: React.FC = () => { )} + {/* 邮箱恢复登录字段 */} + {formType === "recovery" && ( + <> +
+ +
+ + +
+ {errors.email && ( +

+ + {errors.email} +

+ )} +
+ +
+ +
+ + + +
+ {errors.auth_code && ( +

+ + {errors.auth_code} +

+ )} +

{t("hints.recoveryWarning")}

+
+ + )} + {/* 提交按钮 */}
) : formType === "login" ? ( t("submit.login") ) : formType === "register" ? ( t("submit.register") + ) : formType === "recovery" ? ( + t("submit.recovery") ) : ( t("submit.resetPassword") )} @@ -769,6 +1038,17 @@ const AuthPage: React.FC = () => { )} +
+ + {t("links.recoveryPrompt")} + + ) : formType === "register" ? (
diff --git a/src/pages/product/MockProducts.ts b/src/pages/product/MockProducts.ts new file mode 100644 index 0000000..b82a937 --- /dev/null +++ b/src/pages/product/MockProducts.ts @@ -0,0 +1,93 @@ +export interface ProductPackage { + id: string; + name: string; + description: string; + category: string; + techStack: string[]; + version: string; + link: string; + users: number; +} + +export const MOCK_PRODUCTS: ProductPackage[] = [ + { + id: "1", + name: "TechHaven CLI", + description: "面向技术团队的博客与研发平台命令行工具,支持文章发布、工单管理与 CI 集成。", + category: "工具", + techStack: ["Node.js", "TypeScript", "commander"], + version: "v2.4.1", + link: "https://github.com/techhaven/cli", + users: 1284, + }, + { + id: "2", + name: "Token 认证 SDK", + description: "内存态 Token + HttpOnly Cookie 的前端认证套件,杜绝敏感数据落地浏览器存储。", + category: "SDK", + techStack: ["TypeScript", "axios", "Cookie"], + version: "v1.8.0", + link: "https://github.com/techhaven/auth-sdk", + users: 867, + }, + { + id: "3", + name: "实时通知 WebSocket 客户端", + description: "单例连接、指数退避重连与心跳保活的实时通知客户端,绑定登录生命周期。", + category: "SDK", + techStack: ["WebSocket", "TypeScript"], + version: "v1.2.3", + link: "https://github.com/techhaven/ws-client", + users: 542, + }, + { + id: "4", + name: "分片上传组件", + description: "支持断点续传与并发控制的大文件分片上传 React 组件,可无缝嵌入任意表单。", + category: "插件", + techStack: ["React", "TypeScript", "CSS Modules"], + version: "v0.9.7", + link: "https://github.com/techhaven/chunk-upload", + users: 319, + }, + { + id: "5", + name: "研发燃尽图可视化", + description: "基于 ECharts 的 Sprint 燃尽图与趋势分析面板,为研发平台提供数据洞察。", + category: "工具", + techStack: ["React", "ECharts"], + version: "v0.6.0-beta", + link: "https://github.com/techhaven/burndown", + users: 176, + }, + { + id: "6", + name: "AI 摘要流式渲染", + description: "基于 SSE 的文章 AI 摘要组件,支持流式输出与 markdown 安全渲染。", + category: "插件", + techStack: ["React", "SSE", "rehype-sanitize"], + version: "v0.4.2", + link: "https://github.com/techhaven/ai-summary", + users: 98, + }, + { + id: "7", + name: "组织协作模块", + description: "成员、角色、权限与申请流程的完整前端组织协作解决方案。", + category: "平台", + techStack: ["React", "TypeScript"], + version: "v1.0.0", + link: "https://github.com/techhaven/org-collab", + users: 402, + }, + { + id: "8", + name: "明暗主题引擎", + description: "基于 CSS 自定义属性与 data-theme 的主题切换引擎,支持运行时无闪烁切换。", + category: "工具", + techStack: ["CSS", "TypeScript"], + version: "v1.1.0", + link: "https://github.com/techhaven/theme-engine", + users: 655, + }, +]; diff --git a/src/pages/product/ProductList.module.css b/src/pages/product/ProductList.module.css new file mode 100644 index 0000000..27261c9 --- /dev/null +++ b/src/pages/product/ProductList.module.css @@ -0,0 +1,238 @@ +.mainContent { + flex: 1; + width: 100%; + max-width: var(--page-max-width); + margin: 0 auto; + padding: 1rem 1.8rem; + box-sizing: border-box; +} + +/* 胶囊布局:内容用统一的 mainContent 外框卡片 */ +:global([data-layout-mode="appShell"]) .mainContent { + min-height: 0; + overflow-y: auto; + scrollbar-width: none; + width: calc(100% - 2rem); + margin: 0 auto 0.5rem; + padding: 1.5rem; + background-color: var(--card-bg); + border: 1px solid var(--border-primary); + border-radius: 20px; + box-shadow: var(--shadow-md); +} + +:global([data-layout-mode="appShell"]) .mainContent::-webkit-scrollbar { + display: none; +} + +.pageHeader { + margin-bottom: 2rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.pageTitle { + font-size: 1.8rem; + color: var(--text-primary); + font-weight: 600; + margin: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.pageSubtitle { + color: var(--text-secondary); + font-size: 0.95rem; + margin: 0; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; + list-style: none; + padding: 0; + margin: 0; +} + +.card { + position: relative; + background: var(--bg-card); + border-radius: 16px; + padding: 1.5rem; + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + border: 1px solid var(--border-primary); + overflow: hidden; +} + +.card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + opacity: 0; +} + +.card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); + border-color: var(--primary); +} + +.card:hover::before { + opacity: 1; +} + +.cardHeader { + display: flex; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; +} + +.cardIcon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + color: #fff; + flex-shrink: 0; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25); +} + +.gradient0 { + background: linear-gradient(135deg, #3b82f6, #8b5cf6); +} + +.gradient1 { + background: linear-gradient(135deg, #f59e0b, #ef4444); + box-shadow: 0 4px 12px rgba(245, 158, 11, 0.25); +} + +.gradient2 { + background: linear-gradient(135deg, #10b981, #0ea5e9); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.25); +} + +.gradient3 { + background: linear-gradient(135deg, #ec4899, #8b5cf6); + box-shadow: 0 4px 12px rgba(236, 72, 153, 0.25); +} + +.cardTitleRow { + display: flex; + align-items: flex-start; + gap: 0.5rem; + flex: 1; + min-width: 0; +} + +.cardTitle { + flex: 1; + min-width: 0; + font-size: 1.15rem; + color: var(--text-primary); + margin: 0; + font-weight: 600; + line-height: 1.35; +} + +.versionBadge { + flex-shrink: 0; + font-size: 0.72rem; + color: var(--text-tertiary); + background: var(--bg-secondary); + padding: 2px 8px; + border-radius: 999px; + font-family: "SFMono-Regular", Consolas, monospace; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.cardDesc { + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.6; + margin-bottom: 1rem; + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.cardFooter { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 1rem; + border-top: 1px dashed var(--border-primary); + margin-top: auto; +} + +.cardMetric { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: var(--text-tertiary); + font-size: 0.85rem; +} + +.actionLink { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0.5rem 1.2rem; + border-radius: 999px; + font-weight: 500; + font-size: 0.88rem; + text-decoration: none; + background: linear-gradient(135deg, #3b82f6, #6366f1); + color: #fff; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); +} + +.actionLink:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4); +} + +@media (max-width: 768px) { + .mainContent { + padding: 1.2rem 1rem; + } + + .pageTitle { + font-size: 1.4rem; + } + + .grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .card { + padding: 1.2rem; + } +} + +@media (max-width: 480px) { + .mainContent { + padding: 1rem 0.8rem; + } + + .card { + padding: 1rem; + } + + .cardTitle { + font-size: 1.05rem; + } +} diff --git a/src/pages/product/ProductList.tsx b/src/pages/product/ProductList.tsx new file mode 100644 index 0000000..9d5768b --- /dev/null +++ b/src/pages/product/ProductList.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { FaCube, FaCode, FaDownload, FaUserFriends, FaWrench, FaPuzzlePiece, FaLayerGroup } from "react-icons/fa"; +import styles from "./ProductList.module.css"; +import { MOCK_PRODUCTS } from "./MockProducts"; + +const CATEGORY_ICONS: Record = { + 工具: , + SDK: , + 插件: , + 平台: , +}; + +const GRADIENTS = [styles.gradient0, styles.gradient1, styles.gradient2, styles.gradient3]; + +const ProductList: React.FC = () => { + return ( +
+
+

+ 产品包 +

+

我们打造的一系列软件产品与工具集

+
+ +
+ {MOCK_PRODUCTS.map((product, index) => ( +
+
+
+ {CATEGORY_ICONS[product.category] || } +
+
+

{product.name}

+ {product.version} +
+
+ +

{product.description}

+ +
+ + {product.users} 人使用 + + + 下载 + +
+
+ ))} +
+
+ ); +}; + +export default ProductList; diff --git a/src/pages/user/AccountSecurity.module.css b/src/pages/user/AccountSecurity.module.css index 8241a73..35b3abf 100644 --- a/src/pages/user/AccountSecurity.module.css +++ b/src/pages/user/AccountSecurity.module.css @@ -1,3 +1,56 @@ +.cardHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.cardHeader button { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.passkeyList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.passkeyItem { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + border: 1px solid var(--border-primary); + border-radius: 10px; + background: var(--bg-secondary); +} + +.passkeyIcon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 10px; + background: var(--card-bg); + border: 1px solid var(--border-primary); + color: var(--primary); + font-size: 18px; + flex-shrink: 0; +} + +.passkeyInfo { + flex: 1; + min-width: 0; +} + +.passkeyNameInput { + width: 100%; +} + .deviceList { display: flex; flex-direction: column; @@ -83,3 +136,29 @@ color: var(--text-tertiary); border-color: var(--border-primary); } + +@media (max-width: 640px) { + .cardHeader { + flex-direction: column; + } + + .cardHeader button { + width: 100%; + justify-content: center; + } + + .passkeyItem, + .deviceItem { + align-items: flex-start; + flex-wrap: wrap; + } + + .passkeyInfo, + .deviceInfo { + flex: 1 1 calc(100% - 54px); + } + + .revokeBtn { + margin-left: 54px; + } +} diff --git a/src/pages/user/SecurityTab.tsx b/src/pages/user/SecurityTab.tsx index fb32cf8..7925ab6 100644 --- a/src/pages/user/SecurityTab.tsx +++ b/src/pages/user/SecurityTab.tsx @@ -1,27 +1,74 @@ import React, { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { FaShieldAlt, FaDesktop, FaMobileAlt, FaSignOutAlt } from "react-icons/fa"; +import { FaShieldAlt, FaDesktop, FaMobileAlt, FaSignOutAlt, FaFingerprint, FaPlus, FaTrashAlt } from "react-icons/fa"; import styles from "./UserPage.module.css"; import secStyles from "./AccountSecurity.module.css"; import Switch from "@/components/switch/Switch"; +import Button from "@/components/button/Button"; +import Input from "@/components/input/Input"; +import Modal from "@/components/modal/Modal"; import message from "@/components/message/Message"; import { confirm } from "@/components/confirm/Confirm"; import Loading from "@/components/loading/Loading"; import ErrorState from "@/components/errorState/ErrorState"; import DeviceService, { type LoginDevice } from "@/services/DeviceService.ts"; +import PasskeyService, { type PasskeySummary } from "@/services/PasskeyService.ts"; import { formatRelativeTime } from "@/utils/Utils.ts"; const isMobilePlatform = (platform: string): boolean => /ios|android|mobile/i.test(platform || ""); +function getAlgorithmLabel(alg: number): string { + if (alg === -7) { + return "ES256"; + } + if (alg === -257) { + return "RS256"; + } + if (alg === -8) { + return "EdDSA"; + } + return `alg ${alg}`; +} + +function getErrorMessage(error: any, fallback: string): string { + if (error?.name === "NotAllowedError") { + return fallback; + } + return error?.msg || error?.message || fallback; +} + const SecurityTab: React.FC = () => { const { t } = useTranslation("user"); const [twoFactor, setTwoFactor] = useState(false); const [loginAlert, setLoginAlert] = useState(true); + + const [passkeys, setPasskeys] = useState([]); + const [passkeysLoading, setPasskeysLoading] = useState(true); + const [passkeysError, setPasskeysError] = useState(null); + const [registerVisible, setRegisterVisible] = useState(false); + const [passkeyName, setPasskeyName] = useState(""); + const [registering, setRegistering] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const passkeySupported = PasskeyService.isSupported(); + const [devices, setDevices] = useState([]); const [devicesLoading, setDevicesLoading] = useState(true); const [devicesError, setDevicesError] = useState(null); const [kickingId, setKickingId] = useState(null); + const loadPasskeys = useCallback(async () => { + setPasskeysLoading(true); + setPasskeysError(null); + try { + const data = await PasskeyService.list(); + setPasskeys(Array.isArray(data.list) ? data.list : []); + } catch (error: any) { + setPasskeysError(error.msg || error.message || t("security.passkeys.fetchFailed")); + } finally { + setPasskeysLoading(false); + } + }, [t]); + const loadDevices = useCallback(async () => { setDevicesLoading(true); setDevicesError(null); @@ -40,8 +87,60 @@ const SecurityTab: React.FC = () => { }, [t]); useEffect(() => { - loadDevices(); - }, [loadDevices]); + void loadPasskeys(); + void loadDevices(); + }, [loadPasskeys, loadDevices]); + + const handleRegisterPasskey = async () => { + if (!passkeySupported) { + message.warn(t("security.passkeys.unsupported")); + return; + } + + setRegistering(true); + try { + await PasskeyService.register(passkeyName.trim() || undefined); + message.success(t("security.passkeys.registerSuccess")); + setRegisterVisible(false); + setPasskeyName(""); + await loadPasskeys(); + } catch (error: any) { + if (error?.name === "NotAllowedError") { + message.info(t("security.passkeys.cancelled")); + } else { + message.error(getErrorMessage(error, t("security.passkeys.registerFailed"))); + } + } finally { + setRegistering(false); + } + }; + + const revokePasskey = (passkey: PasskeySummary) => { + confirm({ + title: t("security.passkeys.deleteTitle"), + content: ( +
+ {t("security.passkeys.deletePrefix")} + {passkey.name || t("security.passkeys.unnamed")} + {t("security.passkeys.deleteSuffix")} +
+ ), + confirmText: t("security.passkeys.delete"), + cancelText: t("security.passkeys.cancel"), + onConfirm: async () => { + setDeletingId(passkey.id); + try { + await PasskeyService.remove(passkey.id); + message.success(t("security.passkeys.deleteSuccess")); + await loadPasskeys(); + } catch (error: any) { + message.error(getErrorMessage(error, t("security.passkeys.deleteFailed"))); + } finally { + setDeletingId(null); + } + }, + }); + }; const revoke = (d: LoginDevice) => { confirm({ @@ -101,6 +200,82 @@ const SecurityTab: React.FC = () => {
)} +
+
+
+

+ {t("security.passkeys.title")} +

+

{t("security.passkeys.desc")}

+
+ +
+ + {!passkeySupported &&

{t("security.passkeys.unsupported")}

} + + {passkeysLoading ? ( + + ) : passkeysError ? ( + + ) : passkeys.length === 0 ? ( +

{t("security.passkeys.empty")}

+ ) : ( +
+ {passkeys.map((passkey) => { + const isDeleting = deletingId === passkey.id; + const meta = [getAlgorithmLabel(passkey.alg), passkey.transports].filter(Boolean).join(" · "); + return ( +
+
+ +
+
+

+ {passkey.name || t("security.passkeys.unnamed")} + {passkey.backup_state ? ( + {t("security.passkeys.synced")} + ) : null} +

+ {meta &&

{meta}

} +

+ {t("security.passkeys.createdAt", { time: formatRelativeTime(passkey.created_at) })} + {" · "} + {t("security.passkeys.lastUsedAt", { + time: passkey.last_used_at + ? formatRelativeTime(passkey.last_used_at) + : t("security.passkeys.neverUsed"), + })} +

+
+ +
+ ); + })} +
+ )} +
+

{t("security.devices.title")} @@ -156,6 +331,42 @@ const SecurityTab: React.FC = () => {

)} + + { + if (!registering) { + setRegisterVisible(false); + } + }} + footer={ + <> + + + + } + > +

{t("security.passkeys.modalHint")}

+ +
); }; diff --git a/src/router/RouterConfig.tsx b/src/router/RouterConfig.tsx index 1f01537..54cccf9 100644 --- a/src/router/RouterConfig.tsx +++ b/src/router/RouterConfig.tsx @@ -43,6 +43,7 @@ import TicketDetail from "../pages/rd-platform/TicketDetail"; import TrendAnalysis from "../pages/rd-platform/TrendAnalysis"; import HelpCenter from "../pages/user/HelpCenter"; import Messages from "../pages/user/Messages"; +import ProductList from "../pages/product/ProductList"; const RouterConfig: React.FC = () => { const { t } = useTranslation("auth"); @@ -87,6 +88,9 @@ const RouterConfig: React.FC = () => { {/* 组织详情页 */} } /> + {/* 产品包列表(仅开发环境可见) */} + {import.meta.env.DEV && } />} + {/* 用户系统:帮助中心 */} } /> diff --git a/src/services/AuthService.ts b/src/services/AuthService.ts index 862bc57..980ac68 100644 --- a/src/services/AuthService.ts +++ b/src/services/AuthService.ts @@ -1,4 +1,4 @@ -import http, { type HttpResponse } from "../infra/Http.ts"; +import http, { type HttpRequestConfig, type HttpResponse } from "../infra/Http.ts"; /** * 登录请求参数 @@ -10,18 +10,13 @@ interface LoginParams { /** * 登录响应数据 + * + * 新认证体系不再返回 token;登录成功由服务端设置 HttpOnly SESSIONID。 + * 启用 TOTP 的账号会先返回 require_2fa/temp_token,需要再调用 /user/2fa/verify。 */ interface LoginResponse { - token?: string; - user?: { - id: number; - name: string; - username: string; - email: string; - avatar: string; - role: string; - created_at: string; - }; + require_2fa?: boolean; + temp_token?: string; } /** @@ -49,7 +44,7 @@ interface ForgetPasswordParams { interface SendCodeParams { email: string; // 邮箱 agent: string; // 用户代理 - type: "1" | "2" | "3" | "4"; // 操作类型:1 注册 2 登录 3 密码重置 4 更换邮箱 + type: "1" | "2" | "3" | "4" | "5"; // 操作类型:1 注册 2 登录 3 密码重置 4 更换邮箱 5 账号恢复 } /** @@ -60,16 +55,11 @@ const CodeType = { LOGIN: "2", // 登录 PASSWORD_RESET: "3", // 密码重置 EMAIL_CHANGE: "4", // 更换邮箱 + RECOVERY: "5", // 账号恢复 } as const; type CodeType = (typeof CodeType)[keyof typeof CodeType]; -const REQUIRE_CREDENTIALS = import.meta.env.VITE_REQUIRE_CREDENTIALS === "true"; - -// 多个调用方同时续期时只允许后端处理一次 token 轮换。 -let refreshTokenPromise: Promise>>> | null = - null; - /** * 用户信息接口 */ @@ -161,14 +151,11 @@ export class AuthService { formData.append("auth_id", params.auth_id); formData.append("passwd", params.passwd); - const response = await http.post("/user/login", formData.toString(), { + return http.post("/user/login", formData.toString(), { headers: { "Content-Type": "application/x-www-form-urlencoded", }, - withCredentials: REQUIRE_CREDENTIALS, }); - - return response; } catch (error) { // 登录失败,抛出错误让调用方处理 throw error; @@ -179,7 +166,7 @@ export class AuthService { * 构建 GitHub OAuth 登录入口地址 * 需要浏览器整页跳转(非 XHR),登录成功后由后端设置 Cookie 并回跳 * @param redirect 登录成功后的站内回跳路径 - * @returns 入口地址(经前端同源代理,保证 S_TOKEN Cookie 落在前端域) + * @returns 入口地址(经前端同源代理,保证 SESSIONID Cookie 落在前端域) */ static githubLoginUrl(redirect?: string): string { const params = new URLSearchParams(); @@ -251,6 +238,15 @@ export class AuthService { return this.sendVerificationCode(email, CodeType.EMAIL_CHANGE); } + /** + * 账号恢复验证码 + * @param email 邮箱地址 + * @returns 发送结果 + */ + static async sendRecoveryCode(email: string) { + return this.sendVerificationCode(email, CodeType.RECOVERY); + } + /** * 用户注册 * @param params 注册参数 @@ -284,40 +280,50 @@ export class AuthService { } /** - * 刷新 token(无感续期) - * @param uid 用户ID(params 优先于 cookie) - * @returns 新 token / token_time,并写入新 cookie + * 完成 TOTP 两步验证 + * @param tempToken 登录接口返回的临时凭证 + * @param code 动态验证码 */ - static async refreshToken(uid: number | string) { - if (refreshTokenPromise) { - return refreshTokenPromise; - } - - const formData = new URLSearchParams(); - formData.append("uid", String(uid)); + static async verify2fa(tempToken: string, code: string) { + return http.postForm("/user/2fa/verify", { + temp_token: tempToken, + code, + }); + } - refreshTokenPromise = http - .post<{ uid: string; token: string; token_time: number }>("/user/refresh_token", formData.toString(), { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - withCredentials: REQUIRE_CREDENTIALS, - }) - .finally(() => { - refreshTokenPromise = null; - }); + /** + * 使用恢复码完成两步验证 + * @param tempToken 登录接口返回的临时凭证 + * @param recoveryCode 一次性恢复码 + */ + static async verify2faRecovery(tempToken: string, recoveryCode: string) { + return http.postForm("/user/2fa/recovery", { + temp_token: tempToken, + recovery_code: recoveryCode, + }); + } - return refreshTokenPromise; + /** + * 邮箱验证码恢复登录 + * @param email 邮箱 + * @param authCode 邮箱验证码 + */ + static async recoverWithEmail(email: string, authCode: string) { + return http.postForm("/auth/email/recovery", { + email, + auth_code: authCode, + }); } /** * 获取用户信息 * @param userId 可选,用户ID。不传则获取当前用户信息 + * @param config 可选请求配置;启动探测时可跳过 1101 全局跳转 * @returns 用户信息 */ - static async getUserInfo(userId?: number | string): Promise> { + static async getUserInfo(userId?: number | string, config?: HttpRequestConfig): Promise> { const url = userId ? `/user/info?user_id=${userId}` : "/user/info"; - return http.get(url, { withCredentials: REQUIRE_CREDENTIALS }); + return http.get(url, config); } /** diff --git a/src/services/PasskeyService.ts b/src/services/PasskeyService.ts new file mode 100644 index 0000000..3edee0c --- /dev/null +++ b/src/services/PasskeyService.ts @@ -0,0 +1,93 @@ +import http from "../infra/Http.ts"; +import i18n from "@/i18n"; +import { credentialToJSON, isWebAuthnSupported, parseCreationOptions, parseRequestOptions } from "../utils/WebAuthn.ts"; + +export interface PasskeySummary { + id: number | string; + name: string; + alg: number; + aaguid: string; + transports: string; + backup_eligible: number; + backup_state: number; + created_at: number; + last_used_at: number; +} + +export interface PasskeyListResponse { + total: number; + list: PasskeySummary[]; +} + +/** + * Passkey / WebAuthn 服务 + * + * begin/delete 使用 form-urlencoded;finish 使用后端要求的 JSON body。 + */ +export class PasskeyService { + static isSupported(): boolean { + return isWebAuthnSupported(); + } + + static async list(): Promise { + const response = await http.post("/auth/passkey/list"); + return response.data ?? { total: 0, list: [] }; + } + + static async remove(id: number | string) { + return http.postForm("/auth/passkey/delete", { id }); + } + + static async register(name?: string) { + if (!isWebAuthnSupported()) { + throw new Error(i18n.t("auth:message.passkeyUnsupported")); + } + + const beginData = new URLSearchParams(); + if (name?.trim()) { + beginData.append("name", name.trim()); + } + + const begin = await http.postForm("/auth/passkey/register/begin", beginData); + if (!begin.data) { + throw new Error(i18n.t("auth:message.passkeyRegisterFailed")); + } + + const publicKey = parseCreationOptions(begin.data); + const credential = (await navigator.credentials.create({ publicKey })) as PublicKeyCredential | null; + if (!credential) { + throw new Error(i18n.t("auth:message.passkeyRegisterFailed")); + } + + return http.post("/auth/passkey/register/finish", { + ...credentialToJSON(credential), + name: name?.trim() || begin.data.name || "Passkey", + }); + } + + static async login(authId?: string) { + if (!isWebAuthnSupported()) { + throw new Error(i18n.t("auth:message.passkeyUnsupported")); + } + + const beginData = new URLSearchParams(); + if (authId?.trim()) { + beginData.append("auth_id", authId.trim()); + } + + const begin = await http.postForm("/auth/passkey/login/begin", beginData); + if (!begin.data) { + throw new Error(i18n.t("auth:message.passkeyLoginFailed")); + } + + const publicKey = parseRequestOptions(begin.data); + const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null; + if (!credential) { + throw new Error(i18n.t("auth:message.passkeyLoginFailed")); + } + + return http.post("/auth/passkey/login/finish", credentialToJSON(credential)); + } +} + +export default PasskeyService; diff --git a/src/utils/ErrorHandlers.ts b/src/utils/ErrorHandlers.ts index 0c66d6c..2bb8a95 100644 --- a/src/utils/ErrorHandlers.ts +++ b/src/utils/ErrorHandlers.ts @@ -5,18 +5,15 @@ * Http.ts 只负责调用 setBusinessErrorHandler 注册的回调。 */ -import { tokenManager, detectInvalidReason, type SessionInvalidReason } from "../auth/TokenManager.ts"; -import { clearAuthCookies } from "../auth/CookieHelper.ts"; +import { sessionEvents, detectInvalidReason, type SessionInvalidReason } from "../auth/SessionEvents.ts"; import { setBusinessErrorHandler } from "../infra/Http.ts"; -// ---------- 1101:未登录(token 过期 / 被顶下线) ---------- +// ---------- 1101:未登录(会话过期 / 被顶下线) ---------- setBusinessErrorHandler((errno, data) => { if (errno === 1101) { const fallback = data?.msg || data?.errstr || ""; const reason: SessionInvalidReason = detectInvalidReason(String(fallback)); - clearAuthCookies(); - tokenManager.clearToken(); - tokenManager.emitSessionInvalidated(reason); + sessionEvents.emitSessionInvalidated(reason); } }); diff --git a/src/utils/WebAuthn.ts b/src/utils/WebAuthn.ts new file mode 100644 index 0000000..eae8e64 --- /dev/null +++ b/src/utils/WebAuthn.ts @@ -0,0 +1,118 @@ +/** + * WebAuthn 浏览器 API 辅助函数 + * + * 后端返回的 challenge / credential id / user.id 都是 base64url 字符串, + * navigator.credentials 需要 ArrayBuffer。优先使用浏览器原生 JSON 转换, + * 不支持时回退到手工转换。 + */ + +function toUint8Array(value: ArrayBuffer | ArrayBufferView): Uint8Array { + if (value instanceof ArrayBuffer) { + return new Uint8Array(value); + } + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); +} + +export function base64urlToBuffer(value: string): ArrayBuffer { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4)); + const binary = window.atob(normalized + padding); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +export function bufferToBase64url(value: ArrayBuffer | ArrayBufferView): string { + const bytes = toUint8Array(value); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return window.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +export function isWebAuthnSupported(): boolean { + return ( + typeof window !== "undefined" && + typeof window.PublicKeyCredential !== "undefined" && + typeof navigator.credentials !== "undefined" && + window.isSecureContext + ); +} + +export function parseCreationOptions(options: any): PublicKeyCredentialCreationOptions { + // 后端在 data 根节点额外返回 name,WebAuthn 选项本身不需要它。 + const jsonOptions = { ...options }; + delete jsonOptions.name; + + const nativeParser = (window.PublicKeyCredential as any)?.parseCreationOptionsFromJSON; + if (typeof nativeParser === "function") { + return nativeParser.call(window.PublicKeyCredential, jsonOptions) as PublicKeyCredentialCreationOptions; + } + + return { + ...jsonOptions, + challenge: base64urlToBuffer(jsonOptions.challenge), + user: { + ...jsonOptions.user, + id: base64urlToBuffer(jsonOptions.user.id), + }, + excludeCredentials: Array.isArray(jsonOptions.excludeCredentials) + ? jsonOptions.excludeCredentials.map((credential: any) => ({ + ...credential, + id: base64urlToBuffer(credential.id), + })) + : undefined, + } as PublicKeyCredentialCreationOptions; +} + +export function parseRequestOptions(options: any): PublicKeyCredentialRequestOptions { + const nativeParser = (window.PublicKeyCredential as any)?.parseRequestOptionsFromJSON; + if (typeof nativeParser === "function") { + return nativeParser.call(window.PublicKeyCredential, options) as PublicKeyCredentialRequestOptions; + } + + return { + ...options, + challenge: base64urlToBuffer(options.challenge), + allowCredentials: Array.isArray(options.allowCredentials) + ? options.allowCredentials.map((credential: any) => ({ + ...credential, + id: base64urlToBuffer(credential.id), + })) + : undefined, + } as PublicKeyCredentialRequestOptions; +} + +export function credentialToJSON(credential: PublicKeyCredential): any { + const nativeToJSON = (credential as any).toJSON; + if (typeof nativeToJSON === "function") { + return nativeToJSON.call(credential); + } + + const response = credential.response as any; + const json: any = { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + }; + + if (response?.attestationObject) { + json.response = { + clientDataJSON: bufferToBase64url(response.clientDataJSON), + attestationObject: bufferToBase64url(response.attestationObject), + transports: typeof response.getTransports === "function" ? response.getTransports() : [], + }; + } else { + json.response = { + clientDataJSON: bufferToBase64url(response.clientDataJSON), + authenticatorData: bufferToBase64url(response.authenticatorData), + signature: bufferToBase64url(response.signature), + userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null, + }; + } + + return json; +}