|
|
@@ -7,11 +7,12 @@
|
|
|
// - Handler(T4-4):把 Service 的 *AppError 映射为统一响应信封(response.Fail)。
|
|
|
//
|
|
|
// 两步登录协议(T4 契约,2026-11-06 拍板):
|
|
|
-// ① Login(ctx, identityType, identifier, password):验账密 + 惰性升级 → 返回 loginTicket + 用户基础信息 +
|
|
|
-// memberships[](机构/校区/角色,供前端"作用域选择页"展示)。**不签 token**。
|
|
|
-// ② Issue(ctx, loginTicket, institutionId, campusId):消费一次性票据(Redis GETDEL 原子)→ 服务端校验
|
|
|
-// (机构,校区) 确属该用户(防越权)→ 取该条 role → 签 access+refresh。
|
|
|
-// 移动端"自动取第一条" = ①之后立即用 memberships[0] 调 ②,同一套协议,无独立端点。
|
|
|
+//
|
|
|
+// ① Login(ctx, identityType, identifier, password):验账密 + 惰性升级 → 返回 loginTicket + 用户基础信息 +
|
|
|
+// memberships[](机构/校区/角色,供前端"作用域选择页"展示)。**不签 token**。
|
|
|
+// ② Issue(ctx, loginTicket, institutionId, campusId):消费一次性票据(Redis GETDEL 原子)→ 服务端校验
|
|
|
+// (机构,校区) 确属该用户(防越权)→ 取该条 role → 签 access+refresh。
|
|
|
+// 移动端"自动取第一条" = ①之后立即用 memberships[0] 调 ②,同一套协议,无独立端点。
|
|
|
//
|
|
|
// 安全要点:
|
|
|
// - **防枚举(anti-user-enumeration)**:"账号不存在 / 密码错 / 账号禁用 / 无密码哈希" 一律统一
|
|
|
@@ -41,6 +42,7 @@ import (
|
|
|
apperrors "yuansiwei/unified-backend/internal/infra/errors"
|
|
|
appredis "yuansiwei/unified-backend/internal/infra/redis"
|
|
|
orgmodel "yuansiwei/unified-backend/internal/org/model"
|
|
|
+ "yuansiwei/unified-backend/internal/org/rbac"
|
|
|
)
|
|
|
|
|
|
// errAuthFailed 登录主链路统一失败(防枚举:账号不存在/密码错/禁用/无哈希 全部用此)。
|
|
|
@@ -73,6 +75,9 @@ type AuthService struct {
|
|
|
pubKeys []string
|
|
|
issuerStr string
|
|
|
revocation token.RevocationStore
|
|
|
+ // menuProvider 角色→菜单树/权限码 解析器(T6.5-d;可空——未注入时 menuTree/permissions 恒空集,
|
|
|
+ // 菜单全隐/按钮全隐,不阻断登录。生产装配注入 *rbac.Resolver)。
|
|
|
+ menuProvider MenuProvider
|
|
|
|
|
|
// dummyHash 防枚举的 timing floor:① "账号不存在"路径补跑一次 argon2 校验(对 dummyHash),
|
|
|
// 使该路径 CPU 成本与"存在 + argon2 用户"同量级,响应时间不泄漏账号存在性(稳态全 argon2 后对齐)。
|
|
|
@@ -92,6 +97,9 @@ type Config struct {
|
|
|
Revocation token.RevocationStore
|
|
|
// LoginTicketTTL 可选:登录票据有效期;0/缺省用 defaultLoginTicketTTL(5min)。
|
|
|
LoginTicketTTL time.Duration
|
|
|
+ // MenuProvider 可选:角色→菜单树/权限码 解析器(T6.5-d,注入 *rbac.Resolver);
|
|
|
+ // nil 时 menuTree/permissions 恒空集(菜单全隐,不阻断登录——RBAC 未装配时的兜底语义)。
|
|
|
+ MenuProvider MenuProvider
|
|
|
}
|
|
|
|
|
|
// NewAuthService 构造身份域业务服务(启动装配一次,全进程共享)。
|
|
|
@@ -122,29 +130,57 @@ func NewAuthService(cfg Config) (*AuthService, error) {
|
|
|
// 失败不阻断启动(dummy 仅优化计时,非安全必需)——但会告警级降级。
|
|
|
dummy, _ := credential.HashArgon2("timing-equalization-dummy")
|
|
|
return &AuthService{
|
|
|
- users: cfg.Users,
|
|
|
- identities: cfg.Identities,
|
|
|
- memberships: cfg.Memberships,
|
|
|
- issuer: cfg.Issuer,
|
|
|
- tickets: cfg.Tickets,
|
|
|
- accessTTL: cfg.AccessTTL,
|
|
|
- ticketTTL: ttl,
|
|
|
- pubKeys: cfg.PublicKeys,
|
|
|
- issuerStr: cfg.IssuerStr,
|
|
|
- revocation: cfg.Revocation,
|
|
|
- dummyHash: dummy,
|
|
|
+ users: cfg.Users,
|
|
|
+ identities: cfg.Identities,
|
|
|
+ memberships: cfg.Memberships,
|
|
|
+ issuer: cfg.Issuer,
|
|
|
+ tickets: cfg.Tickets,
|
|
|
+ accessTTL: cfg.AccessTTL,
|
|
|
+ ticketTTL: ttl,
|
|
|
+ pubKeys: cfg.PublicKeys,
|
|
|
+ issuerStr: cfg.IssuerStr,
|
|
|
+ revocation: cfg.Revocation,
|
|
|
+ menuProvider: cfg.MenuProvider,
|
|
|
+ dummyHash: dummy,
|
|
|
}, nil
|
|
|
}
|
|
|
|
|
|
+// resolveMenu 按角色解析菜单树 + 权限码(T6.5-d;MenuProvider 未注入时返回空集,不阻断登录)。
|
|
|
+//
|
|
|
+// 入参:
|
|
|
+// - ctx 请求上下文;
|
|
|
+// - roleCode 选定作用域的角色码(token 选定 membership 的 role;通常单条)。
|
|
|
+//
|
|
|
+// 出参:
|
|
|
+// - []MenuNode:可见菜单树(**非 nil**,解析失败/未注入时返回 []);
|
|
|
+// - []string:全量权限点 code 集(**非 nil**,解析失败/未注入时返回 [])。
|
|
|
+//
|
|
|
+// 语义:菜单/权限是"展示层"(拍板),DB 异常不应阻断已签发的 token 下发——
|
|
|
+// 返回空集 + 前端菜单全隐,用户仍可登录(数据层权限由路由白名单/Scoping 兜底)。
|
|
|
+func (s *AuthService) resolveMenu(ctx context.Context, roleCode string) (menu []MenuNode, perms []string) {
|
|
|
+ menu = []MenuNode{}
|
|
|
+ perms = []string{}
|
|
|
+ if s.menuProvider == nil {
|
|
|
+ return menu, perms
|
|
|
+ }
|
|
|
+ if m, err := s.menuProvider.MenuTree(ctx, roleCode); err == nil && m != nil {
|
|
|
+ menu = m
|
|
|
+ }
|
|
|
+ if p, err := s.menuProvider.Permissions(ctx, roleCode); err == nil && p != nil {
|
|
|
+ perms = p
|
|
|
+ }
|
|
|
+ return menu, perms
|
|
|
+}
|
|
|
+
|
|
|
// ---- 出参 DTO(Service 层领域对象;Handler 层映射为统一响应信封 R<T>)----
|
|
|
|
|
|
// UserView 用户对外视图(不含密码三列——model 已 json:"-" 脱敏,这里再显式收口,防误带出)。
|
|
|
type UserView struct {
|
|
|
- ID string `json:"id"` // canonical_user_id(ULID)
|
|
|
- Username string `json:"username"` // 用户名(可空)
|
|
|
- RealName string `json:"realName"` // 真实姓名(可空)
|
|
|
- PersonType string `json:"personType"` // 业务身份:student/teacher/parent/staff
|
|
|
- Status string `json:"status"` // active/disabled/merged
|
|
|
+ ID string `json:"id"` // canonical_user_id(ULID)
|
|
|
+ Username string `json:"username"` // 用户名(可空)
|
|
|
+ RealName string `json:"realName"` // 真实姓名(可空)
|
|
|
+ PersonType string `json:"personType"` // 业务身份:student/teacher/parent/staff
|
|
|
+ Status string `json:"status"` // active/disabled/merged
|
|
|
}
|
|
|
|
|
|
// MembershipView 一条成员关系(登录第一步② 作用域选择列表项)。
|
|
|
@@ -152,7 +188,7 @@ type MembershipView struct {
|
|
|
MembershipID int64 `json:"membershipId"`
|
|
|
InstitutionID int64 `json:"institutionId"`
|
|
|
CampusID int64 `json:"campusId"`
|
|
|
- Role string `json:"role"` // 字符串角色码(super_admin/academic/...)
|
|
|
+ Role string `json:"role"` // 字符串角色码(super_admin/academic/...)
|
|
|
Status string `json:"status"` // active/inactive
|
|
|
}
|
|
|
|
|
|
@@ -163,7 +199,14 @@ type LoginStep1 struct {
|
|
|
Memberships []MembershipView `json:"memberships"` // 可选作用域列表(空 = 该用户暂无机构归属)
|
|
|
}
|
|
|
|
|
|
-// TokenPair 两步登录第二步出参(签发的凭证对 + 作用域 + 用户)。
|
|
|
+// TokenPair 两步登录第二步出参(签发的凭证对 + 作用域 + 用户 + RBAC 菜单/权限码,T6.5-d 起)。
|
|
|
+//
|
|
|
+// MenuTree / Permissions 由注入的 MenuProvider(rbac.Resolver)按选定角色解析:
|
|
|
+// - MenuTree:可见菜单树(PC 前端原样渲染,不二次过滤;契约见 rbac.MenuNode);
|
|
|
+// - Permissions:全量权限点 code 集(含 button,前端 v-perm 依据);
|
|
|
+//
|
|
|
+// 两者**非 nil**(无可见菜单/权限 = [],JSON 序列化为 [] 而非 null);
|
|
|
+// 未注入 MenuProvider 时恒为空集(菜单全隐、按钮全隐,不阻断登录——RBAC 子系统装配失败的兜底)。
|
|
|
type TokenPair struct {
|
|
|
AccessToken string `json:"accessToken"`
|
|
|
RefreshToken string `json:"refreshToken"`
|
|
|
@@ -172,16 +215,38 @@ type TokenPair struct {
|
|
|
Roles []string `json:"roles"` // 选定作用域的角色码(通常单条)
|
|
|
InstitutionID int64 `json:"institutionId"` // 选定作用域
|
|
|
CampusID int64 `json:"campusId"`
|
|
|
- Permissions []string `json:"permissions"` // 占位:细粒度权限码(RBAC,T6 域补;当前空)
|
|
|
+ MenuTree []MenuNode `json:"menuTree"` // 可见菜单树(按选定角色过滤后;PC 原样渲染)
|
|
|
+ Permissions []string `json:"permissions"` // 全量权限点 code 集(含 button,v-perm 依据)
|
|
|
+}
|
|
|
+
|
|
|
+// MenuNode 菜单树节点,直接复用 rbac.MenuNode(PC 前端契约的权威定义在 org/rbac 包)。
|
|
|
+// 不在此包重定义:*rbac.Resolver.MenuTree 返回 []rbac.MenuNode,复用同一类型使
|
|
|
+// MenuProvider 接口与生产实现签名严格一致(否则需装配层做类型转换,徒增漂移风险)。
|
|
|
+// 依赖方向说明:identity/service 已 import org/model(登录引用 membership),再 import
|
|
|
+// org/rbac(纯解析器,无 DB/handler 依赖)不破坏分层——rbac 是更下层的展示逻辑。
|
|
|
+type MenuNode = rbac.MenuNode
|
|
|
+
|
|
|
+// MenuProvider 角色 → 权限点 code 集 / 菜单树 解析器(T6.5-d 引入的依赖倒置接口)。
|
|
|
+//
|
|
|
+// 生产实现:*rbac.Resolver(org/rbac 包,TTL 30s 进程内缓存;超管全量、普通查 t_role_permission)。
|
|
|
+// 接口定义在消费方 identity/service(消费方定义接口,供装配层注入 *rbac.Resolver / 单测注入 fake);
|
|
|
+// rbac.Resolver 的方法签名与本接口一致,故 *rbac.Resolver 隐式满足(编译期断言在 main.go)。
|
|
|
+type MenuProvider interface {
|
|
|
+ // Permissions 角色 code 集(超管返回全量;普通角色查矩阵;空集 = 该角色无授权)。
|
|
|
+ Permissions(ctx context.Context, roleCode string) ([]string, error)
|
|
|
+ // MenuTree 角色可见菜单树(button 不进树;父缺失提升顶级;非 nil 切片)。
|
|
|
+ MenuTree(ctx context.Context, roleCode string) ([]rbac.MenuNode, error)
|
|
|
}
|
|
|
|
|
|
-// MeResult 登录态自查出参(/auth/me,菜单刷新恢复用)。
|
|
|
+// MeResult 登录态自查出参(/auth/me,菜单刷新恢复用;T6.5-d 起含 MenuTree/Permissions)。
|
|
|
type MeResult struct {
|
|
|
*UserView
|
|
|
Roles []string `json:"roles"`
|
|
|
InstitutionID int64 `json:"institutionId"` // 当前作用域(来自已验签 claims)
|
|
|
CampusID int64 `json:"campusId"`
|
|
|
Memberships []MembershipView `json:"memberships"`
|
|
|
+ MenuTree []MenuNode `json:"menuTree"` // 可见菜单树(同 TokenPair;菜单刷新恢复)
|
|
|
+ Permissions []string `json:"permissions"` // 全量权限点 code 集
|
|
|
}
|
|
|
|
|
|
// ---- 两步登录 ----
|
|
|
@@ -198,13 +263,14 @@ type MeResult struct {
|
|
|
// 出参:
|
|
|
// - *LoginStep1:验账密成功(含一次性 loginTicket + memberships)。
|
|
|
// - error(*AppError):
|
|
|
-// - errAuthFailed(1001):账号不存在 / 密码错 / 账号禁用 / 无哈希(**统一文案**,防枚举);
|
|
|
-// - ErrBadParam(2001):phone 非 11 位(入参格式非法);
|
|
|
-// - ErrDBError(5002):DB/Redis 异常(含票据写失败——阻断,不放行半套)。
|
|
|
+// - errAuthFailed(1001):账号不存在 / 密码错 / 账号禁用 / 无哈希(**统一文案**,防枚举);
|
|
|
+// - ErrBadParam(2001):phone 非 11 位(入参格式非法);
|
|
|
+// - ErrDBError(5002):DB/Redis 异常(含票据写失败——阻断,不放行半套)。
|
|
|
//
|
|
|
// 时序纪律(防枚举 timing floor):
|
|
|
-// "账号不存在" 与 "无密码哈希" 路径都会补跑一次 argon2(对 dummyHash),使响应时间与
|
|
|
-// "存在 + argon2 用户"同量级——稳态(全员惰性升级到 argon2)下不可区分账号是否存在。
|
|
|
+//
|
|
|
+// "账号不存在" 与 "无密码哈希" 路径都会补跑一次 argon2(对 dummyHash),使响应时间与
|
|
|
+// "存在 + argon2 用户"同量级——稳态(全员惰性升级到 argon2)下不可区分账号是否存在。
|
|
|
func (s *AuthService) Login(ctx context.Context, identityType, identifier, password string) (*LoginStep1, error) {
|
|
|
ident := s.normalize(identityType, identifier)
|
|
|
if identityType == model.IdentityTypePhone && !isValidPhone(ident) {
|
|
|
@@ -306,9 +372,9 @@ func (s *AuthService) Login(ctx context.Context, identityType, identifier, passw
|
|
|
// 出参:
|
|
|
// - *TokenPair:签发成功的凭证对 + 选定作用域 + 用户。
|
|
|
// - error(*AppError):
|
|
|
-// - ErrUnauth(1001):票据失效(不存在/已消费/过期,需重新走①);
|
|
|
-// - ErrForbidden(1003):该 (机构,校区) 不属于当前账号(越权)/ 该归属已失效(inactive);
|
|
|
-// - ErrDBError(5002):票据消费 Redis 故障(fail-closed)/ 取用户·成员关系 DB 故障 / 签发写白名单失败。
|
|
|
+// - ErrUnauth(1001):票据失效(不存在/已消费/过期,需重新走①);
|
|
|
+// - ErrForbidden(1003):该 (机构,校区) 不属于当前账号(越权)/ 该归属已失效(inactive);
|
|
|
+// - ErrDBError(5002):票据消费 Redis 故障(fail-closed)/ 取用户·成员关系 DB 故障 / 签发写白名单失败。
|
|
|
//
|
|
|
// 安全要点:
|
|
|
// - 票据**原子消费**(Redis GETDEL):同一 ticket 并发双请求只有一个能签 token(防重放)。
|
|
|
@@ -358,6 +424,7 @@ func (s *AuthService) Issue(ctx context.Context, loginTicket string, institution
|
|
|
if err != nil {
|
|
|
return nil, apperrors.WithCause(apperrors.ErrDBError, err) // 写 refresh 白名单失败(Redis)等
|
|
|
}
|
|
|
+ menu, perms := s.resolveMenu(ctx, m.Role) // T6.5-d:按选定角色下发菜单树 + 权限码(失败→空集,不阻断)
|
|
|
return &TokenPair{
|
|
|
AccessToken: access,
|
|
|
RefreshToken: refresh,
|
|
|
@@ -366,7 +433,8 @@ func (s *AuthService) Issue(ctx context.Context, loginTicket string, institution
|
|
|
Roles: []string{m.Role},
|
|
|
InstitutionID: institutionID,
|
|
|
CampusID: campusID,
|
|
|
- Permissions: []string{}, // RBAC 占位(T6 域补)
|
|
|
+ MenuTree: menu,
|
|
|
+ Permissions: perms,
|
|
|
}, nil
|
|
|
}
|
|
|
|
|
|
@@ -379,8 +447,8 @@ func (s *AuthService) Issue(ctx context.Context, loginTicket string, institution
|
|
|
// 出参:
|
|
|
// - *MeResult:用户 + 角色 + 当前作用域 + 全部 membership 列表。
|
|
|
// - error(*AppError):
|
|
|
-// - ErrNotFound(3001):claims.Sub 对应用户不存在(token 仍在有效期但用户被删,罕见——前端按登出处理);
|
|
|
-// - ErrDBError(5002):取用户/成员关系 DB 故障。
|
|
|
+// - ErrNotFound(3001):claims.Sub 对应用户不存在(token 仍在有效期但用户被删,罕见——前端按登出处理);
|
|
|
+// - ErrDBError(5002):取用户/成员关系 DB 故障。
|
|
|
func (s *AuthService) Me(ctx context.Context, claims token.Claims) (*MeResult, error) {
|
|
|
u, err := s.users.GetByCanonicalUserID(ctx, claims.Sub)
|
|
|
if err != nil {
|
|
|
@@ -390,12 +458,19 @@ func (s *AuthService) Me(ctx context.Context, claims token.Claims) (*MeResult, e
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
+ roleCode := ""
|
|
|
+ if len(claims.Roles) > 0 {
|
|
|
+ roleCode = claims.Roles[0] // 主角色(签发侧保证多角色时主角色在前)
|
|
|
+ }
|
|
|
+ menu, perms := s.resolveMenu(ctx, roleCode) // T6.5-d:菜单刷新恢复(同 Issue)
|
|
|
return &MeResult{
|
|
|
UserView: toUserView(u),
|
|
|
Roles: claims.Roles,
|
|
|
InstitutionID: claims.InstitutionID,
|
|
|
CampusID: claims.CampusID,
|
|
|
Memberships: toMembershipViews(mergs),
|
|
|
+ MenuTree: menu,
|
|
|
+ Permissions: perms,
|
|
|
}, nil
|
|
|
}
|
|
|
|
|
|
@@ -408,8 +483,8 @@ func (s *AuthService) Me(ctx context.Context, claims token.Claims) (*MeResult, e
|
|
|
// 出参:
|
|
|
// - *TokenPair:新凭证对(claims 从旧 refresh 载荷恢复,role/作用域原样)。
|
|
|
// - error(*AppError):
|
|
|
-// - ErrTokenExpired(1002):refresh 无效(已轮换/已登出/过期/格式错)——前端跳重新登录;
|
|
|
-// - ErrDBError(5002):轮换写白名单失败 / 回读用户 DB 故障。
|
|
|
+// - ErrTokenExpired(1002):refresh 无效(已轮换/已登出/过期/格式错)——前端跳重新登录;
|
|
|
+// - ErrDBError(5002):轮换写白名单失败 / 回读用户 DB 故障。
|
|
|
//
|
|
|
// 实现说明:Rotate 只回 access+refresh 不回 claims,故轮换后用 token.Verify 回读新 access 的 claims
|
|
|
// (stateless,与验签中间件同源),再取用户视图——避免在 Issuer 上额外暴露"带 claims 的 Rotate"。
|
|
|
@@ -431,6 +506,11 @@ func (s *AuthService) Refresh(ctx context.Context, oldRefresh string) (*TokenPai
|
|
|
if uerr != nil {
|
|
|
return nil, uerr // ErrNotFound / ErrDBError
|
|
|
}
|
|
|
+ roleCode := ""
|
|
|
+ if len(claims.Roles) > 0 {
|
|
|
+ roleCode = claims.Roles[0]
|
|
|
+ }
|
|
|
+ menu, perms := s.resolveMenu(ctx, roleCode) // T6.5-d:轮换后同补菜单/权限(claims 原样恢复角色)
|
|
|
return &TokenPair{
|
|
|
AccessToken: newAccess,
|
|
|
RefreshToken: newRefresh,
|
|
|
@@ -439,7 +519,8 @@ func (s *AuthService) Refresh(ctx context.Context, oldRefresh string) (*TokenPai
|
|
|
Roles: claims.Roles,
|
|
|
InstitutionID: claims.InstitutionID,
|
|
|
CampusID: claims.CampusID,
|
|
|
- Permissions: []string{},
|
|
|
+ MenuTree: menu,
|
|
|
+ Permissions: perms,
|
|
|
}, nil
|
|
|
}
|
|
|
|