2026-06-25 22:00:49 +08:00

307 lines
13 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/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"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"
)
// RefreshToken 轮换 refresh token旧 token 成功使用后立即失效。
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()
// 持久层只保存 refresh token hash不能用原文查询。
session, err := s.authRepository.FindActiveSessionByRefreshHash(ctx, hashRefreshToken(refreshToken), nowMs)
if err != nil {
reason := xerr.SessionRevoked
if xerr.IsCode(err, xerr.SessionExpired) {
reason = xerr.SessionExpired
}
s.audit(ctx, meta, 0, loginRefresh, "", resultFailed, string(reason))
return authdomain.Token{}, err
}
if subtle.ConstantTimeCompare([]byte(session.DeviceID), []byte(deviceID)) != 1 {
// refresh token 绑定设备,跨设备使用按 revoked 处理。
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.SessionRevoked))
return authdomain.Token{}, xerr.New(xerr.SessionRevoked, "session revoked")
}
if session.AppCode != appcode.Normalize(meta.AppCode) {
// refresh token 不能跨 App 使用;即使 refresh hash 泄漏也不能从另一个包名续期。
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.SessionRevoked))
return authdomain.Token{}, xerr.New(xerr.SessionRevoked, "session revoked")
}
// refresh 时重新读取用户,确保禁用状态和靓号过期恢复生效。
user, err := s.freshUser(ctx, session.UserID, nowMs, meta.RequestID)
if err != nil {
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, err
}
if !user.CanLogin() {
// 禁用用户不能续期会话。
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.UserDisabled))
return authdomain.Token{}, xerr.New(xerr.UserDisabled, "user is disabled")
}
// refresh token 成功使用后必须轮换,旧 session 立即吊销。
newSession, newRefreshToken, err := s.newSession(session.AppCode, session.UserID, deviceID)
if err != nil {
return authdomain.Token{}, err
}
if err := s.authRepository.ReplaceSession(ctx, session.SessionID, newSession, nowMs); err != nil {
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, err
}
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultSuccess, "")
return s.issueToken(user, newSession.SessionID, newRefreshToken)
}
// 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, "")
}
// Logout 失效指定 session 或 refresh token。
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")
}
// refreshToken 可能为空hashRefreshToken 会保持空值,支持仅用 session_id 登出。
revoked, err := s.authRepository.RevokeSession(ctx, sessionID, hashRefreshToken(refreshToken), s.now().UnixMilli())
if err != nil {
s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.CodeOf(err)))
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")
return 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,
}, refreshToken, nil
}
func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToken string) (authdomain.Token, error) {
now := s.now()
expiresAt := now.Add(time.Duration(s.cfg.AccessTokenTTLSec) * time.Second)
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: s.cfg.AccessTokenTTLSec,
TokenType: tokenType,
}, nil
}
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[:])
}