2026-07-16 18:51:58 +08:00

588 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, 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 单独 denyRedis 失败则返回 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.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.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, 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 解析时丢精度。
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,
"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[:])
}