180 lines
6.6 KiB
Go
180 lines
6.6 KiB
Go
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
|
||
}
|