package auth import ( "context" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "strconv" "strings" "time" jwt "github.com/golang-jwt/jwt/v5" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/xerr" authdomain "hyapp/services/user-service/internal/domain/auth" userdomain "hyapp/services/user-service/internal/domain/user" ) const ( defaultRefreshRotationGrace = 30 * time.Second refreshReplayCipherVersion = "v1" refreshMetricSuccess = "refresh_success" refreshMetricGraceHit = "grace_hit" refreshMetricTokenReuse = "token_reuse" refreshMetricFamilyRevoked = "family_revoked" revokeReasonRefreshReuse = "REFRESH_TOKEN_REUSE" sessionDenylistSafetyMargin = 60 * time.Second // 仓库历史曾签发 7 天 access JWT;即使配置降到 1 小时,首次撤销也必须覆盖这批尚未到期的历史 token。 sessionDenylistHistoricalAccessTTLFloor = 7 * 24 * time.Hour ) // RefreshToken 轮换 refresh token;直接父代在短暂宽限期内只复用首次提交的 token pair,不能产生分叉。 func (s *Service) RefreshToken(ctx context.Context, refreshToken string, deviceID string, meta Meta) (authdomain.Token, error) { if strings.TrimSpace(refreshToken) == "" || strings.TrimSpace(deviceID) == "" { // refresh token 和 device_id 共同定位并校验会话归属。 return authdomain.Token{}, xerr.New(xerr.InvalidArgument, "refresh_token and device_id are required") } if s.authRepository == nil { return authdomain.Token{}, xerr.New(xerr.Unavailable, "auth repository is not configured") } nowMs := s.now().UnixMilli() refreshTokenHash := hashRefreshToken(refreshToken) refreshRequestID, err := normalizeRefreshRequestID(meta.RefreshRequestID, refreshTokenHash) if err != nil { return authdomain.Token{}, err } // 必须读取已轮换行才能区分直接父代 grace 和更早代 replay;数据库仍只用 token hash 查询。 session, err := s.authRepository.FindSessionByRefreshHash(ctx, refreshTokenHash) if err != nil { s.audit(ctx, meta, 0, loginRefresh, "", resultFailed, string(xerr.CodeOf(err))) return authdomain.Token{}, err } grace := s.refreshRotationGrace() command := authdomain.RefreshRotationCommand{ AppCode: appcode.Normalize(meta.AppCode), RefreshTokenHash: refreshTokenHash, DeviceID: strings.TrimSpace(deviceID), RefreshRequestID: refreshRequestID, NowMs: nowMs, GracePeriodMs: grace.Milliseconds(), DenyUntilMs: s.sessionDenyUntilMs(nowMs), } // 只有当前仍 active、未过期且设备匹配的 session 才准备新一代;已轮换 token 直接交给 repository 原子判断 grace/replay。 if session.RevokedAtMs == 0 && session.ExpiresAtMs > nowMs && subtle.ConstantTimeCompare([]byte(session.DeviceID), []byte(strings.TrimSpace(deviceID))) == 1 { // refresh 时重新读取用户,确保禁用状态和靓号过期恢复生效。 user, freshErr := s.freshUser(ctx, session.UserID, nowMs, meta.RequestID) if freshErr != nil { s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(freshErr))) return authdomain.Token{}, freshErr } if !user.CanLogin() { s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.UserDisabled)) return authdomain.Token{}, xerr.New(xerr.UserDisabled, "user is disabled") } newSession, newRefreshToken, createErr := s.newRotatedSession(session, deviceID) if createErr != nil { return authdomain.Token{}, createErr } candidateToken, issueErr := s.issueToken(user, newSession.SessionID, newSession.DeviceID, newRefreshToken, newSession.ExpiresAtMs) if issueErr != nil { return authdomain.Token{}, issueErr } ciphertext, encryptErr := s.encryptRefreshOutcome(candidateToken, session.SessionID, newSession.SessionID, refreshRequestID) if encryptErr != nil { return authdomain.Token{}, encryptErr } command.NewSession = newSession command.TokenCiphertext = ciphertext command.OutcomeExpiresAtMs = nowMs + grace.Milliseconds() } rotation, err := s.authRepository.RotateRefreshSession(ctx, command) if err != nil { s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(err))) return authdomain.Token{}, err } switch rotation.Status { case authdomain.RefreshRotationCreated, authdomain.RefreshRotationGraceHit: // 无论本实例是否赢得轮换锁,都从事务内保存的密文恢复结果,保证并发和崩溃重试拿到完全相同的 token pair。 token, decryptErr := s.decryptRefreshOutcome(rotation) if decryptErr != nil { s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.Unavailable)) return authdomain.Token{}, xerr.New(xerr.Unavailable, "refresh outcome is unavailable") } // 先由 MySQL 原子提交 parent revoke + child + outcome + durable job,再同步写 Redis。 // 这样 DB deadlock/失败不会把仍 active 的 sid 单独 deny;Redis 失败则返回 UNAVAILABLE,旧 RT 重试从 outcome 恢复同 pair 并再次补写。 if err := s.writeRevokedAccessSessionUntil(ctx, rotation.SourceSession.AppCode, rotation.SourceSession.SessionID, revokeReasonRefreshRotated, command.DenyUntilMs); err != nil { s.audit(ctx, meta, rotation.SourceSession.UserID, loginRefresh, "", resultFailed, string(xerr.Unavailable)) return authdomain.Token{}, xerr.New(xerr.Unavailable, "rotated session denylist is unavailable") } _ = s.authRepository.MarkSessionDenylistJobDelivered(ctx, 0, rotation.SourceSession.AppCode, rotation.SourceSession.SessionID, revokeReasonRefreshRotated, command.DenyUntilMs, nowMs, nowMs+sessionDenylistRehydrateInterval.Milliseconds()) if rotation.Status == authdomain.RefreshRotationCreated { s.refreshMetrics.Increment(ctx, refreshMetricSuccess, rotation.SourceSession.AppCode) // refresh 是登录态存续的主路径;老设备升级新客户端后,设备档案主要在这里补齐。 s.recordUserDevice(ctx, meta, rotation.SourceSession.UserID, deviceID) } else { s.refreshMetrics.Increment(ctx, refreshMetricGraceHit, rotation.SourceSession.AppCode) } s.audit(ctx, meta, rotation.SourceSession.UserID, loginRefresh, "", resultSuccess, "") return token, nil case authdomain.RefreshRotationReuseDetected: s.refreshMetrics.Increment(ctx, refreshMetricTokenReuse, rotation.SourceSession.AppCode) if rotation.FamilyNewlyRevoked { s.refreshMetrics.Increment(ctx, refreshMetricFamilyRevoked, rotation.SourceSession.AppCode) } if err := s.writeRevokedAccessFamilyUntil(ctx, rotation.SourceSession.AppCode, rotation.FamilySessionIDs, revokeReasonRefreshReuse, command.DenyUntilMs); err != nil { // MySQL 已是 revoked 事实;返回 unavailable 促使调用方重试,后续重试会再次补齐全部 sid denylist。 s.audit(ctx, meta, rotation.SourceSession.UserID, loginRefresh, "", resultFailed, string(xerr.Unavailable)) return authdomain.Token{}, err } s.audit(ctx, meta, rotation.SourceSession.UserID, loginRefresh, "", resultFailed, string(xerr.SessionRevoked)) return authdomain.Token{}, xerr.New(xerr.SessionRevoked, "session revoked") default: return authdomain.Token{}, xerr.New(xerr.Unavailable, "refresh rotation result is invalid") } } // IssueAccessTokenForSession 基于当前 active session 重签 access token,不轮换 refresh token。 // 该链路用于 CompleteOnboarding 后刷新 profile_completed 快照,避免客户端必须额外 refresh。 func (s *Service) IssueAccessTokenForSession(ctx context.Context, userID int64, sessionID string, meta Meta) (authdomain.Token, error) { sessionID = strings.TrimSpace(sessionID) if userID <= 0 || sessionID == "" { // session_id 来自已校验 JWT 的 sid claim,缺失说明调用方不能安全重签。 return authdomain.Token{}, xerr.New(xerr.InvalidArgument, "user_id and session_id are required") } if s.authRepository == nil { return authdomain.Token{}, xerr.New(xerr.Unavailable, "auth repository is not configured") } nowMs := s.now().UnixMilli() session, err := s.authRepository.FindActiveSessionByID(ctx, sessionID, nowMs) if err != nil { s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.CodeOf(err))) return authdomain.Token{}, err } if session.UserID != userID { // sid 和 user_id 必须同源于同一个 access token,任何不一致都按会话失效处理。 s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.SessionRevoked)) return authdomain.Token{}, xerr.New(xerr.SessionRevoked, "session revoked") } if session.AppCode != appcode.Normalize(meta.AppCode) { s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.SessionRevoked)) return authdomain.Token{}, xerr.New(xerr.SessionRevoked, "session revoked") } user, err := s.freshUser(ctx, userID, nowMs, meta.RequestID) if err != nil { s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.CodeOf(err))) return authdomain.Token{}, err } if !user.CanLogin() { s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.UserDisabled)) return authdomain.Token{}, xerr.New(xerr.UserDisabled, "user is disabled") } s.audit(ctx, meta, userID, loginAccessResign, "", resultSuccess, "") return s.issueToken(user, session.SessionID, session.DeviceID, "", session.ExpiresAtMs) } // AdminIssueUserAccessToken 供后台按用户当前最新 active session 重签 access token。 // 与 IssueAccessTokenForSession 的关键差异:这里不写 login_audit,也不更新 session 行。 // 后台取 token 不是用户行为,写审计会污染“最近活跃 = GREATEST(session.updated, audit.created)”口径; // 取用责任由 admin 侧操作日志承担。签发本身复用 issueToken 的 claim 组装,不复制 JWT 逻辑。 func (s *Service) AdminIssueUserAccessToken(ctx context.Context, userID int64, meta Meta) (authdomain.Token, authdomain.Session, error) { if userID <= 0 { return authdomain.Token{}, authdomain.Session{}, xerr.New(xerr.InvalidArgument, "user_id is required") } if s.authRepository == nil { return authdomain.Token{}, authdomain.Session{}, xerr.New(xerr.Unavailable, "auth repository is not configured") } if !s.TokenConfigValid() { // 签发配置残缺时 fail closed,不能签出 gateway 无法验证的 token。 return authdomain.Token{}, authdomain.Session{}, xerr.New(xerr.Unavailable, "token config is invalid") } nowMs := s.now().UnixMilli() // 没有可信 sid 输入,取最新 active session 近似客户端当前会话;无会话时透传 NotFound 给后台展示。 session, err := s.authRepository.FindLatestActiveSessionByUser(ctx, userID, nowMs) if err != nil { return authdomain.Token{}, authdomain.Session{}, err } user, err := s.freshUser(ctx, userID, nowMs, meta.RequestID) if err != nil { return authdomain.Token{}, authdomain.Session{}, err } if !user.CanLogin() { // 禁用用户不能经由后台间接拿到有效 token,语义与所有签发路径一致。 return authdomain.Token{}, authdomain.Session{}, xerr.New(xerr.UserDisabled, "user is disabled") } // refreshToken 传空:后台没有轮换会话的理由,响应中也绝不携带 refresh 原文。 token, err := s.issueToken(user, session.SessionID, session.DeviceID, "", session.ExpiresAtMs) if err != nil { return authdomain.Token{}, authdomain.Session{}, err } return token, session, nil } // Logout 失效指定 session 所属 token family;与 refresh 并发时也不能留下可继续登录的 orphan child。 func (s *Service) Logout(ctx context.Context, sessionID string, refreshToken string, meta Meta) (bool, error) { if strings.TrimSpace(sessionID) == "" && strings.TrimSpace(refreshToken) == "" { // 至少需要一种 session 定位方式。 return false, xerr.New(xerr.InvalidArgument, "session_id or refresh_token is required") } if s.authRepository == nil { return false, xerr.New(xerr.Unavailable, "auth repository is not configured") } if s.ipDecisionCache == nil { // 没有 Redis 写能力时不进入 DB 撤销;运行中 Redis 瞬断则由事务内 denylist job 持久补偿。 return false, xerr.New(xerr.Unavailable, "session denylist is not configured") } nowMs := s.now().UnixMilli() denyUntilMs := s.sessionDenyUntilMs(nowMs) // repository 读取包含已轮换父代在内的 lineage,并在同事务写每个 sid 的 denylist 补偿 job。 resolvedAppCode, sessionIDs, revoked, err := s.authRepository.RevokeSessionFamily(ctx, strings.TrimSpace(sessionID), hashRefreshToken(refreshToken), nowMs, denyUntilMs, meta.RequestID) if err != nil { s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.CodeOf(err))) return false, err } if len(sessionIDs) > 0 { if err := s.writeRevokedAccessFamilyUntil(ctx, resolvedAppCode, sessionIDs, revokeReasonUserLogout, denyUntilMs); err != nil { s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.Unavailable)) return false, err } } s.audit(ctx, meta, 0, "logout", "", resultSuccess, "") return revoked, nil } // AppHeartbeat 刷新当前 App 登录会话的服务端在线时间。 func (s *Service) AppHeartbeat(ctx context.Context, userID int64, sessionID string, meta Meta) (authdomain.Session, error) { sessionID = strings.TrimSpace(sessionID) if userID <= 0 || sessionID == "" { // APP 心跳只能续当前 access token 中的会话,不能让客户端传任意 user_id 或空 sid。 return authdomain.Session{}, xerr.New(xerr.InvalidArgument, "user_id and session_id are required") } if s.authRepository == nil { return authdomain.Session{}, xerr.New(xerr.Unavailable, "auth repository is not configured") } nowMs := s.now().UnixMilli() session, err := s.authRepository.UpdateSessionHeartbeat(ctx, sessionID, userID, nowMs, meta.RequestID) if err != nil { return authdomain.Session{}, err } if session.AppCode != appcode.Normalize(meta.AppCode) { // 理论上 repository 已按 app_code 限定;这里保留防御,避免错误 context 造成跨 App 心跳成功。 return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked") } return session, nil } // TokenConfigValid 判断签发配置是否具备启动条件。 func (s *Service) TokenConfigValid() bool { // 当前只支持 HS256,避免签发和下游解析在算法上分叉。 return s.cfg.Issuer != "" && s.cfg.AccessTokenTTLSec > 0 && s.cfg.RefreshTokenTTLSec > 0 && s.cfg.SigningAlg == jwt.SigningMethodHS256.Alg() && s.cfg.SigningSecret != "" } func (s *Service) newSession(appCode string, userID int64, deviceID string) (authdomain.Session, string, error) { // refresh token 原文只返回给调用方,session 中只保存 hash。 refreshToken, err := randomToken("refresh") if err != nil { return authdomain.Session{}, "", err } nowMs := s.now().UnixMilli() // SessionID 使用独立前缀,便于日志和排障识别。 sessionID := idgen.New("sess") session := authdomain.Session{ AppCode: appcode.Normalize(appCode), SessionID: sessionID, UserID: userID, RefreshTokenHash: hashRefreshToken(refreshToken), DeviceID: strings.TrimSpace(deviceID), ExpiresAtMs: nowMs + s.cfg.RefreshTokenTTLSec*1000, CreatedAtMs: nowMs, UpdatedAtMs: nowMs, } // 首个登录 session 自己就是 family root;后续轮换只继承该 ID 并递增 generation。 session.TokenFamilyID = session.SessionID return session, refreshToken, nil } func (s *Service) newRotatedSession(parent authdomain.Session, deviceID string) (authdomain.Session, string, error) { child, refreshToken, err := s.newSession(parent.AppCode, parent.UserID, deviceID) if err != nil { return authdomain.Session{}, "", err } familyID := strings.TrimSpace(parent.TokenFamilyID) if familyID == "" { // 兼容迁移前创建、尚未轮换的 active session:第一次新版轮换时把自身升级为 family root。 familyID = parent.SessionID } child.TokenFamilyID = familyID child.Generation = parent.Generation + 1 child.ParentSessionID = parent.SessionID return child, refreshToken, nil } func (s *Service) refreshRotationGrace() time.Duration { if s.cfg.RefreshRotationGraceSec <= 0 { return defaultRefreshRotationGrace } return time.Duration(s.cfg.RefreshRotationGraceSec) * time.Second } func normalizeRefreshRequestID(refreshRequestID string, refreshTokenHash string) (string, error) { refreshRequestID = strings.TrimSpace(refreshRequestID) if refreshRequestID == "" { // 旧客户端每次 HTTP request_id 都会变化,不能拿它当幂等键;只从已 hash 的 predecessor token 派生稳定兼容键。 if len(refreshTokenHash) < 48 { return "", xerr.New(xerr.InvalidArgument, "refresh token hash is invalid") } refreshRequestID = "legacy_" + refreshTokenHash[:48] } if len(refreshRequestID) > 96 { return "", xerr.New(xerr.InvalidArgument, "refresh_request_id is too long") } return refreshRequestID, nil } func (s *Service) encryptRefreshOutcome(token authdomain.Token, parentSessionID string, childSessionID string, refreshRequestID string) ([]byte, error) { plaintext, err := json.Marshal(token) if err != nil { return nil, err } gcm, err := s.refreshReplayCipher() if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return nil, err } // nonce 前置于密文;AAD 绑定父子 lineage 和幂等键,数据库行被搬移后无法解密成可用 token。 return gcm.Seal(nonce, nonce, plaintext, refreshOutcomeAAD(token.AppCode, parentSessionID, childSessionID, refreshRequestID)), nil } func (s *Service) decryptRefreshOutcome(rotation authdomain.RefreshRotationResult) (authdomain.Token, error) { gcm, err := s.refreshReplayCipher() if err != nil { return authdomain.Token{}, err } if len(rotation.TokenCiphertext) <= gcm.NonceSize() { return authdomain.Token{}, fmt.Errorf("refresh outcome ciphertext is empty") } nonce := rotation.TokenCiphertext[:gcm.NonceSize()] ciphertext := rotation.TokenCiphertext[gcm.NonceSize():] plaintext, err := gcm.Open(nil, nonce, ciphertext, refreshOutcomeAAD(rotation.SourceSession.AppCode, rotation.SourceSession.SessionID, rotation.ActiveSession.SessionID, rotation.RefreshRequestID)) if err != nil { return authdomain.Token{}, err } var token authdomain.Token if err := json.Unmarshal(plaintext, &token); err != nil { return authdomain.Token{}, err } if token.SessionID != rotation.ActiveSession.SessionID || token.AppCode != rotation.SourceSession.AppCode || token.RefreshToken == "" || token.AccessToken == "" { // 密文即使能通过认证,也必须和事务返回的 child session 一致,避免错误行关联泄露另一个会话结果。 return authdomain.Token{}, fmt.Errorf("refresh outcome lineage mismatch") } return token, nil } func (s *Service) refreshReplayCipher() (cipher.AEAD, error) { if strings.TrimSpace(s.cfg.SigningSecret) == "" { return nil, fmt.Errorf("signing secret is empty") } // 使用带用途前缀的 SHA-256 派生独立 AES-256 key,避免把 JWT HMAC key 直接作为分组密码 key。 key := sha256.Sum256([]byte("hyapp-auth-refresh-replay:" + refreshReplayCipherVersion + "\x00" + s.cfg.SigningSecret)) block, err := aes.NewCipher(key[:]) if err != nil { return nil, err } return cipher.NewGCM(block) } func refreshOutcomeAAD(appCode string, parentSessionID string, childSessionID string, refreshRequestID string) []byte { return []byte(strings.Join([]string{refreshReplayCipherVersion, appcode.Normalize(appCode), parentSessionID, childSessionID, refreshRequestID}, "\x00")) } func (s *Service) issueToken(user userdomain.User, sessionID string, deviceID string, refreshToken string, sessionExpiresAtMs int64) (authdomain.Token, error) { now := s.now() expiresInSec := s.cfg.AccessTokenTTLSec expiresAt := now.Add(time.Duration(expiresInSec) * time.Second) if sessionExpiresAtMs > 0 { sessionExpiresAt := time.UnixMilli(sessionExpiresAtMs) if !sessionExpiresAt.After(now) { // access token 不能越过服务端 refresh session 的生命周期;已到期 session 必须重新登录。 return authdomain.Token{}, xerr.New(xerr.SessionExpired, "session expired") } if sessionExpiresAt.Before(expiresAt) { expiresAt = sessionExpiresAt expiresInSec = int64(expiresAt.Sub(now) / time.Second) if expiresInSec <= 0 { return authdomain.Token{}, xerr.New(xerr.SessionExpired, "session expired") } } } onboardingStatus := tokenOnboardingStatus(user) // JWT 只携带内部 user_id;客户端展示和人工输入统一使用 display_user_id。 // user_id 必须写成字符串:雪花 ID 超过 JS/JSON number 的安全整数范围,写成 number 会在 gateway 解析时丢精度。 // device_id 只能来自已落库 auth_session 的绑定值;心跳/资料完成重签也必须从同一 session 回读, // 不允许 gateway 用 sid 或业务 command_id 伪造设备风控作用域。 claims := jwt.MapClaims{ "iss": s.cfg.Issuer, "app_code": appcode.Normalize(user.AppCode), "sub": strconv.FormatInt(user.UserID, 10), "user_id": strconv.FormatInt(user.UserID, 10), "display_user_id": user.CurrentDisplayUserID, "default_display_user_id": user.DefaultDisplayUserID, "display_user_id_kind": string(user.CurrentDisplayUserIDKind), "display_user_id_expires_at_ms": user.CurrentDisplayUserIDExpiresAtMs, "profile_completed": user.ProfileCompleted, "onboarding_status": onboardingStatus, "sid": sessionID, "device_id": strings.TrimSpace(deviceID), "typ": "access", "iat": now.Unix(), "exp": expiresAt.Unix(), } // 当前固定 HS256,配置有效性由 TokenConfigValid 校验。 token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) accessToken, err := token.SignedString([]byte(s.cfg.SigningSecret)) if err != nil { return authdomain.Token{}, err } return authdomain.Token{ AppCode: appcode.Normalize(user.AppCode), UserID: user.UserID, DisplayUserID: user.CurrentDisplayUserID, DefaultDisplayUserID: user.DefaultDisplayUserID, DisplayUserIDKind: string(user.CurrentDisplayUserIDKind), DisplayUserIDExpiresAtMs: user.CurrentDisplayUserIDExpiresAtMs, ProfileCompleted: user.ProfileCompleted, OnboardingStatus: onboardingStatus, SessionID: sessionID, AccessToken: accessToken, RefreshToken: refreshToken, ExpiresInSec: expiresInSec, TokenType: tokenType, }, nil } func (s *Service) writeRevokedAccessSession(ctx context.Context, appCode string, sessionID string, reason string) error { return s.writeRevokedAccessSessionUntil(ctx, appCode, sessionID, reason, s.sessionDenyUntilMs(s.now().UnixMilli())) } func (s *Service) writeRevokedAccessSessionUntil(ctx context.Context, appCode string, sessionID string, reason string, denyUntilMs int64) error { if strings.TrimSpace(sessionID) == "" { return nil } if s.ipDecisionCache == nil { // refresh/logout 一旦成功,gateway 必须能立刻拒绝旧 access token;缺少 denylist 不能假装吊销成功。 return xerr.New(xerr.Unavailable, "session denylist is not configured") } ttl := time.Until(time.UnixMilli(denyUntilMs)) if s.now != nil { ttl = time.UnixMilli(denyUntilMs).Sub(s.now()) } if ttl <= 0 { // deny_until 已过说明该 sid 的 access JWT 已全部自然失效,无需再写 Redis。 return nil } return s.ipDecisionCache.SetRevokedSession(ctx, appcode.Normalize(appCode), sessionID, reason, ttl) } func (s *Service) writeRevokedAccessFamilyUntil(ctx context.Context, appCode string, sessionIDs []string, reason string, denyUntilMs int64) error { // replay 是罕见安全事件,可以逐 sid 补齐 denylist;不能只写当前 active child,否则 family 内尚未自然过期的旧 JWT 仍能通过 gateway。 for _, sessionID := range sessionIDs { if err := s.writeRevokedAccessSessionUntil(ctx, appCode, sessionID, reason, denyUntilMs); err != nil { return xerr.New(xerr.Unavailable, "session family denylist is unavailable") } // family revoke 事务已经写入持久 job;即时 Redis 成功后尽量标 delivered,标记失败会由 worker 幂等重放。 nowMs := s.now().UnixMilli() _ = s.authRepository.MarkSessionDenylistJobDelivered(ctx, 0, appCode, sessionID, reason, denyUntilMs, nowMs, nowMs+sessionDenylistRehydrateInterval.Milliseconds()) } return nil } func (s *Service) sessionDenyUntilMs(nowMs int64) int64 { accessTTL := time.Duration(s.cfg.AccessTokenTTLSec) * time.Second if accessTTL < sessionDenylistHistoricalAccessTTLFloor { // 这个 floor 只能随历史最大签发 TTL 上调,不能因当前配置下降而下调。 accessTTL = sessionDenylistHistoricalAccessTTLFloor } return nowMs + (accessTTL + sessionDenylistSafetyMargin).Milliseconds() } func tokenOnboardingStatus(user userdomain.User) string { // 旧测试数据或手工种子可能没有显式状态;token 按 profile_completed 推导安全默认值。 if user.OnboardingStatus != "" { return string(user.OnboardingStatus) } if user.ProfileCompleted { return string(userdomain.OnboardingStatusCompleted) } return string(userdomain.OnboardingStatusProfileRequired) } func (s *Service) freshUser(ctx context.Context, userID int64, nowMs int64, requestID string) (userdomain.User, error) { if s.userRepository == nil { // 没有用户读取能力时不能签发 token。 return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured") } user, err := s.userRepository.GetUser(ctx, userID) if err != nil { return userdomain.User{}, err } if !user.DisplayUserIDExpired(nowMs) { // 当前展示号仍有效,直接返回规范化用户。 return user, nil } if s.identityRepository == nil { // 发现过期靓号但无法恢复时不能签发带过期短号的 token。 return userdomain.User{}, xerr.New(xerr.Unavailable, "identity repository is not configured") } // 登录和 refresh 路径都复用同一过期恢复事务。 if _, err := s.identityRepository.ExpirePrettyDisplayUserID(ctx, userdomain.ExpirePrettyDisplayUserIDCommand{ UserID: userID, RequestID: requestID, NowMs: nowMs, }); err != nil { return userdomain.User{}, err } // 恢复后重新读取 users,确保 token 使用最新默认短号。 return s.userRepository.GetUser(ctx, userID) } func randomToken(prefix string) (string, error) { // 32 字节随机熵足够作为 refresh token 原文,使用 URL-safe base64 返回。 var entropy [32]byte if _, err := rand.Read(entropy[:]); err != nil { return "", err } return fmt.Sprintf("%s_%s", prefix, base64.RawURLEncoding.EncodeToString(entropy[:])), nil } func hashRefreshToken(refreshToken string) string { if refreshToken == "" { // 空 refresh token hash 保持空字符串,支持只按 session_id 吊销。 return "" } // refresh token 只持久化 SHA-256 hash,避免数据库泄漏后可直接使用原文。 sum := sha256.Sum256([]byte(refreshToken)) return hex.EncodeToString(sum[:]) }