Files
EchoChat/backend/go-service/app/auth/service/token_store.go
2026-06-04 12:23:14 +08:00

157 lines
5.5 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 service
import (
"context"
"fmt"
"strings"
"time"
"github.com/echochat/backend/config"
"github.com/echochat/backend/pkg/logs"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// Redis Key 前缀,按 client_type 隔离前台和管理端的 Token
// 格式echo:auth:token:{client_type}:{user_id}
// 例如echo:auth:token:frontend:1 / echo:auth:token:admin:1
const (
keyPrefixAccessToken = "echo:auth:token:" // echo:auth:token:{client_type}:{user_id}
keyPrefixRefreshToken = "echo:auth:refresh:" // echo:auth:refresh:{client_type}:{user_id}
// cloudLawUsernamePrefix 云律临时会议账号用户名前缀(与 meeting.cloudLawUsername 保持一致)。
// 这类账号按手机号即时创建、仅用于视频/语音会议,会在「双方进会 / SSO 多跳 / 多端」时被重复签发,
// 单点登录语义会把先签发的 token 挤掉。对其放宽校验,避免 web-view 进会误报「认证已失效」。
cloudLawUsernamePrefix = "cloudlaw_"
)
// isCloudLawUsername 判断是否为云律临时会议账号
func isCloudLawUsername(username string) bool {
return strings.HasPrefix(username, cloudLawUsernamePrefix)
}
// TokenStore 管理 Token 在 Redis 中的存取
// 实现有状态 JWT登录存入、验证时校验、登出时删除
type TokenStore struct {
redis *redis.Client
jwtCfg *config.JWTConfig
}
// NewTokenStore 创建 TokenStore 实例
func NewTokenStore(redisClient *redis.Client, jwtCfg *config.JWTConfig) *TokenStore {
return &TokenStore{
redis: redisClient,
jwtCfg: jwtCfg,
}
}
// SaveTokens 将 Access Token 和 Refresh Token 存入 Redis
// 每次登录/注册时调用,覆盖同一 clientType 下的旧 Token
// clientType 用于隔离前台frontend和管理端admin的 Token
func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, clientType, accessToken, refreshToken string) error {
funcName := "service.token_store.SaveTokens"
accessKey := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
refreshKey := fmt.Sprintf("%s%s:%d", keyPrefixRefreshToken, clientType, userID)
accessTTL := time.Duration(s.jwtCfg.AccessExpireMin) * time.Minute
refreshTTL := time.Duration(s.jwtCfg.RefreshExpireDay) * 24 * time.Hour
pipe := s.redis.Pipeline()
pipe.Set(ctx, accessKey, accessToken, accessTTL)
pipe.Set(ctx, refreshKey, refreshToken, refreshTTL)
if _, err := pipe.Exec(ctx); err != nil {
logs.Error(ctx, funcName, "保存 Token 到 Redis 失败",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
zap.Error(err),
)
return err
}
logs.Debug(ctx, funcName, "Token 已存入 Redis",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
zap.Duration("access_ttl", accessTTL),
zap.Duration("refresh_ttl", refreshTTL),
)
return nil
}
// ValidateAccessToken 校验 Access Token 是否与 Redis 中存储的一致
// clientType 用于定位正确的 Redis key前台 vs 管理端)
// username 用于识别云律临时会议账号,对其放宽「单点登录挤号」校验
func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool {
funcName := "service.token_store.ValidateAccessToken"
key := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
stored, err := s.redis.Get(ctx, key).Result()
if err == redis.Nil {
logs.Debug(ctx, funcName, "Token 不存在(已登出或过期)",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
)
return false
}
if err != nil {
logs.Error(ctx, funcName, "Redis 查询 Token 失败",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
zap.Error(err),
)
return false
}
if stored == token {
return true
}
// 云律临时会议账号:同一手机号在「双方进会 / SSO 多跳 / 多端」场景会被重复签发会话,
// 单点登录会把先签发的 token 挤掉,导致 web-view 进会报「认证已失效」。
// 这里只要该账号在 Redis 中仍存在有效会话stored 非空,未登出/未过期),
// 且 token 已通过 JWT 签名与有效期校验(调用方 ParseToken 已保证),即放行。
if stored != "" && isCloudLawUsername(username) {
logs.Debug(ctx, funcName, "云律临时账号 token 已被新会话覆盖,按宽松策略放行",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
)
return true
}
return false
}
// ValidateRefreshToken 校验 Refresh Token 是否与 Redis 中存储的一致
func (s *TokenStore) ValidateRefreshToken(ctx context.Context, userID int64, clientType, token string) bool {
key := fmt.Sprintf("%s%s:%d", keyPrefixRefreshToken, clientType, userID)
stored, err := s.redis.Get(ctx, key).Result()
if err != nil {
return false
}
return stored == token
}
// RemoveTokens 从 Redis 删除指定 clientType 下用户的 Token登出时调用
func (s *TokenStore) RemoveTokens(ctx context.Context, userID int64, clientType string) error {
funcName := "service.token_store.RemoveTokens"
accessKey := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
refreshKey := fmt.Sprintf("%s%s:%d", keyPrefixRefreshToken, clientType, userID)
if err := s.redis.Del(ctx, accessKey, refreshKey).Err(); err != nil {
logs.Error(ctx, funcName, "删除 Token 失败",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
zap.Error(err),
)
return err
}
logs.Info(ctx, funcName, "Token 已从 Redis 删除",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
)
return nil
}