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

180 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 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 用于识别云律临时会议账号,对其放宽有状态校验
//
// 云律临时会议账号(cloudlaw_*)说明:按手机号即时创建、默认口令、仅用于视频/语音会议、角色受限,
// 在「双方进会 / SSO 多跳 / 多端 / 重进会议」时会被重复签发,单点登录语义会把先签发的 token 挤掉;
// 叠加 Redis key 过期/内存淘汰/重启、登出清理、并发等情况Redis 侧可能出现「不一致 / 缺失 / 查询失败」。
// 由于调用方(中间件/WS 握手)在调用本方法前已用 ParseToken 校验过 JWT 的签名与有效期,
// 对这类临时账号只要 JWT 有效即放行(不强依赖 Redis 有状态记录),避免 web-view 进会偶发「认证已失效」。
// 普通账号(前台用户/管理端)仍维持严格的有状态校验。
func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool {
funcName := "service.token_store.ValidateAccessToken"
cloudLaw := isCloudLawUsername(username)
key := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
stored, err := s.redis.Get(ctx, key).Result()
if err == redis.Nil {
// 云律临时账号Redis 无记录(挤号后过期 / 内存淘汰 / 重启 / 登出清理)也凭有效 JWT 放行
if cloudLaw {
logs.Debug(ctx, funcName, "云律临时账号 Redis 无 token 记录,按宽松策略放行(仅凭有效 JWT",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
)
return true
}
logs.Debug(ctx, funcName, "Token 不存在(已登出或过期)",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
)
return false
}
if err != nil {
// 云律临时账号Redis 抖动/不可用时凭有效 JWT 放行,避免阻断进会
if cloudLaw {
logs.Warn(ctx, funcName, "Redis 查询 token 失败,云律临时账号按宽松策略放行(仅凭有效 JWT",
zap.Int64("user_id", userID),
zap.String("client_type", clientType),
zap.Error(err),
)
return true
}
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
}
// 云律临时会议账号token 已被新会话挤号stored != token也放行
if cloudLaw {
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
}