2026-06-25 21:30:26 +08:00

217 lines
6.6 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"
"fmt"
"strconv"
"strings"
jwt "github.com/golang-jwt/jwt/v5"
"hyapp/pkg/appcode"
)
type contextKey string
const (
userContextKey contextKey = "gateway_user_id"
claimsContextKey contextKey = "gateway_claims"
// TokenTypeAccess 是真实用户 access token。
TokenTypeAccess = "access"
// TokenTypePendingRegistration 是只允许完成注册的 pending token。
TokenTypePendingRegistration = "pending_registration"
)
// Claims 是 gateway 从 access token 中消费的最小用户状态快照。
// 它只用于入口鉴权和 profile gate不替代 user-service 的用户主数据。
type Claims struct {
AppCode string
UserID int64
SessionID string
TokenType string
PendingRegistrationID string
ProfileCompleted bool
OnboardingStatus string
AccessTokenExpiresAtMS int64
}
// Verifier 负责在 gateway 入口层校验用户 JWT。
type Verifier struct {
secret []byte
}
// NewVerifier 初始化入口 JWT 校验器。
func NewVerifier(secret string) *Verifier {
return &Verifier{secret: []byte(secret)}
}
// VerifyUserID 只负责从 Authorization header 中提取并校验用户身份。
// HTTP 状态码和响应 envelope 属于 transport 层auth 包不直接写 ResponseWriter。
func (v *Verifier) VerifyUserID(header string) (int64, error) {
claims, err := v.Verify(header)
if err != nil {
return 0, err
}
return claims.UserID, nil
}
// Verify 校验 access token 并返回 gateway 入口需要的用户状态快照。
func (v *Verifier) Verify(header string) (Claims, error) {
if !strings.HasPrefix(header, "Bearer ") {
return Claims{}, fmt.Errorf("missing bearer token")
}
tokenString := strings.TrimPrefix(header, "Bearer ")
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("unexpected signing method")
}
return v.secret, nil
})
if err != nil || !token.Valid {
return Claims{}, fmt.Errorf("invalid token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return Claims{}, fmt.Errorf("invalid claims")
}
tokenType, _ := claims["typ"].(string)
tokenType = strings.TrimSpace(tokenType)
if tokenType == "" {
// 旧测试 token 没有 typ 时按真实用户 access token 处理。
tokenType = TokenTypeAccess
}
profileCompleted, _ := claims["profile_completed"].(bool)
onboardingStatus, _ := claims["onboarding_status"].(string)
sessionID, _ := claims["sid"].(string)
appCode, _ := claims["app_code"].(string)
expiresAtMS := claimUnixSecondsMS(claims["exp"])
if tokenType == TokenTypePendingRegistration {
pendingID, _ := claims["pending_registration_id"].(string)
pendingID = strings.TrimSpace(pendingID)
if pendingID == "" {
return Claims{}, fmt.Errorf("missing pending_registration_id")
}
if strings.TrimSpace(sessionID) == "" {
sessionID = pendingID
}
return Claims{
AppCode: appcode.Normalize(appCode),
SessionID: strings.TrimSpace(sessionID),
TokenType: tokenType,
PendingRegistrationID: pendingID,
ProfileCompleted: false,
OnboardingStatus: strings.TrimSpace(onboardingStatus),
AccessTokenExpiresAtMS: expiresAtMS,
}, nil
}
if tokenType != TokenTypeAccess {
return Claims{}, fmt.Errorf("invalid token type")
}
rawUserID, ok := claims["user_id"].(string)
if !ok || strings.TrimSpace(rawUserID) == "" {
return Claims{}, fmt.Errorf("missing user_id")
}
userID, err := strconv.ParseInt(strings.TrimSpace(rawUserID), 10, 64)
if err != nil || userID <= 0 {
return Claims{}, fmt.Errorf("invalid user_id")
}
return Claims{
AppCode: appcode.Normalize(appCode),
UserID: userID,
SessionID: strings.TrimSpace(sessionID),
TokenType: tokenType,
ProfileCompleted: profileCompleted,
OnboardingStatus: strings.TrimSpace(onboardingStatus),
AccessTokenExpiresAtMS: expiresAtMS,
}, nil
}
func claimUnixSecondsMS(value any) int64 {
switch typed := value.(type) {
case float64:
if typed <= 0 {
return 0
}
return int64(typed) * 1000
case int64:
if typed <= 0 {
return 0
}
return typed * 1000
case int:
if typed <= 0 {
return 0
}
return int64(typed) * 1000
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err != nil || parsed <= 0 {
return 0
}
return parsed * 1000
default:
return 0
}
}
// WithUserID 把已鉴权用户写入请求上下文,供 gateway 组装 RequestMeta。
func WithUserID(ctx context.Context, userID int64) context.Context {
return context.WithValue(ctx, userContextKey, userID)
}
// WithClaims 把 access token 中的准入快照写入上下文。
func WithClaims(ctx context.Context, claims Claims) context.Context {
ctx = appcode.WithContext(ctx, claims.AppCode)
ctx = WithUserID(ctx, claims.UserID)
return context.WithValue(ctx, claimsContextKey, claims)
}
// UserIDFromContext 提取鉴权后的用户 ID。
func UserIDFromContext(ctx context.Context) int64 {
userID, _ := ctx.Value(userContextKey).(int64)
return userID
}
// SessionIDFromContext 提取 access token 中的服务端 session_id。
func SessionIDFromContext(ctx context.Context) string {
claims, _ := ctx.Value(claimsContextKey).(Claims)
return strings.TrimSpace(claims.SessionID)
}
// IsPendingRegistrationFromContext 表示当前请求使用的是待完成注册 token。
func IsPendingRegistrationFromContext(ctx context.Context) bool {
claims, _ := ctx.Value(claimsContextKey).(Claims)
return claims.TokenType == TokenTypePendingRegistration
}
// PendingRegistrationIDFromContext 提取 pending token 对应的注册态 ID。
func PendingRegistrationIDFromContext(ctx context.Context) string {
claims, _ := ctx.Value(claimsContextKey).(Claims)
return strings.TrimSpace(claims.PendingRegistrationID)
}
// ProfileCompletedFromContext 返回 gateway profile gate 的判断依据。
func ProfileCompletedFromContext(ctx context.Context) bool {
claims, _ := ctx.Value(claimsContextKey).(Claims)
return claims.ProfileCompleted
}
// OnboardingStatusFromContext 暴露 token 中的注册状态,便于后续审计或灰度判断。
func OnboardingStatusFromContext(ctx context.Context) string {
claims, _ := ctx.Value(claimsContextKey).(Claims)
return claims.OnboardingStatus
}
// AccessTokenExpiresAtMSFromContext 返回当前 access token 的过期时间;旧测试 token 没有 exp 时返回 0。
func AccessTokenExpiresAtMSFromContext(ctx context.Context) int64 {
claims, _ := ctx.Value(claimsContextKey).(Claims)
return claims.AccessTokenExpiresAtMS
}