|
@@ -0,0 +1,547 @@
|
|
|
|
|
+// Package service 身份域业务逻辑(登录主链路编排:两步登录 / me / refresh / logout)。
|
|
|
|
|
+//
|
|
|
|
|
+// 分层职责(架构规格 §3.2 三层):
|
|
|
|
|
+// - 本包(Service):编排 Repository(数据)+ token.Issuer(凭证)+ redis.LoginTicketStore(票据),
|
|
|
|
|
+// 只返回 **sentinel 业务错误**(不泄漏 gorm/redis 原错——那是 Repository/存储层的事);
|
|
|
|
|
+// - Repository(identity/repository):平台级数据访问(本域不继承 scope.BaseRepo);
|
|
|
|
|
+// - 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] 调 ②,同一套协议,无独立端点。
|
|
|
|
|
+//
|
|
|
|
|
+// 安全要点:
|
|
|
|
|
+// - **防枚举(anti-user-enumeration)**:"账号不存在 / 密码错 / 账号禁用 / 无密码哈希" 一律统一
|
|
|
|
|
+// 文案"账号或密码错误"(code 1001),并对"账号不存在"路径补跑一次 argon2(timing floor),
|
|
|
|
|
+// 使响应时间不泄漏"该账号是否存在"。
|
|
|
|
|
+// - **惰性升级(ADR-016)**:命中旧算法(md5_salt/sha_salt)即 argon2 重哈希写回 + 撤该用户 token;
|
|
|
|
|
+// 升级写回失败则**阻断登录**(凭证半升级态 = 安全漏洞,宁可让用户重试)。
|
|
|
|
|
+// - **作用域校验**:② 必须服务端确认 (institutionId, campusId) 确属该用户的 membership,
|
|
|
|
|
+// 否则拒绝(客户端不能挑任意作用域签 token = 越权)。
|
|
|
|
|
+//
|
|
|
|
|
+// 依赖方向:service → repository(接口) + token + infra/redis + credential + model(全部为下层/同域),
|
|
|
|
|
+// 不依赖 handler/main(纯业务逻辑,可纯单测:mock repo + 真 Issuer + miniredis 票据)。
|
|
|
|
|
+package service
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "crypto/rand"
|
|
|
|
|
+ "encoding/hex"
|
|
|
|
|
+ stderrors "errors"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "time"
|
|
|
|
|
+
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/identity/credential"
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/identity/model"
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/identity/repository"
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/identity/token"
|
|
|
|
|
+ apperrors "yuansiwei/unified-backend/internal/infra/errors"
|
|
|
|
|
+ appredis "yuansiwei/unified-backend/internal/infra/redis"
|
|
|
|
|
+ orgmodel "yuansiwei/unified-backend/internal/org/model"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// errAuthFailed 登录主链路统一失败(防枚举:账号不存在/密码错/禁用/无哈希 全部用此)。
|
|
|
|
|
+// code 1001(认证类),msg 脱敏不区分具体原因。
|
|
|
|
|
+var errAuthFailed = apperrors.New(apperrors.CodeUnauth, "账号或密码错误")
|
|
|
|
|
+
|
|
|
|
|
+// defaultLoginTicketTTL 登录票据有效期(①→② 之间的窗口;5min 给前端留足操作时间,
|
|
|
|
|
+// 又不至于让泄露的 ticket 长期可换 token)。
|
|
|
|
|
+const defaultLoginTicketTTL = 5 * time.Minute
|
|
|
|
|
+
|
|
|
|
|
+// AuthService 身份域业务服务(两步登录 / me / refresh / logout 编排)。
|
|
|
|
|
+//
|
|
|
|
|
+// 字段(装配入口 NewAuthService 注入,均必填):
|
|
|
|
|
+// - users / identities / memberships:三仓储接口(生产注入 *repository.UserRepo 等具体实现,
|
|
|
|
|
+// 单测注入 mock——依赖倒置,纯单测不连 DB)。
|
|
|
|
|
+// - issuer:token 签发器(RS256 私钥 + access/refresh TTL + refresh 白名单/吊销存储,T4-1/T4-6 装配)。
|
|
|
|
|
+// - tickets:登录票据存储(Redis GETDEL 原子消费,T4-3 新增)。
|
|
|
|
|
+// - accessTTL:access 有效期(算 expiresAt + logout 撤销的剩余 TTL)。
|
|
|
|
|
+// - pubKeys / issuerStr / revocation:验签三件套(Refresh 轮换后回读新 access 的 claims 用,
|
|
|
|
|
+// 走 stateless 的 token.Verify;与验签中间件同源,来自 config + 同一吊销存储)。
|
|
|
|
|
+type AuthService struct {
|
|
|
|
|
+ users repository.UserRepository
|
|
|
|
|
+ identities repository.IdentityRepository
|
|
|
|
|
+ memberships repository.MembershipRepository
|
|
|
|
|
+ issuer *token.Issuer
|
|
|
|
|
+ tickets *appredis.LoginTicketStore
|
|
|
|
|
+
|
|
|
|
|
+ accessTTL time.Duration
|
|
|
|
|
+ ticketTTL time.Duration
|
|
|
|
|
+ pubKeys []string
|
|
|
|
|
+ issuerStr string
|
|
|
|
|
+ revocation token.RevocationStore
|
|
|
|
|
+
|
|
|
|
|
+ // dummyHash 防枚举的 timing floor:① "账号不存在"路径补跑一次 argon2 校验(对 dummyHash),
|
|
|
|
|
+ // 使该路径 CPU 成本与"存在 + argon2 用户"同量级,响应时间不泄漏账号存在性(稳态全 argon2 后对齐)。
|
|
|
|
|
+ dummyHash string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Config AuthService 装配参数(T4-6 main 按此构造;全部必填,缺则 NewAuthService 报错)。
|
|
|
|
|
+type Config struct {
|
|
|
|
|
+ Users repository.UserRepository
|
|
|
|
|
+ Identities repository.IdentityRepository
|
|
|
|
|
+ Memberships repository.MembershipRepository
|
|
|
|
|
+ Issuer *token.Issuer
|
|
|
|
|
+ Tickets *appredis.LoginTicketStore
|
|
|
|
|
+ AccessTTL time.Duration
|
|
|
|
|
+ PublicKeys []string
|
|
|
|
|
+ IssuerStr string
|
|
|
|
|
+ Revocation token.RevocationStore
|
|
|
|
|
+ // LoginTicketTTL 可选:登录票据有效期;0/缺省用 defaultLoginTicketTTL(5min)。
|
|
|
|
|
+ LoginTicketTTL time.Duration
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// NewAuthService 构造身份域业务服务(启动装配一次,全进程共享)。
|
|
|
|
|
+//
|
|
|
|
|
+// 入参:cfg Config:见 Config 字段说明。
|
|
|
|
|
+// 出参:
|
|
|
|
|
+// - *AuthService:就绪服务。
|
|
|
|
|
+// - error:必填依赖缺失(nil 检查,fail-fast——缺仓储/签发器/票据存储时不允许启动);
|
|
|
|
|
+// AccessTTL ≤ 0 视为配置错误(access 不能无有效期,安全红线)。
|
|
|
|
|
+func NewAuthService(cfg Config) (*AuthService, error) {
|
|
|
|
|
+ if cfg.Users == nil || cfg.Identities == nil || cfg.Memberships == nil {
|
|
|
|
|
+ return nil, apperrors.New(apperrors.CodeInternal, "service: 仓储接口未注入(users/identities/memberships 必填)")
|
|
|
|
|
+ }
|
|
|
|
|
+ if cfg.Issuer == nil {
|
|
|
|
|
+ return nil, apperrors.New(apperrors.CodeInternal, "service: token.Issuer 未注入")
|
|
|
|
|
+ }
|
|
|
|
|
+ if cfg.Tickets == nil {
|
|
|
|
|
+ return nil, apperrors.New(apperrors.CodeInternal, "service: 登录票据存储未注入(redis.LoginTicketStore 必填)")
|
|
|
|
|
+ }
|
|
|
|
|
+ if cfg.AccessTTL <= 0 {
|
|
|
|
|
+ return nil, apperrors.New(apperrors.CodeInternal, "service: AccessTTL 必须 > 0(access 无有效期 = 安全红线)")
|
|
|
|
|
+ }
|
|
|
|
|
+ ttl := cfg.LoginTicketTTL
|
|
|
|
|
+ if ttl <= 0 {
|
|
|
|
|
+ ttl = defaultLoginTicketTTL
|
|
|
|
|
+ }
|
|
|
|
|
+ // 防枚举 timing floor:启动时生成一个 argon2 dummy(64MB/1 次,~百毫秒,仅启动开销)。
|
|
|
|
|
+ // 失败不阻断启动(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,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---- 出参 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
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MembershipView 一条成员关系(登录第一步② 作用域选择列表项)。
|
|
|
|
|
+type MembershipView struct {
|
|
|
|
|
+ MembershipID int64 `json:"membershipId"`
|
|
|
|
|
+ InstitutionID int64 `json:"institutionId"`
|
|
|
|
|
+ CampusID int64 `json:"campusId"`
|
|
|
|
|
+ Role string `json:"role"` // 字符串角色码(super_admin/academic/...)
|
|
|
|
|
+ Status string `json:"status"` // active/inactive
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// LoginStep1 两步登录第一步出参(验账密成功,未签 token)。
|
|
|
|
|
+type LoginStep1 struct {
|
|
|
|
|
+ LoginTicket string `json:"loginTicket"` // 一次性票据(② 消费;5min 有效,单次)
|
|
|
|
|
+ User *UserView `json:"user"` // 登录用户基础信息
|
|
|
|
|
+ Memberships []MembershipView `json:"memberships"` // 可选作用域列表(空 = 该用户暂无机构归属)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TokenPair 两步登录第二步出参(签发的凭证对 + 作用域 + 用户)。
|
|
|
|
|
+type TokenPair struct {
|
|
|
|
|
+ AccessToken string `json:"accessToken"`
|
|
|
|
|
+ RefreshToken string `json:"refreshToken"`
|
|
|
|
|
+ ExpiresAt int64 `json:"expiresAt"` // access 过期(Unix 毫秒)
|
|
|
|
|
+ User *UserView `json:"user"`
|
|
|
|
|
+ Roles []string `json:"roles"` // 选定作用域的角色码(通常单条)
|
|
|
|
|
+ InstitutionID int64 `json:"institutionId"` // 选定作用域
|
|
|
|
|
+ CampusID int64 `json:"campusId"`
|
|
|
|
|
+ Permissions []string `json:"permissions"` // 占位:细粒度权限码(RBAC,T6 域补;当前空)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MeResult 登录态自查出参(/auth/me,菜单刷新恢复用)。
|
|
|
|
|
+type MeResult struct {
|
|
|
|
|
+ *UserView
|
|
|
|
|
+ Roles []string `json:"roles"`
|
|
|
|
|
+ InstitutionID int64 `json:"institutionId"` // 当前作用域(来自已验签 claims)
|
|
|
|
|
+ CampusID int64 `json:"campusId"`
|
|
|
|
|
+ Memberships []MembershipView `json:"memberships"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---- 两步登录 ----
|
|
|
|
|
+
|
|
|
|
|
+// Login 两步登录**第一步**:验账密(惰性升级)→ 发一次性票据 + 用户信息 + 作用域列表。
|
|
|
|
|
+//
|
|
|
|
|
+// 入参:
|
|
|
|
|
+// - ctx context.Context:请求上下文。
|
|
|
|
|
+// - identityType string:登录身份类型(model.IdentityType* 之一:username/phone/email/wechat)。
|
|
|
|
|
+// PC 当前走 "username"(账号);phone/email/wechat 后续开通。
|
|
|
|
|
+// - identifier string:归一前标识(username 原值 / phone 11 位 / email 大小写混 / wechat open_id)。
|
|
|
|
|
+// - password string:明文密码(仅内存调用栈,禁止日志/响应)。
|
|
|
|
|
+//
|
|
|
|
|
+// 出参:
|
|
|
|
|
+// - *LoginStep1:验账密成功(含一次性 loginTicket + memberships)。
|
|
|
|
|
+// - error(*AppError):
|
|
|
|
|
+// - errAuthFailed(1001):账号不存在 / 密码错 / 账号禁用 / 无哈希(**统一文案**,防枚举);
|
|
|
|
|
+// - ErrBadParam(2001):phone 非 11 位(入参格式非法);
|
|
|
|
|
+// - ErrDBError(5002):DB/Redis 异常(含票据写失败——阻断,不放行半套)。
|
|
|
|
|
+//
|
|
|
|
|
+// 时序纪律(防枚举 timing floor):
|
|
|
|
|
+// "账号不存在" 与 "无密码哈希" 路径都会补跑一次 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) {
|
|
|
|
|
+ return nil, apperrors.ErrBadParam
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ it, err := s.identities.GetByTypeIdentifier(ctx, identityType, ident)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ // 区分"不存在"(统一文案,防枚举)与"DB 故障"(5002)
|
|
|
|
|
+ if stderrors.Is(err, apperrors.ErrNotFound) {
|
|
|
|
|
+ s.burnTiming(ctx, password) // timing floor:补跑一次 argon2
|
|
|
|
|
+ return nil, errAuthFailed
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil, err // *AppError(ErrDBError 等)直接上抛
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ u, err := s.users.GetByCanonicalUserID(ctx, it.CanonicalUserID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ if stderrors.Is(err, apperrors.ErrNotFound) {
|
|
|
|
|
+ // 身份指向的用户不存在(数据不一致,理论不该发生)→ 统一文案 + timing floor
|
|
|
|
|
+ s.burnTiming(ctx, password)
|
|
|
|
|
+ return nil, errAuthFailed
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ // 账号状态:disabled/merged 一律统一文案(不泄漏"该账号存在但被禁用")
|
|
|
|
|
+ if u.Status != model.UserStatusActive {
|
|
|
|
|
+ s.burnTiming(ctx, password)
|
|
|
|
|
+ return nil, errAuthFailed
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 密码三列缺失(无哈希):统一文案 + timing floor(不区分"无哈希"与"密码错")
|
|
|
|
|
+ if u.PasswordHash == nil || *u.PasswordHash == "" {
|
|
|
|
|
+ s.burnTiming(ctx, password)
|
|
|
|
|
+ return nil, errAuthFailed
|
|
|
|
|
+ }
|
|
|
|
|
+ var salt, version string
|
|
|
|
|
+ if u.PasswordSalt != nil {
|
|
|
|
|
+ salt = *u.PasswordSalt
|
|
|
|
|
+ }
|
|
|
|
|
+ if u.HashVersion != nil {
|
|
|
|
|
+ version = *u.HashVersion
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ res, err := credential.Check(ctx, password, *u.PasswordHash, salt, version)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ // 未知 hash_version / 数据污染:拒绝登录(非密码错误,但对外统一文案防枚举;
|
|
|
|
|
+ // 根因进日志,运维凭 ErrUnknownVersion 排查)
|
|
|
|
|
+ s.burnTiming(ctx, password)
|
|
|
|
|
+ return nil, errAuthFailed
|
|
|
|
|
+ }
|
|
|
|
|
+ if !res.Match {
|
|
|
|
|
+ return nil, errAuthFailed
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 惰性升级(ADR-016 后半段):命中旧算法 → argon2 重哈希写回;失败则阻断登录(半升级态不放行)
|
|
|
|
|
+ if res.NeedsUpgrade {
|
|
|
|
|
+ newHash, herr := credential.HashArgon2(password)
|
|
|
|
|
+ if herr != nil {
|
|
|
|
|
+ return nil, apperrors.WithCause(apperrors.ErrInternal, herr)
|
|
|
|
|
+ }
|
|
|
|
|
+ if uerr := s.users.UpdatePasswordLazyUpgrade(ctx, u.CanonicalUserID, newHash); uerr != nil {
|
|
|
|
|
+ // 升级写回失败:阻断(凭证已改语义但未落库,放行 = 旧凭证仍可登录 = 安全漏洞)
|
|
|
|
|
+ return nil, uerr // *AppError(ErrDBError 3001/5002)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 注:首次登录场景无"该用户既有 token"需撤销(RevokeAll 用于"改密后踢旧端",非首登)。
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 取作用域列表(登录第一步展示用;空 = 该用户暂无机构归属,前端提示"联系管理员分配机构")
|
|
|
|
|
+ mergs, merr := s.memberships.ListByCanonicalUserID(ctx, u.CanonicalUserID)
|
|
|
|
|
+ if merr != nil {
|
|
|
|
|
+ return nil, merr // *AppError(ErrDBError)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 发一次性登录票据(Redis;写失败阻断——无票据则无法完成②,不放行半套凭证)
|
|
|
|
|
+ ticket, terr := s.newTicket()
|
|
|
|
|
+ if terr != nil {
|
|
|
|
|
+ return nil, apperrors.WithCause(apperrors.ErrInternal, terr)
|
|
|
|
|
+ }
|
|
|
|
|
+ if setErr := s.tickets.SetTicket(ctx, ticket, u.CanonicalUserID, s.ticketTTL); setErr != nil {
|
|
|
|
|
+ return nil, setErr // *AppError(ErrDBError)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return &LoginStep1{
|
|
|
|
|
+ LoginTicket: ticket,
|
|
|
|
|
+ User: toUserView(u),
|
|
|
|
|
+ Memberships: toMembershipViews(mergs),
|
|
|
|
|
+ }, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Issue 两步登录**第二步**:消费一次性票据 → 校验作用域归属 → 取 role → 签 access+refresh。
|
|
|
|
|
+//
|
|
|
|
|
+// 入参:
|
|
|
|
|
+// - ctx context.Context:请求上下文。
|
|
|
|
|
+// - loginTicket string:① 返回的一次性票据(32 hex;已消费/过期/不存在 → 拒绝)。
|
|
|
|
|
+// - institutionID int64:选定机构(scope 根;0=平台级超管)。
|
|
|
|
|
+// - campusID int64:选定校区(0=机构级/不限)。
|
|
|
|
|
+//
|
|
|
|
|
+// 出参:
|
|
|
|
|
+// - *TokenPair:签发成功的凭证对 + 选定作用域 + 用户。
|
|
|
|
|
+// - error(*AppError):
|
|
|
|
|
+// - ErrUnauth(1001):票据失效(不存在/已消费/过期,需重新走①);
|
|
|
|
|
+// - ErrForbidden(1003):该 (机构,校区) 不属于当前账号(越权)/ 该归属已失效(inactive);
|
|
|
|
|
+// - ErrDBError(5002):票据消费 Redis 故障(fail-closed)/ 取用户·成员关系 DB 故障 / 签发写白名单失败。
|
|
|
|
|
+//
|
|
|
|
|
+// 安全要点:
|
|
|
|
|
+// - 票据**原子消费**(Redis GETDEL):同一 ticket 并发双请求只有一个能签 token(防重放)。
|
|
|
|
|
+// - 作用域**服务端校验**:(institutionID, campusID) 必须命中该用户的 membership(防客户端挑任意作用域)。
|
|
|
|
|
+// - 用户状态**二次校验**:①→② 之间账号可能被禁用/合并,② 重新取 user 校验 active。
|
|
|
|
|
+func (s *AuthService) Issue(ctx context.Context, loginTicket string, institutionID, campusID int64) (*TokenPair, error) {
|
|
|
|
|
+ // 原子消费票据(Redis 故障 fail-closed;未命中返回 "")
|
|
|
|
|
+ uid, err := s.tickets.GetAndDeleteTicket(ctx, loginTicket)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed
|
|
|
|
|
+ }
|
|
|
|
|
+ if uid == "" {
|
|
|
|
|
+ return nil, apperrors.New(apperrors.CodeUnauth, "登录票据已失效,请重新登录")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 二次校验用户状态(①→② 间可能被禁用/合并)
|
|
|
|
|
+ u, err := s.users.GetByCanonicalUserID(ctx, uid)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ if stderrors.Is(err, apperrors.ErrNotFound) {
|
|
|
|
|
+ return nil, apperrors.New(apperrors.CodeUnauth, "登录票据已失效,请重新登录")
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if u.Status != model.UserStatusActive {
|
|
|
|
|
+ return nil, apperrors.New(apperrors.CodeUnauth, "登录票据已失效,请重新登录")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 作用域归属校验(防越权:客户端不能挑任意 (机构,校区))
|
|
|
|
|
+ m, err := s.memberships.GetByUserInstitutionCampus(ctx, uid, institutionID, campusID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ if stderrors.Is(err, apperrors.ErrNotFound) {
|
|
|
|
|
+ return nil, apperrors.New(apperrors.CodeForbidden, "该机构/校区不属于当前账号")
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if m.Status != "active" {
|
|
|
|
|
+ return nil, apperrors.New(apperrors.CodeForbidden, "该归属已失效,请重新登录")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ claims := token.Claims{
|
|
|
|
|
+ Sub: u.CanonicalUserID,
|
|
|
|
|
+ Roles: []string{m.Role},
|
|
|
|
|
+ InstitutionID: institutionID,
|
|
|
|
|
+ CampusID: campusID,
|
|
|
|
|
+ }
|
|
|
|
|
+ access, refresh, err := s.issuer.Issue(ctx, claims)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, apperrors.WithCause(apperrors.ErrDBError, err) // 写 refresh 白名单失败(Redis)等
|
|
|
|
|
+ }
|
|
|
|
|
+ return &TokenPair{
|
|
|
|
|
+ AccessToken: access,
|
|
|
|
|
+ RefreshToken: refresh,
|
|
|
|
|
+ ExpiresAt: time.Now().Add(s.accessTTL).UnixMilli(),
|
|
|
|
|
+ User: toUserView(u),
|
|
|
|
|
+ Roles: []string{m.Role},
|
|
|
|
|
+ InstitutionID: institutionID,
|
|
|
|
|
+ CampusID: campusID,
|
|
|
|
|
+ Permissions: []string{}, // RBAC 占位(T6 域补)
|
|
|
|
|
+ }, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Me 登录态自查(/auth/me):凭已验签 claims 回读用户 + 当前全部作用域(菜单刷新恢复)。
|
|
|
|
|
+//
|
|
|
|
|
+// 入参:
|
|
|
|
|
+// - ctx context.Context:请求上下文。
|
|
|
|
|
+// - claims token.Claims:验签中间件解析出的 claims(Sub 必填)。
|
|
|
|
|
+//
|
|
|
|
|
+// 出参:
|
|
|
|
|
+// - *MeResult:用户 + 角色 + 当前作用域 + 全部 membership 列表。
|
|
|
|
|
+// - error(*AppError):
|
|
|
|
|
+// - 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 {
|
|
|
|
|
+ return nil, err // ErrNotFound / ErrDBError
|
|
|
|
|
+ }
|
|
|
|
|
+ mergs, err := s.memberships.ListByCanonicalUserID(ctx, claims.Sub)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ return &MeResult{
|
|
|
|
|
+ UserView: toUserView(u),
|
|
|
|
|
+ Roles: claims.Roles,
|
|
|
|
|
+ InstitutionID: claims.InstitutionID,
|
|
|
|
|
+ CampusID: claims.CampusID,
|
|
|
|
|
+ Memberships: toMembershipViews(mergs),
|
|
|
|
|
+ }, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Refresh refresh 轮换:用旧 refresh 换新 access + 新 refresh(旧串立即失效,防重放)。
|
|
|
|
|
+//
|
|
|
|
|
+// 入参:
|
|
|
|
|
+// - ctx context.Context:请求上下文。
|
|
|
|
|
+// - oldRefresh string:旧 refresh 不透明串(48 hex)。
|
|
|
|
|
+//
|
|
|
|
|
+// 出参:
|
|
|
|
|
+// - *TokenPair:新凭证对(claims 从旧 refresh 载荷恢复,role/作用域原样)。
|
|
|
|
|
+// - error(*AppError):
|
|
|
|
|
+// - ErrTokenExpired(1002):refresh 无效(已轮换/已登出/过期/格式错)——前端跳重新登录;
|
|
|
|
|
+// - ErrDBError(5002):轮换写白名单失败 / 回读用户 DB 故障。
|
|
|
|
|
+//
|
|
|
|
|
+// 实现说明:Rotate 只回 access+refresh 不回 claims,故轮换后用 token.Verify 回读新 access 的 claims
|
|
|
|
|
+// (stateless,与验签中间件同源),再取用户视图——避免在 Issuer 上额外暴露"带 claims 的 Rotate"。
|
|
|
|
|
+func (s *AuthService) Refresh(ctx context.Context, oldRefresh string) (*TokenPair, error) {
|
|
|
|
|
+ newAccess, newRefresh, err := s.issuer.Rotate(ctx, oldRefresh)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ if stderrors.Is(err, token.ErrRefreshInvalid) {
|
|
|
|
|
+ return nil, apperrors.ErrTokenExpired // 1002:refresh 失效,前端跳登录
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil, apperrors.WithCause(apperrors.ErrDBError, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 回读新 access 的 claims(Sub/Roles/InstitutionID/CampusID)
|
|
|
|
|
+ claims, verr := token.Verify(ctx, s.pubKeys, s.revocation, s.issuerStr, newAccess)
|
|
|
|
|
+ if verr != nil {
|
|
|
|
|
+ // 理论上刚签发的 access 必可验签;失败属配置异常(公钥/issuer 不一致)→ 5001
|
|
|
|
|
+ return nil, apperrors.WithCause(apperrors.ErrInternal, verr)
|
|
|
|
|
+ }
|
|
|
|
|
+ u, uerr := s.users.GetByCanonicalUserID(ctx, claims.Sub)
|
|
|
|
|
+ if uerr != nil {
|
|
|
|
|
+ return nil, uerr // ErrNotFound / ErrDBError
|
|
|
|
|
+ }
|
|
|
|
|
+ return &TokenPair{
|
|
|
|
|
+ AccessToken: newAccess,
|
|
|
|
|
+ RefreshToken: newRefresh,
|
|
|
|
|
+ ExpiresAt: time.Now().Add(s.accessTTL).UnixMilli(),
|
|
|
|
|
+ User: toUserView(u),
|
|
|
|
|
+ Roles: claims.Roles,
|
|
|
|
|
+ InstitutionID: claims.InstitutionID,
|
|
|
|
|
+ CampusID: claims.CampusID,
|
|
|
|
|
+ Permissions: []string{},
|
|
|
|
|
+ }, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Logout 登出:吊销当前 access jti(黑名单)+ 删除 refresh 白名单键(幂等、尽力而为)。
|
|
|
|
|
+//
|
|
|
|
|
+// 入参:
|
|
|
|
|
+// - ctx context.Context:请求上下文。
|
|
|
|
|
+// - refreshToken string:当前 refresh 串(删其白名单键,防再换 access)。
|
|
|
|
|
+// - accessJTI string:当前 access 的 jti(拉黑;来自已验签 claims 的 ID)。
|
|
|
|
|
+// - accessRemaining time.Duration:该 access 剩余有效期(黑名单键 TTL;≤0 时存储跳过写——本已过期)。
|
|
|
|
|
+//
|
|
|
|
|
+// 出参:
|
|
|
|
|
+// - error:**登出对客户端恒为"成功"(本地态必清)**,本方法的错误仅用于服务端告警——
|
|
|
|
|
+// Handler 层对 logout 恒返回 200(对齐前端 doLogout 的幂等语义:后端失败也清本地)。
|
|
|
|
|
+// 故调用方(Handler)可忽略 err 直接返回成功;此处保留 error 供日志/告警,不阻断登出。
|
|
|
|
|
+// - 幂等:refresh 键已删/不存在 → DeleteRefreshToken 不报错(天然幂等)。
|
|
|
|
|
+func (s *AuthService) Logout(ctx context.Context, refreshToken, accessJTI string, accessRemaining time.Duration) error {
|
|
|
|
|
+ // 删 refresh 白名单(尽力而为;格式非法/Redis 故障都不阻断登出——access 30min 后自然过期 + 黑名单兜底)
|
|
|
|
|
+ if refreshToken != "" {
|
|
|
|
|
+ if err := s.issuer.DeleteRefreshToken(ctx, refreshToken); err != nil {
|
|
|
|
|
+ // 记录但不阻断(登出幂等):返回 err 供 Handler 记日志
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 吊销当前 access(尽力而为;jti 为空或 remaining≤0 时存储跳过)
|
|
|
|
|
+ if accessJTI != "" {
|
|
|
|
|
+ if err := s.issuer.RevokeAll(ctx, accessJTI, accessRemaining); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---- 私有工具 ----
|
|
|
|
|
+
|
|
|
|
|
+// normalize 入参归一(P0-02 数据归一规则):email 小写 / phone 去空白 / 其余 trim。
|
|
|
|
|
+// 归一在 Service 入参处理(Repository 不做归一,见 identity_repo.go 注释)。
|
|
|
|
|
+func (s *AuthService) normalize(identityType, identifier string) string {
|
|
|
|
|
+ switch identityType {
|
|
|
|
|
+ case model.IdentityTypeEmail:
|
|
|
|
|
+ return strings.ToLower(strings.TrimSpace(identifier))
|
|
|
|
|
+ default:
|
|
|
|
|
+ return strings.TrimSpace(identifier)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// burnTiming 防枚举 timing floor:对 dummyHash 跑一次 argon2 校验(丢弃结果),
|
|
|
|
|
+// 使"账号不存在"路径的 CPU 成本与"存在 + argon2 用户"同量级。
|
|
|
|
|
+// ctx 预留(当前 credential.VerifyArgon2 不消费 ctx,传 _ 占位)。
|
|
|
|
|
+func (s *AuthService) burnTiming(_ context.Context, password string) {
|
|
|
|
|
+ _ = credential.VerifyArgon2(s.dummyHash, password)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// newTicket 生成一次性登录票据(16 字节随机 → 32 hex)。
|
|
|
|
|
+// crypto/rand 故障概率≈0;失败返回 error(调用方阻断登录,不放行半套凭证)。
|
|
|
|
|
+func (s *AuthService) newTicket() (string, error) {
|
|
|
|
|
+ buf := make([]byte, 16)
|
|
|
|
|
+ if _, err := rand.Read(buf); err != nil {
|
|
|
|
|
+ return "", err
|
|
|
|
|
+ }
|
|
|
|
|
+ return hex.EncodeToString(buf), nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// toUserView 用户实体 → 对外视图(密码三列 model 已 json:"-",此处再收口取可空字段)。
|
|
|
|
|
+func toUserView(u *model.User) *UserView {
|
|
|
|
|
+ if u == nil {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+ v := &UserView{ID: u.CanonicalUserID, PersonType: u.PersonType, Status: u.Status}
|
|
|
|
|
+ if u.Username != nil {
|
|
|
|
|
+ v.Username = *u.Username
|
|
|
|
|
+ }
|
|
|
|
|
+ if u.DisplayName != nil {
|
|
|
|
|
+ v.RealName = *u.DisplayName
|
|
|
|
|
+ }
|
|
|
|
|
+ return v
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// toMembershipViews 成员关系切片 → 对外视图(空切片保持非 nil,JSON 序列化为 [] 而非 null)。
|
|
|
|
|
+func toMembershipViews(list []orgmodel.Membership) []MembershipView {
|
|
|
|
|
+ out := make([]MembershipView, 0, len(list))
|
|
|
|
|
+ for _, m := range list {
|
|
|
|
|
+ out = append(out, MembershipView{
|
|
|
|
|
+ MembershipID: m.MembershipID,
|
|
|
|
|
+ InstitutionID: m.InstitutionID,
|
|
|
|
|
+ CampusID: m.CampusID,
|
|
|
|
|
+ Role: m.Role,
|
|
|
|
|
+ Status: m.Status,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// isValidPhone 手机号基本校验(11 位数字;中文手机号 1 开头,此处仅校验位数+数字,不过度限制)。
|
|
|
|
|
+func isValidPhone(p string) bool {
|
|
|
|
|
+ if len(p) != 11 {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ for i := 0; i < len(p); i++ {
|
|
|
|
|
+ if p[i] < '0' || p[i] > '9' {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return true
|
|
|
|
|
+}
|