Web 认证模块 — CUJ 关键用户旅程

Employee Auth, RBAC & Session Management | 更新时间: 2026-02-09

TOUCHES (Pages):
login/page.tsx (员工登录页), layout.tsx (tenant layout, AuthProvider 包裹), page.tsx (根路由重定向)

TOUCHES (Components):
Auth/ProtectedRoute.tsx (路由级权限守卫), Auth/PermissionGate.tsx (组件级权限控制, 含 AdminOnly/SuperAdminOnly/ManagerOnly/CanEdit/CanView), Auth/AuthGuard.tsx (认证检查)

TOUCHES (Context & Stores):
contexts/AuthContext.tsx (全局认证状态, login/logout/refreshToken/switchStore), stores/guest-auth-store.ts (客户认证, 独立于员工认证)

TOUCHES (Backend):
backend/auth/jwt-service.js (RS256 JWT 签发/验证/轮换), backend/auth/auth-controller.js (登录/注册/刷新/登出 API), backend/auth/auth-middleware.js (认证中间件, authenticateToken), backend/auth/password-service.js (bcrypt 密码哈希), backend/auth/roles.js (角色定义与权限映射), backend/auth/tenant-middleware.js (租户上下文提取), backend/middleware/permission-middleware.js (RBAC 权限检查), backend/api/guest-auth.js (客户认证 API, 060 redesign)

架构概览

双认证系统

本系统有两套完全独立的认证体系
1. 员工认证 (Employee Auth) — AuthContext + JWT cookies + /api/auth/*
2. 客户认证 (Guest Auth) — guest-auth-store + OTP + /api/guest-auth/*
两者使用不同的 token、不同的中间件、不同的存储方式。本文档聚焦员工认证;客户认证详见 CUJ-G4

JWT Token 生命周期

flowchart LR
    A["POST /auth/login\n邮箱+密码"] --> B["验证密码\nbcrypt compare"]
    B --> C["检查账户状态\nactive? locked?"]
    C --> D["生成 JWT\nRS256 签名"]
    D --> E["Access Token\n15 分钟有效"]
    D --> F["Refresh Token\n7 天有效"]
    E --> G["HttpOnly Cookie\n+ localStorage 备份"]
    F --> G
    G --> H["每 10 分钟\n自动刷新"]
    H --> I["POST /auth/refresh"]
    I --> D

    J["登出 / Token 过期"] --> K["Token Blacklist\nauth_token_blacklist"]
    K --> L["重定向 /login"]

Token 配置

参数说明
签名算法RS256RSA 2048-bit 密钥对,非对称签名
Access Token 有效期15 分钟HttpOnly Cookie + localStorage 备份
Refresh Token 有效期7 天HttpOnly Cookie
自动刷新间隔10 分钟AuthContext setInterval,过期前 5 分钟触发
密码哈希bcrypt, 10 roundspassword-service.js
密钥存储system_config自动生成,支持轮换
Token 黑名单auth_token_blacklist登出/撤销时加入

JWT Payload 结构

字段说明
sub / user_id用户 UUID
user_role角色: admin / manager / employee / guest
center_id当前门店 ID
typeemployee / customer
tenant_id租户 UUID
tenant_short_id租户短码 (URL 中的 tenant)
jtiToken ID (UUID),用于黑名单

角色层级与权限

角色层级关键权限说明
platform_superadmin1 (最高)全部 + platform:*平台管理员,跨租户
super_admin2租户内全部租户超级管理员
admin3大部分管理权限不能删除员工/服务,无平台权限
manager4运营权限,只读报表门店经理
technician5查看/创建预约,处理支付技师 (employee)
guest6 (最低)查看服务,创建自己的预约客户

三层权限优先级

优先级类型来源说明
1 (最高)DENYemployee_special_permissions明确拒绝,最高优先级(除 platform_superadmin)
2GRANTemployee_special_permissions特殊授权,覆盖角色权限
3 (基础)Rolerole_permissions + job_title_permissions角色+职位的基础权限
权限格式: resource:action(如 appointments:create, reports:view
权限级别: view(只读)vs manage(完全访问)
门店范围: 通过 employee_store_permissions 表控制每个员工的门店级别访问
Superadmin 跳过: platform_superadminsuper_admin 自动跳过数据库权限查询

Rate Limiting

端点限制说明
POST /auth/login1000 req/min/IP (dev)生产环境应更严格
POST /auth/register1000 req/min/IP (dev)防注册滥用
一般 API100 req/min/IP全局限制

目录

CUJ 总览与优先级矩阵

CUJ优先级描述触发点业务价值E2E 状态
CUJ-A1 P0 员工邮箱登录 访问 /[tenant]/login 系统入口,所有操作前提 已覆盖
CUJ-A2 P0 会话保持与自动刷新 Token 过期前自动刷新 避免频繁登录 部分覆盖
CUJ-A3 P0 未认证重定向与公开路由 未登录访问受保护页面 安全性,引导登录 已覆盖
CUJ-A4 P0 账户锁定与安全防护 连续5次密码错误 防暴力破解 缺失
CUJ-A5 P0 RBAC 权限检查 访问需要权限的页面/操作 最小权限原则 部分覆盖
CUJ-A6 P1 多租户登录隔离 用户访问不同租户 URL 数据安全,租户隔离 部分覆盖
CUJ-A7 P1 密码变更 首次登录/主动改密 安全合规 缺失
CUJ-A8 P1 门店切换 多门店员工切换当前门店 多门店运营支持 部分覆盖
CUJ-A9 P2 登出 点击登出按钮 安全性 已覆盖

CUJ-A1: 员工邮箱登录

P0 系统入口 — 员工通过邮箱密码登录管理后台

用户流程

flowchart TD
    A["访问 /en/qqnails/login"] --> B["TenantContext 验证租户"]
    B --> C{租户有效?}
    C -->|"否"| D["显示 Tenant Error 页面"]
    C -->|"是"| E["显示登录表单\n含租户 Logo"]
    E --> F["输入邮箱 + 密码"]
    F --> G["POST /api/auth/employee/login\nbody 含 tenant_id"]
    G --> H{后端验证}
    H -->|"账户锁定"| I["错误: 账户已锁定\n30分钟后重试"]
    H -->|"密码错误"| J["错误: Invalid email or password\n失败次数+1"]
    H -->|"must_change_password"| K["重定向到改密页"]
    H -->|"成功"| L["JWT Token → Cookie + localStorage"]
    L --> M{角色判断}
    M -->|"admin"| N["跳转 /admin/dashboard"]
    M -->|"其他"| O["跳转 /appointments/board"]

    style A fill:#2196F3,stroke:#1565C0,color:#fff
    style N fill:#4CAF50,stroke:#2E7D32,color:#fff
    style O fill:#4CAF50,stroke:#2E7D32,color:#fff
    style I fill:#f44336,stroke:#c62828,color:#fff
    style J fill:#f44336,stroke:#c62828,color:#fff

BDD 场景

场景 A1.1: 正常登录 — 正确凭据

Given 用户在 /en/qqnails/login 页面
When 输入邮箱 "test.admin@qqnails.com"
  And 输入密码 "test123"
  And 点击 "Login" 按钮
Then 调用 POST /api/auth/employee/login,body 含 tenant_id: "qqnails"
  And JWT access token (15min) 存入 HttpOnly Cookie + localStorage
  And JWT refresh token (7days) 存入 HttpOnly Cookie
  And 页面跳转到 /en/qqnails/appointments/board

测试: login.spec.ts

场景 A1.2: 登录失败 — 错误密码

Given 用户在登录页面
When 输入正确邮箱和错误密码 "wrongpass"
  And 点击 "Login"
Then 显示错误 "Invalid email or password"
  And 不显示 "[object Object]" 等技术性错误
  And 后端记录失败次数 +1
  And 用户仍在登录页面

测试: login.spec.ts

场景 A1.3: 邮箱自动去除空格

Given 用户在登录页面
When 输入邮箱 " test.admin@qqnails.com " (前后有空格)
  And 提交表单
Then 发送给 API 的邮箱为 "test.admin@qqnails.com" (已 trim)

测试: AuthContext.login.test.tsx (单元)

场景 A1.4: 支持 returnUrl 参数

Given 用户被重定向到 /login?returnUrl=/admin/reports
When 成功登录
Then 跳转到 /admin/reports 而非默认首页
  And returnUrl 不能指向 /login(防循环重定向)

场景 A1.5: 会话过期提示

Given 用户被重定向到 /login?sessionExpired=true
When 页面加载
Then 显示"会话已过期,请重新登录"提示
  And 正常显示登录表单

CUJ-A2: 会话保持与自动刷新

P0 用户体验 — Access Token 15分钟有效,每10分钟自动刷新

用户流程

flowchart TD
    A["用户正在操作后台"] --> B["AuthContext setInterval\n每10分钟检查"]
    B --> C{Token 即将过期?}
    C -->|"否 (>5min)"| A
    C -->|"是 (<5min)"| D["POST /api/auth/refresh\n携带 refresh token"]
    D --> E{刷新成功?}
    E -->|"是"| F["新 access token\n更新 Cookie + localStorage"]
    F --> A
    E -->|"否 (refresh 也过期)"| G["清除所有 token"]
    G --> H["重定向 /login?sessionExpired=true"]

    style A fill:#2196F3,stroke:#1565C0,color:#fff
    style F fill:#4CAF50,stroke:#2E7D32,color:#fff
    style H fill:#f44336,stroke:#c62828,color:#fff

BDD 场景

场景 A2.1: Token 自动刷新 — 静默成功

Given 用户已登录,access token 将在 3 分钟后过期
When 10 分钟定时器触发刷新检查
Then 调用 POST /api/auth/refresh(携带 refresh token Cookie)
  And 获得新 access token,更新 Cookie + localStorage
  And 用户操作不受任何中断

测试: 缺失 — 需新增

场景 A2.2: Refresh Token 过期 — 强制重新登录

Given access token 已过期,refresh token 也已过期(超过7天)
When 刷新请求失败
Then 清除 localStorage 中的所有 token/user 数据
  And 跳转到 /login?sessionExpired=true

测试: full-flow/01-auth-verification.spec.ts (部分覆盖)

场景 A2.3: 网络断开恢复后自动重试

Given 用户已登录,网络暂时断开
When 网络恢复(online event)
Then AuthContext 检测到 online 状态
  And 立即尝试验证/刷新 session

CUJ-A3: 未认证重定向与公开路由

P0 安全性 — 未登录用户只能访问公开路由,其余重定向到登录页

用户流程

flowchart TD
    A["访问任意页面"] --> B["AuthGuard 检查"]
    B --> C{已认证?}
    C -->|"是"| D["正常渲染"]
    C -->|"否"| E{是公开路由?}
    E -->|"是"| F["允许访问"]
    E -->|"否"| G["重定向 /login\n携带 returnUrl"]

    style D fill:#4CAF50,stroke:#2E7D32,color:#fff
    style F fill:#4CAF50,stroke:#2E7D32,color:#fff
    style G fill:#FF9800,stroke:#E65100,color:#fff

公开路由列表

路由前缀说明
/booking公共在线预约
/checkin自助签到终端
/terminal门店自助终端
/login登录页本身
/terms服务条款
/privacy隐私政策
/gift-cards/claim礼品卡兑换
/gift-cards/purchase礼品卡购买

BDD 场景

场景 A3.1: 未登录访问受保护页面

Given 用户未登录
When 访问 /en/qqnails/appointments/board
Then AuthGuard 检测到未认证
  And 跳转到 /en/qqnails/login?returnUrl=/appointments/board

测试: url-redirects.spec.ts

场景 A3.2: 公开路由不拦截

Given 用户未登录
When 访问 /en/qqnails/booking/store001
Then 页面正常渲染(不跳转到登录页)

测试: AuthGuard.test.tsx (单元 — 8 公开路由 + 7 受保护路由)

CUJ-A4: 账户锁定与安全防护

P0 防暴力破解 — 连续5次错误密码后锁定30分钟

BDD 场景

场景 A4.1: 连续5次错误 — 账户锁定

Given 用户已连续输入4次错误密码
When 第5次输入错误密码
Then 账户状态变为 locked
  And 返回错误: "账户已锁定,请30分钟后重试"
  And 即使输入正确密码也无法登录

测试: 缺失 — 需新增

场景 A4.2: 锁定到期 — 自动解锁

Given 账户在30分钟前被锁定
When 用户输入正确密码
Then 锁定已过期,正常登录
  And 失败计数重置为0

场景 A4.3: 成功登录重置计数

Given 用户已连续输入3次错误密码
When 第4次输入正确密码
Then 登录成功
  And 失败计数重置为0

CUJ-A5: RBAC 权限检查

P0 最小权限 — 页面级 ProtectedRoute + 组件级 PermissionGate

权限检查流程

flowchart TD
    A["用户访问页面"] --> B["ProtectedRoute 检查"]
    B --> C{已认证?}
    C -->|"否"| D["重定向 /login"]
    C -->|"是"| E["加载用户权限\nusePermissions()"]
    E --> F{有 requiredPermission?}
    F -->|"否"| G["渲染页面"]
    F -->|"是"| H{检查权限}
    H -->|"DENY 特殊权限"| I["Permission Denied 对话框"]
    H -->|"GRANT 特殊权限"| G
    H -->|"检查 Role 权限"| J{角色有此权限?}
    J -->|"是"| K{检查权限级别}
    J -->|"否"| I
    K -->|"view 够用"| G
    K -->|"需要 manage"| L{有 manage 级别?}
    L -->|"是"| G
    L -->|"否"| I

    style G fill:#4CAF50,stroke:#2E7D32,color:#fff
    style I fill:#f44336,stroke:#c62828,color:#fff
    style D fill:#FF9800,stroke:#E65100,color:#fff

BDD 场景

场景 A5.1: ProtectedRoute — 有权限

Given admin 用户已登录
When 访问 /admin/reports(需要 reports:view
Then ProtectedRoute 验证通过
  And 正常渲染报表页面

场景 A5.2: ProtectedRoute — 无权限

Given technician 用户已登录(无 reports:view 权限)
When 访问 /admin/reports
Then 显示 "Permission Denied" 对话框
  And 提供 "Go Back" 按钮

场景 A5.3: PermissionGate — 组件级隐藏

Given manager 用户已登录(无 appointments:delete
When 查看预约详情
Then 删除按钮不显示(PermissionGate 隐藏)
  And 编辑按钮正常显示(有 appointments:update

场景 A5.4: Superadmin 跳过权限检查

Given 用户已登录并拥有对应功能所需权限与范围(scope)
When 访问任何需要权限的页面
Then 自动跳过数据库权限查询
  And 直接渲染页面

场景 A5.5: 门店范围权限

Given manager 用户被授权管理 Store A,但未授权 Store B
When 切换到 Store B
Then checkStoreAccess(storeBId) 返回 false
  And 显示"无权访问此门店"提示

CUJ-A6: 多租户登录隔离

P1 数据安全 — 不同租户的登录凭据和会话完全隔离

用户流程

flowchart TD
    A["访问 /en/qqnails/login"] --> B["TenantContext 验证 qqnails"]
    B --> C{租户有效且激活?}
    C -->|"否"| D["Tenant Error 页面\nTENANT_NOT_FOUND"]
    C -->|"是"| E["渲染登录表单\n显示租户 Logo"]
    E --> F["POST /auth/login\n含 tenant_id"]
    F --> G["后端切换 Schema\ntenant_qqnails"]
    G --> H["在租户 Schema 中验证员工"]
    H --> I["JWT 写入 tenant_short_id"]

    style E fill:#4CAF50,stroke:#2E7D32,color:#fff
    style D fill:#f44336,stroke:#c62828,color:#fff

BDD 场景

场景 A6.1: 正确租户登录

Given 用户在 /en/qqnails/login
When 提交登录表单
Then API 请求通过 X-Tenant-ID header 传递租户标识
  And 后端切换到 tenant_qqnails schema 验证员工
  And JWT 中包含 tenant_short_id: "qqnails"

测试: AuthContext.login.test.tsx (单元)

场景 A6.2: 无效租户 — 显示错误页

Given 用户访问 /en/invalid_tenant/login
When TenantContext 加载失败
Then 显示 Tenant Error 页面(TENANT_NOT_FOUND)
  And 不显示登录表单

测试: 缺失 — 需新增

场景 A6.3: 跨租户 Token 拒绝

Given 用户持有租户 A 的有效 JWT
When 访问租户 B 的 API
Then 后端检测 token.tenant_id 与请求的 tenant_id 不匹配
  And 返回 401 Unauthorized

测试: 缺失 — 需新增

CUJ-A7: 密码变更

P1 安全合规 — 首次登录强制改密 + 主动密码变更

BDD 场景

场景 A7.1: 首次登录 — 强制改密

Given 新员工账户 must_change_password = true
When 成功登录
Then 被重定向到密码变更页面
  And 不能访问其他页面直到完成改密

测试: 缺失 — 需新增

场景 A7.2: 主动改密

Given 用户已登录
When 调用 POST /api/auth/change-password
  And 提供当前密码和新密码
Then 密码更新(bcrypt 重新哈希)
  And 旧 JWT 版本失效(jwt_version 递增)
  And 签发新 token

CUJ-A8: 门店切换

P1 多门店支持 — 员工在授权的多个门店间切换

BDD 场景

场景 A8.1: 切换到授权门店

Given 员工被授权访问 Store A 和 Store B
  And 当前在 Store A
When 调用 switchStore(storeBId)
Then currentStoreId 更新为 Store B
  And localStorage currentStoreId 更新
  And API 请求自动携带新的 store context

场景 A8.2: 管理员 — 可查看所有门店

Given admin 用户有 stores:view_all 权限
When 打开门店选择器
Then 显示所有门店列表
  And 可自由切换到任何门店

CUJ-A9: 登出

P2 安全性 — 用户主动登出,清除所有会话数据

BDD 场景

场景 A9.1: 正常登出

Given 用户已登录
When 点击侧边栏的"登出"按钮
Then 调用 POST /api/auth/logout
  And 当前 token 加入 auth_token_blacklist
  And 清除 Cookie + localStorage 中的所有 token/user 数据
  And 跳转到 /login 页面

测试: login.spec.ts

跨模块链接

从 A 模块关联 CUJ关系
A1 员工登录所有 CUJ登录是所有后台操作的前提
A3 公开路由CUJ-G1 在线预约/booking 是公开路由,不需要员工登录
A5 RBACCUJ-B 预约appointments:create/view/update 权限控制
A5 RBACCUJ-F 支付payments:process/refund 权限控制
A5 RBACCUJ-C4 日结解锁day_end_closeout:unlock 权限控制
A6 多租户所有 CUJSchema 隔离影响所有数据查询
Guest Auth (独立)CUJ-G4 OTP 验证完全独立的认证系统

关键业务规则

双认证系统完全隔离: 员工认证 (AuthContext + Cookie) 与客户认证 (guest-auth-store + OTP) 是两套独立系统。不同的中间件:员工用 authenticateToken(),客户用 guestJwtAuth()。不同的 token 格式、不同的存储位置、不同的 API 前缀。
密码变更使旧 Token 失效: 每次密码变更会递增 jwt_version,后端验证 token 时检查 version 匹配。旧 token 即使未过期也会被拒绝。
权限中间件使用规范:
Token 存储策略:
用户偏好 (Preferences): AuthContext 支持 updatePreferences(),可存储:主题 (theme)、语言 (language)、默认门店 (default_store_id)、侧边栏状态。通过 PUT /api/auth/profile 持久化到数据库。