User & Permission Domain - Deep Dive Analysis
1. Domain Overview
1.1 领域职责描述
User & Permission 领域是整个 Speaker Platform 的安全基础设施,负责:
- 用户身份管理:统一用户账户(UnifiedUser)的创建、更新、激活/停用
- 多层级认证:支持密码认证、SSO/SAML 认证、Token 认证三种方式
- 多维度 RBAC 权限模型:用户在 Instance/Company/Product 三个维度分别拥有不同角色和权限
- 三套独立认证流程:Plannerview (Instance)、Salesview (Product)、Speakerview (Company) 各有不同的登录和授权逻辑
- 用户数量限制管理:按视图(SalesView/SpeakerView/PlannerView)控制活跃用户上限
- 密码策略管理:密码历史检查、重置密码、忘记密码流程
- SSO 双服务并存:pharmagin-sso(生产)和 pharmagin-login(待部署)两套 SSO 服务
1.2 涉及的后端模块和包
| 模块/包 | 路径 | 职责 |
|---|---|---|
| user module | pharmagin-web/.../modules/v1/user/ | 用户 CRUD、认证、角色管理、用户限制 |
| security | pharmagin-web/.../common/security/ | 认证提供者、过滤器、权限表达式 |
| security config | pharmagin-web/.../common/config/SecurityConfig.java | Spring Security 配置 |
| persistence entities | pharmagin-web/.../common/persistence/entity/ | 10+ 用户/角色/权限实体 |
| pharmagin-sso | pharmagin-api/pharmagin-sso/ | 生产 SSO/SAML 2.0 服务 |
| pharmagin-login | pharmagin-api/pharmagin-login/ | 下一代 SSO 服务(未上线) |
2. Data Model Analysis
2.1 Entity Overview Table
| Entity | 表名 | 主键 | 关键字段 | 文件位置 |
|---|---|---|---|---|
UnifiedUser | t_unified_user | user_id (auto) | username, password, legacy_password, email, first_name, last_name, office_phone, mobile_phone, fax, status(int), created_at/by, updated_at/by, last_login_dt | common/persistence/entity/UnifiedUser.java |
UserInstance | t_user_instance | user_instance_id (auto) | user_id, instance_id, status | common/persistence/entity/UserInstance.java |
UserCompany | t_user_company | user_company_id (auto) | user_id, company_id, status, product_ids(jsonb) | common/persistence/entity/UserCompany.java |
UserProduct | t_user_product | user_product_id (auto) | user_id, product_id, status, sales_force_id, team_id, region_id, district_id, territory_id, employee_id, city, state, street, zip_code | common/persistence/entity/UserProduct.java |
UserRole | t_user_role | role_id (auto) | role_name, display_name | common/persistence/entity/UserRole.java |
RoleGroup | t_role_group | role_group_id (auto) | role_group_name | common/persistence/entity/RoleGroup.java |
Permission | t_permission | permission_id (auto) | permission_name | common/persistence/entity/Permission.java |
RolePermission | t_role_permission | (role_id, permission_id) 复合主键 | role_id, permission_id | common/persistence/entity/RolePermission.java |
ProductRole | t_product_role | role_id (auto) | product_id, role_name, display_name | common/persistence/entity/ProductRole.java |
ProductRolePermission | t_product_role_permission | (role_id, permission_id) 复合主键 | role_id, permission_id | common/persistence/entity/ProductRolePermission.java |
UserProductRole | t_user_product_role | 无主键 | user_product_id, role_id | common/persistence/entity/UserProductRole.java |
UserAuthToken | t_user_auth_token | (email, token) 复合主键 | email, token | common/persistence/entity/UserAuthToken.java |
UserPasswordHistory | t_user_password_history | id (auto) | user_id, password, legacy_password, created_at | common/persistence/entity/UserPasswordHistory.java |
UserQuantityLimit | t_user_quantity_limit | id (auto) | object_id, type(0/1/2), users_limit | common/persistence/entity/UserQuantityLimit.java |
UserQuantityLimitChangeLog | t_user_quantity_limit_change_log | id (auto) | object_id, type, old_value, new_value, created_at, created_by | common/persistence/entity/UserQuantityLimitChangeLog.java |
UserQuantityLimitWhitelist | t_user_quantity_limit_whitelist | id (auto) | domain | common/persistence/entity/UserQuantityLimitWhitelist.java |
2.2 Table Relationships (ER Diagram - ASCII)
+-----------------+
| t_role_group |
|-----------------|
| role_group_id |
| role_group_name |
+--------+--------+
|
t_role_group_role (隐式)
|
+-------------------+ +-------------------+ +------------------+
| t_unified_user | | t_user_role | | t_permission |
|-------------------| |-------------------| |------------------|
| user_id [PK] | | role_id [PK] | | permission_id [PK]|
| username | | role_name | | permission_name |
| password | | display_name | +--------+---------+
| legacy_password | +--------+----------+ |
| email | | |
| first/last_name | t_role_permission |
| status | (role_id, permission_id) ----------------+
| ... |
+---+----+-----+----+
| | |
| | +--------------------------------+
| | |
| +-------------------+ |
| | |
+---v---------------+ +----v--------------+ +v-------------------+
| t_user_instance | | t_user_company | | t_user_product |
|-------------------| |-------------------| |--------------------|
| user_instance_id | | user_company_id | | user_product_id |
| user_id [FK] | | user_id [FK] | | user_id [FK] |
| instance_id | | company_id | | product_id |
| status | | status | | status |
+-------------------+ | product_ids(jsonb)| | sales_force_id |
| +-------------------+ | team_id |
| | | region_id |
| t_user_instance_role | t_user_company_role| district_id |
| (隐式关联) | (隐式关联) | territory_id |
| | | employee_id |
| | +--------+-----------+
| | |
+--- 通过 Mapper 关联 t_user_role |
(INSTANCE-{id}-{permission}) +--------v-----------+
| t_user_product_role|
|--------------------|
| user_product_id |
| role_id |
+--------------------+
|
+--------v----------+
| t_product_role |
|-------------------|
| role_id [PK] |
| product_id |
| role_name |
| display_name |
+--------+----------+
|
t_product_role_permission
(role_id, permission_id)
|
+--------v----------+
| t_permission |
+-------------------+
+---------------------------+ +------------------------------------+
| t_user_auth_token | | t_user_password_history |
|---------------------------| |------------------------------------|
| email [PK] | | id [PK] |
| token [PK] | | user_id [FK] |
+---------------------------+ | password |
| legacy_password |
| created_at |
+------------------------------------+
+---------------------------+ +------------------------------------+
| t_user_quantity_limit | | t_user_quantity_limit_change_log |
|---------------------------| |------------------------------------|
| id [PK] | | id [PK] |
| object_id | | object_id |
| type (0/1/2) | | type (0/1/2) |
| users_limit | | old_value / new_value |
+---------------------------+ | created_at / created_by |
+------------------------------------+
+---------------------------+
| t_user_quantity_limit_ |
| whitelist |
|---------------------------|
| id [PK] |
| domain |
+---------------------------+2.3 Data Model Issues
Issue D1: 双重角色体系并存(严重)
t_user_role+t_role_permission是全局角色体系(Instance/Company 维度使用)t_product_role+t_product_role_permission是 Product 维度的独立角色体系- 两套体系共享同一张
t_permission表,但角色定义完全独立 - 导致权限管理代码大量重复,且容易出现不一致
Issue D2: UserProductRole 实体缺少主键
t_user_product_role没有@Id注解,只有user_product_id和role_id两个字段- 位置:
UserProductRole.java:21-28 - 无法安全地执行单条记录的更新或删除
Issue D3: UserCompany.productIds 使用 Object 类型
UserCompany.java:43:private Object productIds;存储 JSONB 数据- 类型不安全,多处强制转换为
List<Integer>(如UserService.java:258, 425, 737) - 应使用具体类型如
List<Integer>配合 JSONB TypeHandler
Issue D4: legacy_password 遗留字段
UnifiedUser.legacy_password和UserPasswordHistory.legacy_password字段仍存在UserPasswordAuthenticationProvider.java:65-76仍有明文密码比对逻辑- 安全隐患:遗留密码以明文存储
Issue D5: Instance/Company 角色关联表不透明
UserInstance和UserCompany的角色关联不通过实体类,而是通过 Mapper 的addRole/removeRole方法直接操作 SQL- 中间表(如
t_user_instance_role、t_user_company_role)没有对应的 JPA Entity - 只有
UserProductRole有实体定义
Issue D6: status 字段类型不一致
UnifiedUser.status使用Integer(0/1)UserStatusenum 同时有getValue()返回 int 和getStatus()返回 String- DTO 层
ProductUser.status是String类型 - 在
listProductUsers()中需要做Integer.valueOf()转换
3. Business Flow Analysis
3.1 Core Business Flows
3.1.1 认证流程(三种方式)
方式1: 密码认证 (Plannerview/Salesview/Speakerview 通用)
==========================================================
Frontend AuthenticationController UserService UserPasswordAuthProvider
| | | |
|-- POST /v1/authenticate ---->| | |
| {username, password} | | |
| |-- authenticate() --------->| |
| | |-- authManager.auth() -->|
| | | (UsernamePassword |
| | | AuthToken) |
| | | |-- loadUserByUsername()
| | | | (UserPasswordUDS)
| | | | 查询 UnifiedUser
| | | | 加载 Instance/Product/
| | | | Company 角色
| | | |-- BCrypt 验证
| | | | 失败则尝试 legacy 密码
| | |<-- AuthInfo<UnifiedUser>|
| | | |
| | |-- 构建 User DTO |
| | |-- 生成 accessToken |
| | | (UUID, 存入 Guava Cache)|
| | |-- 更新 last_login_dt |
| |<-- AuthInfo<User> ---------| |
| | | |
| |-- 检查 productUser 状态 -->| |
| | (必须在当前 product 下 | |
| | 有 active 状态) | |
|<-- {accessToken, user, ...}--| | |
| | | |
|== 后续请求 ======================================================== |
| | | |
|-- GET /v1/xxx ------------->| | |
| Header: Authorization: | | |
| Bearer {accessToken} | | |
| | | |
| AuthInfoPreAuthFilter 从 header 提取 token | |
| 从 Guava Cache 获取 AuthInfo | |
| 设置为 SecurityContext.principal | |
方式2: SSO/SAML Token 认证
==========================================================
Browser pharmagin-sso (或 pharmagin-login) DB(t_user_auth_token) Frontend pharmagin-web
| | | | |
|-- 访问 /sso/ ---------->| | | |
| |-- SAML2 Login Flow (IDP) ---------> | | |
| | (Azure AD / Okta) | | |
| |<-- SAML Response (email) -----------| | |
| | | | |
| |-- generateAndStoreToken(email) ---->| | |
| | 生成 UUID token | | |
| | INSERT t_user_auth_token | | |
| | | | |
|<-- Set-Cookie: ----------| | | |
| pharmaginAuth= | | | |
| {email}|{token} | | | |
| | | | |
|-- Redirect to Frontend -|------------------------------------>(读 cookie) | |
| | | | |
| | |-- POST /v1/auth -->| |
| | | {username: email, | |
| | | token: token} | |
| | | |-- UserTokenAuth -->|
| | | | Provider |
| | | | 查找 auth_token |
| | | | 验证 email 匹配 |
| | |<-- AuthInfo -------| |
方式3: Pre-Auth 过滤器 (每个请求)
==========================================================
Request AuthInfoPreAuthFilter AuthManager (Guava Cache)
| | |
|-- Authorization: ------>| |
| Bearer {token} | |
| |-- getPreAuthenticatedPrincipal() -->|
| | 1. 检查 External-Authorization |
| | (RSA JWT 验证) |
| | 2. 从 Authorization header |
| | 提取 Bearer token |
| | 3. 从 apiKey header/param 获取 |
| | |
| |-- AuthManager.get(token) ---------->|
| | 查询 Guava Cache |
| |<-- AuthInfo or null ----------------|
| | |
| |-- set SecurityContext.principal |3.1.2 Speaker 独立认证流程
Speaker Browser Speakerview Frontend pharmagin-web API
| | |
|== 注册流程 =============|================================|
| | |
|-- 点击 Sign Up -------->| |
| |-- GET /v1/speakers/ |
| | activation-code?email=xxx -->|
| | (无需认证, permitAll) |
| | |-- 发送激活码到邮箱
| |<-- 200 OK ---------------------|
| | |
|-- 输入激活码 ---------->| |
| |-- POST /v1/speakers/ |
| | activation-code/validation ->|
| | |-- 验证激活码
| |<-- 200 OK ---------------------|
| | |
|-- 设置密码 ------------>| |
| |-- POST /v1/speakers/signup --->|
| | {email, password} |
| | |-- 创建 UnifiedUser
| | |-- 创建 UserCompany
| | |-- 关联 Speaker 记录
| | |
|== 登录流程 =============|================================|
| | |
|-- Login {userName, pwd}>| |
| |-- POST /v1/authenticate ------>|
| | {username, password} |
| | |-- 同通用认证流程
| |<-- {accessToken, user} --------|
| | |
| |-- sessionStorage.set( |
| | 'accessToken', token) |
| |-- sessionStorage.set( |
| | 'user', JSON) // 含 role |
| | |
|== 路由权限 =============|================================|
| | |
| | auth.js: isAllowedPath() |
| | 根据 user.role (Admin/ |
| | Speaker/Sales) 判断路由访问权限|
| | 注意:这是简单的字符串角色 |
| | 不是 RBAC 权限 |3.1.3 多层级 RBAC 模型
权限格式: {TYPE}-{ID}-{PERMISSION}
示例:
INSTANCE-1-ADMIN -> Plannerview 管理员
INSTANCE-1-MEETINGS -> Plannerview 会议管理
INSTANCE-1-COMPLIANCE -> Plannerview 合规管理
PRODUCT-25-USERS -> SalesView Product 25 用户管理
PRODUCT-25-PRODUCT_PROGRAMS -> SalesView Product 25 全部项目
PRODUCT-25-REGION_PROGRAMS -> SalesView Product 25 区域项目
COMPANY-1-EXTENDED_USER_PROFILE -> Speakerview Company 1 扩展用户
权限构建过程:
=============
UnifiedUser
|
+-- UserInstance (instance_id=1)
| |-- UserRole (via t_user_instance_role)
| | |-- Permission (via t_role_permission)
| | |-- 生成: INSTANCE-1-{permissionName}
| |
+-- UserCompany (company_id=X)
| |-- UserRole (via t_user_company_role)
| | |-- Permission (via t_role_permission)
| | |-- 生成: COMPANY-X-{permissionName}
| |
+-- UserProduct (product_id=Y)
|-- ProductRole (via t_user_product_role + t_product_role)
| |-- Permission (via t_product_role_permission)
| |-- 生成: PRODUCT-Y-{permissionName}
权限检查: HasPermissionExpressionHandlerRoot
==============================================
@PreAuthorize("hasProductPermission(#productId, 'users')")
-> 检查 authorities 中是否存在 "PRODUCT-{productId}-USERS"
@PreAuthorize("hasInstancePermission(1, 'MULTI_MODULE_USER_ADMIN')")
-> 检查 authorities 中是否存在 "INSTANCE-1-MULTI_MODULE_USER_ADMIN"
@PreAuthorize("hasProductPermission(#productId, '*')")
-> 通配符检查: 是否有任何 PRODUCT-{productId}-xxx 权限3.1.4 SalesView 预定义角色权限
ProductRoleEnum (ProductRoleEnum.java)
======================================
SALES_ADMIN -> USERS, BUDGET_ALLOCATION, GEOGRAPHY, REASSIGN_PROGRAM_GEOGRAPHY,
PROGRAMS, SCHEDULED/COMPLETED/PENDING_APPROVAL/APPROVED_PROGRAMS,
ALL_PROGRAMS, CALENDAR, REPORTS (多种), PRODUCT_PROGRAMS,
ALL_TEAMS, ALL_SERVICE_TYPES, DOCTEMPLATE_DOWNLOAD, SPEAKER_LIST,
VIEW_ALL_PENDING_APPROVAL_PROGRAMS
UPPER_MANAGER -> PROGRAMS, 各状态项目, CALENDAR, REPORTS (多种),
PRODUCT_PROGRAMS, ALL_TEAMS, ALL_SERVICE_TYPES,
VIEW_ALL_PENDING_APPROVAL_PROGRAMS
REGIONAL_MANAGER -> PROGRAMS, 各状态项目, CALENDAR, REPORTS (多种),
REGION_PROGRAMS, VIEW_ALL_PENDING_APPROVAL_PROGRAMS
(+条件: REGION_BUDGET_ALLOCATION if brandBudgetAllocationEnabled)
DISTRICT_MANAGER -> PROGRAMS, 各状态项目, CALENDAR, REPORTS (多种),
DISTRICT_PROGRAMS, VIEW_ALL_PENDING_APPROVAL_PROGRAMS
(+条件: DISTRICT_BUDGET_ALLOCATION if brandBudgetAllocationEnabled)
SALES_PERSON -> PROGRAMS, SCHEDULED_PROGRAMS, COMPLETED_PROGRAMS, CALENDAR
PLANNER -> PROGRAMS, ALL_PROGRAMS, CALENDAR, DOCTEMPLATE_DOWNLOAD
数据可见性层级:
SALES_ADMIN / UPPER_MANAGER -> PRODUCT_PROGRAMS (全产品线)
REGIONAL_MANAGER -> REGION_PROGRAMS (区域)
DISTRICT_MANAGER -> DISTRICT_PROGRAMS (地区)
SALES_PERSON -> 仅本人数据3.2 Validation Rules
| 规则 | 位置 | 描述 |
|---|---|---|
| 用户名唯一 | UserService.java:288-291 | 创建用户时检查 username 是否已存在 |
| 用户名更新唯一性 | UserService.java:390-394 | 更新时捕获 DuplicateKeyException |
| 密码历史检查 | UserService.java:1025-1039 | 根据配置 passwordHistoryCheck 检查最近 N 个密码是否重复 |
| 用户数量限制 | UsersLimitService.java:279-286 | 激活用户前检查是否超过配额 |
| 域名白名单 | UsersLimitService.java:263-277 | 白名单域名的用户不受数量限制 |
| Product 用户激活状态检查 | AuthenticationController.java:48-61 | 登录时验证用户在当前 product 下是否 active |
| 产品切换 | AuthenticationController.java:52-54 | 如果 switchProductEnabled,允许在任意 active product 下登录 |
| 忘记密码链接有效期 | UserService.java:151-154 | 24 小时 Guava Cache 过期 |
| 登录请求验证 | UserLoginRequest.java:9 | username 为 @NotBlank,password 和 token 可选(二选一) |
| 创建用户验证 | CreateUserRequest.java:10-15 | username, password, email 均 @NotBlank |
| ProductUser 创建验证 | CreateProductUserRequest.java | username, password, firstName, lastName, email 均 @NotBlank |
3.3 Business Logic Issues
Issue B1: Token 存储使用 Guava Cache 而非 JWT(严重)
AuthManager.java:27-28:使用CacheBuilder.expireAfterAccess(2, TimeUnit.HOURS)存储 token- Token 是随机 UUID,不是 JWT,没有自描述性
- 代码注释
UserService.java:159-160明确标注这是临时方案:TODO this is a temporary solution until we can convert all of pharamgin-web to oauth - 问题:服务重启后所有用户需要重新登录;无法做水平扩展(内存级缓存不共享)
Issue B2: SSO Token 未及时清理(严重)
UserTokenAuthenticationProvider.java:47-51:注释掉了 token 删除逻辑- 原因注释:
FIXME: temp solution, it seems the token was deleted by salesview. when iPad get the token from Cookie then login, the token can't be found - 结果:
t_user_auth_token表中 token 会无限积累
Issue B3: SSO Cookie 安全设置缺失(严重)
CustomAuthenticationSuccessHandler.java:161-162:HttpOnly和Secure均被注释掉RedirectAuthController.java:75-76(pharmagin-login):Cookie maxAge 仅 15 秒- Cookie 值格式:
email|token,以 URL 编码传输
Issue B4: Legacy 密码明文存储(安全)
UserPasswordAuthenticationProvider.java:66-67:StringUtils.equals(user.getLegacyPassword(), (String)auth.getCredentials())- 明文密码直接比对
- 转换后虽然会清除 legacy_password,但历史表中
legacy_password字段仍然保留
Issue B5: 权限信息快照不更新
- 认证时将用户全部角色和权限加载到
AuthInfo中,缓存 2 小时 - 如果管理员修改了用户权限,用户需要重新登录才能生效
AuthInfoUserDetailsService.java:44-48:每次请求从缓存的 AuthInfo 构建 UserDetails- 没有权限变更通知机制
Issue B6: Speakerview 的角色模型过于简单
speakerview/legacy/src/utils/auth.js:14-58:使用硬编码的字符串角色('Admin', 'Speaker', 'Sales')- 与后端 RBAC 模型不一致,前端路由权限控制是简单的 includes 检查
- 没有使用后端返回的
authorities列表
Issue B7: External-Authorization 过于宽松
AuthInfoPreAuthFilter.java:45-49:仅验证 JWT 签名有效,不检查任何 claim(subject, audience, expiry)- 成功后直接创建一个
accessToken="system"的 AuthInfo AuthInfoUserDetailsService.java:35-37:system token 绕过所有权限检查,赋予SYSTEM权限
Issue B8: forgetPassword 使用 Guava Cache 存储 UUID
UserService.java:151-154, 979:重置密码链接使用内存 Cache- 服务重启后所有未使用的重置链接失效
- 与认证 token 相同的问题:不支持多实例部署
4. API Inventory
4.1 REST Endpoints Table
AuthenticationController (v1/authenticate)
| Method | Path | 权限 | 描述 | 文件:行 |
|---|---|---|---|---|
| POST | /v1/authenticate | permitAll | 用户登录(密码或 SSO token) | AuthenticationController.java:43-64 |
| GET | /v1/authenticate/logout | 无 | 用户登出(仅清 session) | AuthenticationController.java:67-74 |
UserController (v1/users)
| Method | Path | 权限 | 描述 | 文件:行 |
|---|---|---|---|---|
| GET | /v1/users/{username} | authenticated | 按用户名获取用户 | UserController.java:37-40 |
| GET | /v1/users | authenticated | 分页列表用户 | UserController.java:43-46 |
| POST | /v1/users | authenticated | 创建新用户 | UserController.java:49-52 |
| PUT | /v1/users/{username} | authenticated | 更新用户信息 | UserController.java:55-58 |
| PUT | /v1/users/{username}/active | authenticated | 激活用户 | UserController.java:61-64 |
| PUT | /v1/users/{username}/deactive | authenticated | 停用用户 | UserController.java:67-70 |
| PUT | /v1/users/{username}/password | authenticated | 更新密码 | UserController.java:73-76 |
| POST | /v1/users/{username}/instances | authenticated | 添加 Instance 用户 | UserController.java:79-82 |
| POST | /v1/users/{username}/companies | authenticated | 添加 Company 用户 | UserController.java:85-88 |
| PUT | /v1/users/{username}/companies/{companyId}/activate | authenticated | 激活 Company 用户 | UserController.java:91-94 |
| PUT | /v1/users/{username}/companies/{companyId}/deactivate | authenticated | 停用 Company 用户 | UserController.java:96-100 |
| POST | /v1/users/{username}/products | authenticated | 添加 Product 用户 | UserController.java:103-106 |
| PUT | /v1/users/{username}/instances | authenticated | 更新 Instance 用户 | UserController.java:109-112 |
| PUT | /v1/users/{username}/companies | authenticated | 更新 Company 用户 | UserController.java:115-118 |
| PUT | /v1/users/{username}/products | authenticated | 更新 Product 用户 | UserController.java:121-124 |
| GET | /v1/users/{username}/products/{productId} | authenticated | 获取 Product 用户 | UserController.java:127-130 |
| PUT | /v1/users/{username}/instances/{instanceId}/roles/{roleName} | authenticated | 添加 Instance 角色 | UserController.java:133-136 |
| PUT | /v1/users/{username}/companies/{companyId}/roles/{roleName} | authenticated | 添加 Company 角色 | UserController.java:139-142 |
| PUT | /v1/users/{username}/products/{productId}/roles/{roleName} | authenticated | 添加 Product 角色 | UserController.java:145-148 |
| DELETE | /v1/users/{username}/instances/{instanceId}/roles/{roleName} | authenticated | 移除 Instance 角色 | UserController.java:151-154 |
| DELETE | /v1/users/{username}/companies/{companyId}/roles/{roleName} | authenticated | 移除 Company 角色 | UserController.java:157-160 |
| DELETE | /v1/users/{username}/products/{productId}/roles/{roleName} | authenticated | 移除 Product 角色 | UserController.java:163-166 |
ProductUserController (v1/users/products)
| Method | Path | 权限 | 描述 | 文件:行 |
|---|---|---|---|---|
| GET | /v1/users/products/{productId} | hasProductPermission(users) OR hasInstancePermission(1, MULTI_MODULE_USER_ADMIN) | 列表 Product 用户 | ProductUserController.java:44-48 |
| GET | /v1/users/products/{productId}/subordinates | hasProductPermission(*, *) | 获取下属用户 | ProductUserController.java:51-55 |
| POST | /v1/users/products/{productId} | hasProductPermission(users) OR ... | 创建 Product 用户 | ProductUserController.java:58-62 |
| PUT | /v1/users/products/{username}/{productId} | hasProductPermission(users) OR ... | 更新 Product 用户 | ProductUserController.java:65-70 |
| GET | /v1/users/products/{username}/{productId} | hasProductPermission(users) OR ... | 获取 Product 用户详情 | ProductUserController.java:73-77 |
| PUT | /v1/users/products/{username}/{productId}/active | hasProductPermission(users) OR ... | 激活 Product 用户 | ProductUserController.java:80-84 |
| PUT | /v1/users/products/{username}/{productId}/deactive | hasProductPermission(users) OR ... | 停用 Product 用户 | ProductUserController.java:87-91 |
| POST | /v1/users/products/{productId}/upload | hasProductPermission(users) OR ... | 批量上传 Product 用户 | ProductUserController.java:94-98 |
| GET | /v1/users/products/salesforce-members | authenticated | 查询 Salesforce 成员 | ProductUserController.java:100-103 |
| GET | /v1/users/products/{username}/forget-password | permitAll | 忘记密码(发送邮件) | ProductUserController.java:106-109 |
| PUT | /v1/users/products/reset-password | permitAll | 重置密码(通过 UUID) | ProductUserController.java:111-114 |
| PUT | /v1/users/products/change-password | #request.userId == principal.user.userId | 修改密码(当前用户) | ProductUserController.java:117-122 |
| GET | /v1/users/products/{productId}/meetings | authenticated | 查询用户的 SalesMeetings | ProductUserController.java:124-127 |
RoleController (v1/)
| Method | Path | 权限 | 描述 | 文件:行 |
|---|---|---|---|---|
| GET | /v1/roles/ | authenticated | 获取所有角色 | RoleController.java:39-42 |
| GET | /v1/permissions/ | authenticated | 获取所有权限 | RoleController.java:45-48 |
| GET | /v1/roleGroups/{roleGroupName}/roles | authenticated | 按角色组获取角色 | RoleController.java:52-55 |
| POST | /v1/roles/ | authenticated | 创建角色 | RoleController.java:58-61 |
| POST | /v1/permissions/ | authenticated | 创建权限 | RoleController.java:64-67 |
| PUT | /v1/roles/{roleName}/permissions/{permissionName} | authenticated | 为角色添加单个权限 | RoleController.java:70-73 |
| POST | /v1/roles/{roleName}/permissions | authenticated | 批量分配权限 | RoleController.java:74-77 |
ProductRoleController (v1/products/{productId})
| Method | Path | 权限 | 描述 | 文件:行 |
|---|---|---|---|---|
| GET | /v1/products/{productId}/roles | authenticated | 列表 Product 角色 | ProductRoleController.java:36-39 |
| POST | /v1/products/{productId}/roles | authenticated | 创建 Product 角色 | ProductRoleController.java:42-45 |
| POST | /v1/products/{productId}/roles/initialization | authenticated | 初始化默认角色模板 | ProductRoleController.java:48-51 |
| PUT | /v1/products/{productId}/roles/{roleName} | authenticated | 更新 Product 角色 | ProductRoleController.java:54-57 |
| DELETE | /v1/products/{productId}/roles/{roleName} | authenticated | 删除 Product 角色 | ProductRoleController.java:60-63 |
| GET | /v1/products/{productId}/roles/{roleName}/permissions | authenticated | 获取角色权限 | ProductRoleController.java:66-69 |
| POST | /v1/products/{productId}/roles/{roleName}/permissions | authenticated | 更新角色权限 | ProductRoleController.java:72-75 |
| GET | /v1/products/{productId}/permissions | authenticated | 列表所有权限 | ProductRoleController.java:78-81 |
TestPermissionsController (v1/test/permissions)
| Method | Path | 权限 | 描述 | 文件:行 |
|---|---|---|---|---|
| GET | /v1/test/permissions/instance/{instanceId} | hasInstancePermission(read) | 测试 Instance 权限 | TestPermissionsController.java:26-30 |
| GET | /v1/test/permissions/product/{productId} | hasProductPermission(read) | 测试 Product 权限 | TestPermissionsController.java:33-37 |
| GET | /v1/test/permissions/company/{companyId} | hasCompanyPermission(read) | 测试 Company 权限 | TestPermissionsController.java:40-47 |
UsersLimitController (v1/users-limit)
| Method | Path | 权限 | 描述 | 文件:行 |
|---|---|---|---|---|
| GET | /v1/users-limit | authenticated | 列表用户限制 | UsersLimitController.java:22-25 |
| POST | /v1/users-limit | authenticated | 保存用户限制 | UsersLimitController.java:27-30 |
| GET | /v1/users-limit/{type}/{objectId} | authenticated | 检查用户限制 | UsersLimitController.java:32-36 |
| POST | /v1/users-limit/whitelist | authenticated | 保存白名单 | UsersLimitController.java:38-41 |
4.2 API Design Issues
Issue A1: UserController 缺少权限控制(严重)
UserController的所有 18 个端点仅需authenticated,无细粒度权限检查- 任何登录用户都可以创建/修改/激活/停用其他用户
- 对比
ProductUserController有@PreAuthorize注解
Issue A2: RoleController 和 ProductRoleController 缺少权限控制
- 角色和权限的管理 API 没有
@PreAuthorize注解 - 任何认证用户可以创建角色、分配权限
Issue A3: API 路径设计不一致
- UserController:
v1/users/{username}/products/{productId} - ProductUserController:
v1/users/products/{username}/{productId} - 两个 Controller 有重叠的功能(如
getProductUser)
Issue A4: RESTful 命名不规范
/v1/users/{username}/active和/v1/users/{username}/deactive应使用 PATCH 或更 RESTful 的设计- "deactive" 应为 "deactivate"(拼写错误)
Issue A5: TestPermissionsController 暴露在生产环境
v1/test/permissions/*是测试端点,不应部署到生产环境
Issue A6: Swagger 注解中 httpMethod 冗余
@ApiOperation(value = "xxx", httpMethod = "GET")与@GetMapping重复定义
5. Frontend Analysis
5.1 Pages & Components
Plannerview (Admin User Management)
| Component | 路径 | 功能 |
|---|---|---|
| Login | pharmagin-plannerview/legacy/src/components/Login/index.js | 密码登录页,含 "Forget Password" |
| User (Admin) | pharmagin-plannerview/legacy/src/components/User/index.js | 用户管理列表(CRUD),含状态切换和密码修改 |
| UserForm | pharmagin-plannerview/legacy/src/components/User/UserForm.js | 用户创建/编辑表单 |
| PasswordForm | pharmagin-plannerview/legacy/src/components/User/PasswordForm.js | 修改密码表单 |
| UsersLimit | pharmagin-plannerview/legacy/src/components/UsersLimit/index.js | 用户数量限制配置 |
| UsersLimitForm | pharmagin-plannerview/legacy/src/components/UsersLimit/UsersLimitForm.js | 限制设置表单 |
| UsersLimitWhitelistForm | pharmagin-plannerview/legacy/src/components/UsersLimit/UsersLimitWhitelistForm.js | 域名白名单配置 |
| ForgetPwd | pharmagin-plannerview/legacy/src/components/ForgetPwd/ | 忘记密码组件 |
| auth.js | pharmagin-plannerview/legacy/src/utils/auth.js | 登录状态和路由权限检查 |
Salesview (Product User Management)
| Component | 路径 | 功能 |
|---|---|---|
| Login | pharmagin-salesview/src/pages/Login/index.js | 密码/SSO 登录页 |
| Login saga | pharmagin-salesview/src/pages/Login/saga.js | 调用 /api/v1/authenticate |
| Login Footer | pharmagin-salesview/src/components/Footer/LoginFooter.js | 登录页底部 |
| Users List | pharmagin-salesview/src/pages/Users/List/index.js | Product 用户管理 |
| ExternalContainer | pharmagin-salesview/src/pages/ExternalContainer/saga.js | SSO 入口(读取 cookie token) |
| ForgotPwd | pharmagin-salesview/src/pages/Login/ForgotPwd.js | 忘记密码 |
Speakerview (Speaker Auth)
| Component | 路径 | 功能 |
|---|---|---|
| Login | pharmagin-speakerview/legacy/src/components/Login/index.js | 登录页(含 Sign Up 和 Forgot Password) |
| SignUp | pharmagin-speakerview/legacy/src/components/Login/SignUp.js | Speaker 自注册流程(3步) |
| SignUpEmail | pharmagin-speakerview/legacy/src/components/Login/SignUpEmail.js | 步骤1:输入邮箱获取激活码 |
| SignUpActivationCode | pharmagin-speakerview/legacy/src/components/Login/SignUpActivationCode.js | 步骤2:验证激活码 |
| SignUpPassword | pharmagin-speakerview/legacy/src/components/Login/SignUpPassword.js | 步骤3:设置密码 |
| ForgotPwd | pharmagin-speakerview/legacy/src/components/Login/ForgotPwd.js | 忘记密码 |
| auth.js | pharmagin-speakerview/legacy/src/utils/auth.js | 角色路由权限(Admin/Speaker/Sales) |
5.2 Redux State Structure
Plannerview
// store structure
{
user: {
loading: false, // 加载状态
error: '', // 错误信息
users: { // 用户列表 (分页)
list: [],
pagination: {}
},
success: false
}
}
// sessionStorage
sessionStorage.accessToken // UUID token
sessionStorage.authorities // JSON 字符串 ["INSTANCE-1-MEETINGS", ...]Salesview
// Login saga 登录后存储
sessionStorage.accessToken // UUID token
// user, productId, authorities 通过 Redux action 传入 App state
// ExternalContainer (SSO) 硬编码测试数据 (!)
// pharmagin-salesview/src/pages/ExternalContainer/saga.js
// 包含完整的 mock user/authorities 数据Speakerview
// sessionStorage
sessionStorage.accessToken // UUID token
sessionStorage.user // JSON {role: 'Admin'|'Speaker'|'Sales', speakerId: ...}
sessionStorage.nav // 导航状态
sessionStorage.opened // 是否已打开5.3 Frontend Issues
Issue F1: Plannerview 权限检查使用硬编码字符串
auth.js:16:authorities.includes('INSTANCE-1-MEETINGS')- 硬编码 instanceId=1- 如果有多 instance 部署(虽然当前不存在),这些权限检查全部失效
- 路由名称和权限之间的映射是手动 switch-case
Issue F2: Speakerview 角色权限模型与后端完全不同
- 后端使用
COMPANY-{id}-{permission}格式的 authorities - 前端
auth.js使用简单字符串 role ('Admin', 'Speaker', 'Sales') - 两套逻辑不一致,前端未使用后端返回的 authorities 列表
Issue F3: ExternalContainer (SSO) saga 包含硬编码 mock 数据
pharmagin-salesview/src/pages/ExternalContainer/saga.js:7-133- SSO 流程完全被 mock 数据替代,实际 API 调用被注释掉
- 生产环境依赖 cookie 中的 token 调用
/v1/authenticate
Issue F4: 所有三个前端使用 sessionStorage 存储 accessToken
- sessionStorage 不跨标签页共享
- 每开一个新标签页需要重新登录
- 安全性问题:XSS 攻击可直接读取 sessionStorage
Issue F5: 前端密码明文传输
- 登录请求直接通过 HTTP POST 传递明文密码到
/v1/authenticate - 虽然生产环境有 HTTPS(Nginx),但开发环境可能裸跑 HTTP
6. Problem Summary
6.1 Critical Issues (must fix in rewrite)
| # | 问题 | 影响 | 相关代码 |
|---|---|---|---|
| C1 | Token 使用内存 Guava Cache,非 JWT | 不支持水平扩展,重启丢失所有会话 | AuthManager.java:27-28 |
| C2 | UserController 18 个端点完全无权限控制 | 任何登录用户可 CRUD 任何其他用户 | UserController.java 全文件 |
| C3 | SSO Token 不删除导致数据积累 | t_user_auth_token 表无限增长 | UserTokenAuthenticationProvider.java:47-51 |
| C4 | SSO Cookie 缺少 HttpOnly/Secure | XSS 可窃取认证 cookie | CustomAuthenticationSuccessHandler.java:161-162 |
| C5 | Legacy 密码明文比对 | 安全隐患 | UserPasswordAuthenticationProvider.java:66-67 |
| C6 | External-Authorization 无 claim 验证 | JWT 签名有效即获 SYSTEM 权限 | AuthInfoPreAuthFilter.java:78-99 |
| C7 | RoleController 角色/权限管理无权限控制 | 任何用户可创建角色、分配权限 | RoleController.java 全文件 |
| C8 | 双重角色体系(UserRole + ProductRole)并行 | 增加系统复杂度,权限模型不统一 | 对比 t_user_role vs t_product_role |
6.2 Design Defects (should improve)
| # | 问题 | 影响 | 相关代码 |
|---|---|---|---|
| D1 | 权限信息缓存不更新 | 管理员修改权限后不能实时生效 | AuthInfoUserDetailsService.java |
| D2 | Speakerview 前端角色模型与后端不一致 | 前端权限控制不完整 | speakerview/legacy/src/utils/auth.js |
| D3 | forgetPassword 使用内存 Cache | 重启丢失所有未用重置链接 | UserService.java:151-154 |
| D4 | UserCompany.productIds 使用 Object 类型 | 类型不安全,多处强转 | UserCompany.java:43 |
| D5 | SSO 双服务并存 | 维护成本高,代码重复 | pharmagin-sso/ vs pharmagin-login/ |
| D6 | API 路径设计不一致 | 学习成本高,容易混淆 | UserController vs ProductUserController |
| D7 | UserProductRole 缺少主键 | 无法安全操作单条记录 | UserProductRole.java:21-28 |
| D8 | Instance/Company 角色中间表无 Entity | 关联逻辑全在 Mapper SQL 中 | UserInstanceMapper.addRole() 等 |
| D9 | 用户限制检查效率低 | 每次都查全表计数再比较 | UsersLimitService.java:144-182 |
| D10 | 三个前端 Login 组件各自实现 | 代码重复,行为不一致 | 三个 Login/index.js |
6.3 Technical Debt (nice to have)
| # | 问题 | 影响 | 相关代码 |
|---|---|---|---|
| T1 | TestPermissionsController 应移除 | 测试代码不应在生产环境 | TestPermissionsController.java |
| T2 | "deactive" 拼写错误 | API 命名不规范 | 多处 endpoint 路径 |
| T3 | ExternalContainer saga 包含大量 mock 数据 | 开发遗留物 | ExternalContainer/saga.js |
| T4 | status 字段 Integer/String 混用 | 在 DTO 层和 Entity 层来回转换 | UserStatus enum |
| T5 | UserService 过于庞大 (1109 行) | 单一职责原则违反 | UserService.java |
| T6 | Swagger httpMethod 冗余定义 | 代码噪音 | 所有 Controller 的 @ApiOperation |
| T7 | Guava Cache 容量固定 initialCapacity(50) | 用户多时可能频繁淘汰 | AuthManager.java:27 |
| T8 | ProductRoleEnum 硬编码默认权限 | 修改默认权限需改代码重新部署 | ProductRoleEnum.java |
| T9 | sessionStorage 不跨标签页 | 用户体验差 | 三个前端的 auth 处理 |
| T10 | UserMapper 注入两次 | userMapper 和 unifiedUserMapper 相同 | UserService.java:97, 118 |
7. Rewrite Recommendations
7.1 认证系统重构
使用标准 JWT(JSON Web Token)替代 Guava Cache UUID
- 生成 RS256 签名的 JWT,包含 userId, username, authorities, exp 等 claims
- 支持无状态认证,可水平扩展
- 使用 Refresh Token 机制延长会话
统一 SSO 服务
- 合并 pharmagin-sso 和 pharmagin-login 为单一服务
- 使用 Spring Security SAML2(5.x+),支持多 IDP 配置
- SSO 成功后直接签发 JWT,而非依赖 Cookie + Token 中转
移除 Legacy 密码机制
- 运行一次性迁移脚本,清除所有
legacy_password字段 - 未迁移用户使用 "忘记密码" 流程重新设置
- 运行一次性迁移脚本,清除所有
7.2 权限模型统一
统一为单一 RBAC 模型
- 合并
t_user_role和t_product_role为统一的角色表 - 角色可以是 scope-specific(Instance/Company/Product),通过 scope 字段区分
- 消除双重角色体系的复杂性
- 合并
所有 API 端点添加权限检查
UserController所有端点添加@PreAuthorizeRoleController和ProductRoleController添加管理员权限检查- 使用统一的权限前缀
ADMIN:USER_MANAGEMENT等
权限变更实时生效
- 使用短有效期的 Access Token(如 15 分钟) + 长有效期的 Refresh Token
- 或引入 Redis 存储权限快照,管理员修改后清除对应缓存
7.3 前端统一
统一认证 SDK
- 提取公共 auth 模块,三个前端共享
- 使用 httpOnly Cookie 存储 JWT,而非 sessionStorage
- 实现自动 Token 刷新机制
统一 Speakerview 权限模型
- 前端使用后端返回的
authorities列表进行权限检查 - 移除硬编码的 'Admin'/'Speaker'/'Sales' 字符串角色
- 前端使用后端返回的
密码重置链接使用数据库存储
- 将重置 UUID 存储在数据库中,包含过期时间
- 支持多实例部署
7.4 安全加固
- SSO Cookie 启用
HttpOnly=true,Secure=true,SameSite=Strict - External-Authorization JWT 验证添加 audience, issuer, expiration 检查
- 密码重置链接使用加密 token 而非简单 UUID
- 登录接口添加 rate limiting 防暴力破解
- 移除
TestPermissionsController - 用户密码历史应只存储 BCrypt hash,清除所有
legacy_password记录