Employee Auth, RBAC & Session Management | 更新时间: 2026-02-09
login/page.tsx (员工登录页),
layout.tsx (tenant layout, AuthProvider 包裹),
page.tsx (根路由重定向)
Auth/ProtectedRoute.tsx (路由级权限守卫),
Auth/PermissionGate.tsx (组件级权限控制, 含 AdminOnly/SuperAdminOnly/ManagerOnly/CanEdit/CanView),
Auth/AuthGuard.tsx (认证检查)
contexts/AuthContext.tsx (全局认证状态, login/logout/refreshToken/switchStore),
stores/guest-auth-store.ts (客户认证, 独立于员工认证)
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)
/api/auth/*/api/guest-auth/*
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"]
| 参数 | 值 | 说明 |
|---|---|---|
| 签名算法 | RS256 | RSA 2048-bit 密钥对,非对称签名 |
| Access Token 有效期 | 15 分钟 | HttpOnly Cookie + localStorage 备份 |
| Refresh Token 有效期 | 7 天 | HttpOnly Cookie |
| 自动刷新间隔 | 10 分钟 | AuthContext setInterval,过期前 5 分钟触发 |
| 密码哈希 | bcrypt, 10 rounds | password-service.js |
| 密钥存储 | system_config 表 | 自动生成,支持轮换 |
| Token 黑名单 | auth_token_blacklist 表 | 登出/撤销时加入 |
| 字段 | 说明 |
|---|---|
sub / user_id | 用户 UUID |
user_role | 角色: admin / manager / employee / guest |
center_id | 当前门店 ID |
type | employee / customer |
tenant_id | 租户 UUID |
tenant_short_id | 租户短码 (URL 中的 tenant) |
jti | Token ID (UUID),用于黑名单 |
| 角色 | 层级 | 关键权限 | 说明 |
|---|---|---|---|
platform_superadmin | 1 (最高) | 全部 + platform:* | 平台管理员,跨租户 |
super_admin | 2 | 租户内全部 | 租户超级管理员 |
admin | 3 | 大部分管理权限 | 不能删除员工/服务,无平台权限 |
manager | 4 | 运营权限,只读报表 | 门店经理 |
technician | 5 | 查看/创建预约,处理支付 | 技师 (employee) |
guest | 6 (最低) | 查看服务,创建自己的预约 | 客户 |
| 优先级 | 类型 | 来源 | 说明 |
|---|---|---|---|
| 1 (最高) | DENY | employee_special_permissions | 明确拒绝,最高优先级(除 platform_superadmin) |
| 2 | GRANT | employee_special_permissions | 特殊授权,覆盖角色权限 |
| 3 (基础) | Role | role_permissions + job_title_permissions | 角色+职位的基础权限 |
resource:action(如 appointments:create, reports:view)view(只读)vs manage(完全访问)employee_store_permissions 表控制每个员工的门店级别访问platform_superadmin 和 super_admin 自动跳过数据库权限查询
| 端点 | 限制 | 说明 |
|---|---|---|
| POST /auth/login | 1000 req/min/IP (dev) | 生产环境应更严格 |
| POST /auth/register | 1000 req/min/IP (dev) | 防注册滥用 |
| 一般 API | 100 req/min/IP | 全局限制 |
| 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 | 登出 | 点击登出按钮 | 安全性 | 已覆盖 |
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
/en/qqnails/login 页面POST /api/auth/employee/login,body 含 tenant_id: "qqnails"/en/qqnails/appointments/board
测试: login.spec.ts
测试: login.spec.ts
测试: AuthContext.login.test.tsx (单元)
/login?returnUrl=/admin/reports/admin/reports 而非默认首页/login?sessionExpired=trueP0 用户体验 — 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
POST /api/auth/refresh(携带 refresh token Cookie)测试: 缺失 — 需新增
/login?sessionExpired=true
测试: full-flow/01-auth-verification.spec.ts (部分覆盖)
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 | 礼品卡购买 |
/en/qqnails/appointments/board/en/qqnails/login?returnUrl=/appointments/board
测试: url-redirects.spec.ts
/en/qqnails/booking/store001测试: AuthGuard.test.tsx (单元 — 8 公开路由 + 7 受保护路由)
P0 防暴力破解 — 连续5次错误密码后锁定30分钟
测试: 缺失 — 需新增
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
/admin/reports(需要 reports:view)/admin/reportsappointments:delete)appointments:update)
checkStoreAccess(storeBId) 返回 falseP1 数据安全 — 不同租户的登录凭据和会话完全隔离
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
/en/qqnails/loginX-Tenant-ID header 传递租户标识tenant_qqnails schema 验证员工tenant_short_id: "qqnails"
测试: AuthContext.login.test.tsx (单元)
/en/invalid_tenant/login测试: 缺失 — 需新增
测试: 缺失 — 需新增
P1 安全合规 — 首次登录强制改密 + 主动密码变更
must_change_password = true测试: 缺失 — 需新增
POST /api/auth/change-passwordP1 多门店支持 — 员工在授权的多个门店间切换
switchStore(storeBId)currentStoreId 更新为 Store BcurrentStoreId 更新stores:view_all 权限P2 安全性 — 用户主动登出,清除所有会话数据
POST /api/auth/logoutauth_token_blacklist/login 页面
测试: login.spec.ts
| 从 A 模块 | 关联 CUJ | 关系 |
|---|---|---|
| A1 员工登录 | 所有 CUJ | 登录是所有后台操作的前提 |
| A3 公开路由 | CUJ-G1 在线预约 | /booking 是公开路由,不需要员工登录 |
| A5 RBAC | CUJ-B 预约 | appointments:create/view/update 权限控制 |
| A5 RBAC | CUJ-F 支付 | payments:process/refund 权限控制 |
| A5 RBAC | CUJ-C4 日结解锁 | day_end_closeout:unlock 权限控制 |
| A6 多租户 | 所有 CUJ | Schema 隔离影响所有数据查询 |
| Guest Auth (独立) | CUJ-G4 OTP 验证 | 完全独立的认证系统 |
authenticateToken(),客户用 guestJwtAuth()。不同的 token 格式、不同的存储位置、不同的 API 前缀。
jwt_version,后端验证 token 时检查 version 匹配。旧 token 即使未过期也会被拒绝。
permission-middleware.js 的 requirePermission / requireAnyPermission / requireAllPermissionsauth-middleware.js 的已废弃权限方法(会输出 deprecation 警告)if (role === 'admin'))resource:action(如 appointments:create)updatePreferences(),可存储:主题 (theme)、语言 (language)、默认门店 (default_store_id)、侧边栏状态。通过 PUT /api/auth/profile 持久化到数据库。