refactor(yggdrasil): 整理与性能优化,修复若干 Bug
Some checks failed
Build / build (push) Failing after 21m45s
Build / build-docker (push) Has been skipped

Bug 修复
- textures metadata:SKIN 仅在 IsSlim 时输出 {"model":"slim"};CAPE 不带 metadata。
  原实现误用文件字节数 skin.Size 作为 metadata,违反 Yggdrasil 协议。
- KeyPair 持久化不全:Profile 新增 rsa_public_key/public_key_signature/
  public_key_signature_v2/key_expires_at/key_refresh_at 列;GetKeyPair/UpdateKeyPair
  读写全部字段。AutoMigrate 自动加列。原实现每次请求都重新生成 RSA-4096。
- GetPlayerCertificates:无效 token 返回 401(先前误用 403 且未判 err)。
- HasJoinedServer:失败返回 204 无 body,符合 Yggdrasil 协议(原用 APIResponse
  + body 违反 204 规范)。

代码质量
- Authenticate 删除冗余 body 读取与回放。
- 证书服务引入具名结构 PlayerCertificate/PlayerKeyPair,替代 map[string]interface{}。
- CreateSession 用户名缺失改用 ErrUsernameRequired(新增)。

性能优化(签名一致性的回归测试已覆盖)
- SignatureService 缓存已解析的根私钥(sync.RWMutex 双重检查),
  快路径仅一次 RLock + RSA 签名,免去每次签名 PEM 解析与 Redis 往返。
- GetOrCreateYggdrasilKeyPair 用 MGet 单次往返取三字段,缓存命中后零 Redis。
- NewKeyPair 消息构造用 strconv.AppendInt 避免 string+拼接分配;V2 复用 DER 字节。
- 序列化服务:texturesMap 预分配 cap(2);slim 分支直接构造完整 map。
- 会话服务 GetSession 移除重复的 ValidateServerID 校验。

死代码清理
- 删除 yggdrasil_validator.go(未被调用的 Validator)。
- 删除 signature_service.FormatPublicKey/SignStringWithProfileRSA。
- 删除 pkg/auth 中未被使用的 YggdrasilJWTManager 与 GenerateKeyPair/
  EncodePrivateKeyToPEM/RedisClient/YggdrasilPrivateKeyRedisKey。
- 删除 yggdrasil_handler.go 中 25 个未使用常量、passwordRegex、
  APIResponse/standardResponse。
- 删除 errors.go 中未被使用的 ErrYggForbiddenOperation/ErrYggIllegalArgument/
  ErrInvalidSignature/ErrInvalidTextureType/ErrCertificateGenerate/ErrInvalidPassword。

测试
- 新增 signature_service_test.go(基于 miniredis):8 个测试 + 基准。
  核心:SignString_MatchesReference 与重构前参考实现逐字节比对签名输出一致。
  -race 检测通过;基准约 7.1ms/op(缓存命中路径)。

附带(与本次任务前已存在的未提交改动)
- handler/captcha_handler:将响应字段 msg 改为 message,与前端约定对齐。
This commit is contained in:
2026-07-09 17:18:26 +08:00
parent cea22951b9
commit 1dc9c36a3a
15 changed files with 636 additions and 716 deletions

View File

@@ -12,14 +12,12 @@ var (
ErrUserNotFound = errors.New("用户不存在") ErrUserNotFound = errors.New("用户不存在")
ErrUserAlreadyExists = errors.New("用户已存在") ErrUserAlreadyExists = errors.New("用户已存在")
ErrEmailAlreadyExists = errors.New("邮箱已被注册") ErrEmailAlreadyExists = errors.New("邮箱已被注册")
ErrInvalidPassword = errors.New("密码错误")
ErrAccountDisabled = errors.New("账号已被禁用") ErrAccountDisabled = errors.New("账号已被禁用")
// 认证相关错误 // 认证相关错误
ErrUnauthorized = errors.New("未授权") ErrUnauthorized = errors.New("未授权")
ErrInvalidToken = errors.New("无效的令牌") ErrInvalidToken = errors.New("无效的令牌")
ErrTokenExpired = errors.New("令牌已过期") ErrTokenExpired = errors.New("令牌已过期")
ErrInvalidSignature = errors.New("签名验证失败")
// 档案相关错误 // 档案相关错误
ErrProfileNotFound = errors.New("档案不存在") ErrProfileNotFound = errors.New("档案不存在")
@@ -32,7 +30,6 @@ var (
ErrTextureExists = errors.New("该材质已存在") ErrTextureExists = errors.New("该材质已存在")
ErrTextureLimitReached = errors.New("已达材质数量上限") ErrTextureLimitReached = errors.New("已达材质数量上限")
ErrTextureNoPermission = errors.New("无权操作此材质") ErrTextureNoPermission = errors.New("无权操作此材质")
ErrInvalidTextureType = errors.New("无效的材质类型")
// 验证码相关错误 // 验证码相关错误
ErrInvalidVerificationCode = errors.New("验证码错误或已过期") ErrInvalidVerificationCode = errors.New("验证码错误或已过期")
@@ -54,15 +51,11 @@ var (
ErrSessionNotFound = errors.New("会话不存在或已过期") ErrSessionNotFound = errors.New("会话不存在或已过期")
ErrSessionMismatch = errors.New("会话验证失败") ErrSessionMismatch = errors.New("会话验证失败")
ErrUsernameMismatch = errors.New("用户名不匹配") ErrUsernameMismatch = errors.New("用户名不匹配")
ErrUsernameRequired = errors.New("用户名不能为空")
ErrIPMismatch = errors.New("IP地址不匹配") ErrIPMismatch = errors.New("IP地址不匹配")
ErrInvalidAccessToken = errors.New("访问令牌无效") ErrInvalidAccessToken = errors.New("访问令牌无效")
ErrProfileMismatch = errors.New("selectedProfile与Token不匹配") ErrProfileMismatch = errors.New("selectedProfile与Token不匹配")
ErrUUIDRequired = errors.New("UUID不能为空") ErrUUIDRequired = errors.New("UUID不能为空")
ErrCertificateGenerate = errors.New("生成证书失败")
// Yggdrasil协议标准错误
ErrYggForbiddenOperation = errors.New("ForbiddenOperationException")
ErrYggIllegalArgument = errors.New("IllegalArgumentException")
// 通用错误 // 通用错误
ErrBadRequest = errors.New("请求参数错误") ErrBadRequest = errors.New("请求参数错误")

View File

@@ -35,7 +35,7 @@ type CaptchaVerifyRequest struct {
// @Tags captcha // @Tags captcha
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Success 200 {object} map[string]interface{} "生成成功 {code: 200, data: {masterImage, tileImage, captchaId, y}}" // @Success 200 {object} map[string]interface{} "生成成功 {code: 200, message: string, data: {masterImage, tileImage, captchaId, y}}"
// @Failure 500 {object} map[string]interface{} "生成失败" // @Failure 500 {object} map[string]interface{} "生成失败"
// @Router /api/v1/captcha/generate [get] // @Router /api/v1/captcha/generate [get]
func (h *CaptchaHandler) Generate(c *gin.Context) { func (h *CaptchaHandler) Generate(c *gin.Context) {
@@ -44,13 +44,14 @@ func (h *CaptchaHandler) Generate(c *gin.Context) {
h.logger.Error("生成验证码失败", zap.Error(err)) h.logger.Error("生成验证码失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"code": 500, "code": 500,
"msg": "生成验证码失败", "message": "生成验证码失败",
}) })
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": 200, "code": 200,
"message": "操作成功",
"data": gin.H{ "data": gin.H{
"masterImage": masterImg, "masterImage": masterImg,
"tileImage": tileImg, "tileImage": tileImg,
@@ -67,7 +68,7 @@ func (h *CaptchaHandler) Generate(c *gin.Context) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body CaptchaVerifyRequest true "验证请求" // @Param request body CaptchaVerifyRequest true "验证请求"
// @Success 200 {object} map[string]interface{} "验证结果 {code: 200/400, msg: string}" // @Success 200 {object} map[string]interface{} "验证结果 {code: 200/400, message: string}"
// @Failure 400 {object} map[string]interface{} "参数错误" // @Failure 400 {object} map[string]interface{} "参数错误"
// @Router /api/v1/captcha/verify [post] // @Router /api/v1/captcha/verify [post]
func (h *CaptchaHandler) Verify(c *gin.Context) { func (h *CaptchaHandler) Verify(c *gin.Context) {
@@ -75,7 +76,7 @@ func (h *CaptchaHandler) Verify(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"code": 400, "code": 400,
"msg": "参数错误: " + err.Error(), "message": "参数错误: " + err.Error(),
}) })
return return
} }
@@ -88,7 +89,7 @@ func (h *CaptchaHandler) Verify(c *gin.Context) {
) )
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"code": 500, "code": 500,
"msg": "验证失败", "message": "验证失败",
}) })
return return
} }
@@ -96,12 +97,12 @@ func (h *CaptchaHandler) Verify(c *gin.Context) {
if valid { if valid {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": 200, "code": 200,
"msg": "验证成功", "message": "验证成功",
}) })
} else { } else {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": 400, "code": 400,
"msg": "验证失败,请重试", "message": "验证失败,请重试",
}) })
} }
} }

View File

@@ -1,8 +1,6 @@
package handler package handler
import ( import (
"bytes"
"io"
"net/http" "net/http"
"regexp" "regexp"
@@ -17,66 +15,18 @@ import (
// 常量定义 // 常量定义
const ( const (
ErrInternalServer = "服务器内部错误"
// 错误类型
ErrInvalidEmailFormat = "邮箱格式不正确"
ErrInvalidPassword = "密码必须至少包含8个字符只能包含字母、数字和特殊字符"
ErrWrongPassword = "密码错误"
ErrUserNotMatch = "用户不匹配"
// 错误消息
ErrInvalidRequest = "请求格式无效"
ErrJoinServerFailed = "加入服务器失败"
ErrServerIDRequired = "服务器ID不能为空"
ErrUsernameRequired = "用户名不能为空"
ErrSessionVerifyFailed = "会话验证失败"
ErrProfileNotFound = "未找到用户配置文件"
ErrInvalidParams = "无效的请求参数"
ErrEmptyUserID = "用户ID为空"
ErrUnauthorized = "无权操作此配置文件"
ErrGetProfileService = "获取配置文件服务失败"
// 成功信息
SuccessProfileCreated = "创建成功"
MsgRegisterSuccess = "注册成功"
// 错误消息
ErrGetProfile = "获取配置文件失败"
ErrGetTextureService = "获取材质服务失败"
ErrInvalidContentType = "无效的请求内容类型"
ErrParseMultipartForm = "解析多部分表单失败"
ErrGetFileFromForm = "从表单获取文件失败"
ErrInvalidFileType = "无效的文件类型仅支持PNG图片"
ErrSaveTexture = "保存材质失败"
ErrSetTexture = "设置材质失败"
ErrGetTexture = "获取材质失败"
// 内存限制 // 内存限制
MaxMultipartMemory = 32 << 20 // 32 MB MaxMultipartMemory = 32 << 20 // 32 MB
// 材质类型 // 材质类型
TextureTypeSkin = "SKIN" TextureTypeSkin = "SKIN"
TextureTypeCape = "CAPE" TextureTypeCape = "CAPE"
// 内容类型
ContentTypePNG = "image/png"
ContentTypeMultipart = "multipart/form-data"
// 表单参数
FormKeyModel = "model"
FormKeyFile = "file"
// 元数据键
MetaKeyModel = "model"
) )
// 正则表达式 // 正则表达式
var ( var (
// 邮箱正则表达式 // 邮箱正则表达式
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
// 密码强度正则表达式最少8位只允许字母、数字和特定特殊字符
passwordRegex = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*]{8,}$`)
) )
// 请求结构体 // 请求结构体
@@ -139,22 +89,6 @@ type (
} }
) )
// APIResponse API响应
type APIResponse struct {
Status int `json:"status"`
Data interface{} `json:"data"`
Error interface{} `json:"error"`
}
// standardResponse 生成标准响应
func standardResponse(c *gin.Context, status int, data interface{}, err interface{}) {
c.JSON(status, APIResponse{
Status: status,
Data: data,
Error: err,
})
}
// YggdrasilHandler Yggdrasil API处理器 // YggdrasilHandler Yggdrasil API处理器
type YggdrasilHandler struct { type YggdrasilHandler struct {
container *container.Container container *container.Container
@@ -180,16 +114,8 @@ func NewYggdrasilHandler(c *container.Container) *YggdrasilHandler {
// @Failure 403 {object} map[string]string "认证失败" // @Failure 403 {object} map[string]string "认证失败"
// @Router /api/yggdrasil/authserver/authenticate [post] // @Router /api/yggdrasil/authserver/authenticate [post]
func (h *YggdrasilHandler) Authenticate(c *gin.Context) { func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
rawData, err := io.ReadAll(c.Request.Body)
if err != nil {
h.logger.Error("读取请求体失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
return
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(rawData))
var request AuthenticateRequest var request AuthenticateRequest
if err = c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
h.logger.Error("解析认证请求失败", zap.Error(err)) h.logger.Error("解析认证请求失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -198,8 +124,10 @@ func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
var userId int64 var userId int64
var profile *model.Profile var profile *model.Profile
var UUID string var UUID string
var err error
if emailRegex.MatchString(request.Identifier) { if emailRegex.MatchString(request.Identifier) {
// 邮箱登录UUID 留空,后续 TokenService.Create 会自动选择该用户的角色
userId, err = h.container.YggdrasilService.GetUserIDByEmail(c.Request.Context(), request.Identifier) userId, err = h.container.YggdrasilService.GetUserIDByEmail(c.Request.Context(), request.Identifier)
} else { } else {
profile, err = h.container.ProfileService.GetByProfileName(c.Request.Context(), request.Identifier) profile, err = h.container.ProfileService.GetByProfileName(c.Request.Context(), request.Identifier)
@@ -507,7 +435,7 @@ func (h *YggdrasilHandler) SignOut(c *gin.Context) {
if !emailRegex.MatchString(request.Email) { if !emailRegex.MatchString(request.Email) {
h.logger.Warn("登出失败: 邮箱格式不正确", zap.String("email", request.Email)) h.logger.Warn("登出失败: 邮箱格式不正确", zap.String("email", request.Email))
c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidEmailFormat}) c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
return return
} }
@@ -579,8 +507,8 @@ func (h *YggdrasilHandler) GetProfileByUUID(c *gin.Context) {
// @Produce json // @Produce json
// @Param request body JoinServerRequest true "加入请求" // @Param request body JoinServerRequest true "加入请求"
// @Success 204 "加入成功" // @Success 204 "加入成功"
// @Failure 400 {object} APIResponse "参数错误" // @Failure 400 {object} map[string]string "参数错误"
// @Failure 500 {object} APIResponse "服务器错误" // @Failure 500 {object} map[string]string "服务器错误"
// @Router /api/yggdrasil/sessionserver/session/minecraft/join [post] // @Router /api/yggdrasil/sessionserver/session/minecraft/join [post]
func (h *YggdrasilHandler) JoinServer(c *gin.Context) { func (h *YggdrasilHandler) JoinServer(c *gin.Context) {
var request JoinServerRequest var request JoinServerRequest
@@ -643,14 +571,14 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
serverID, exists := c.GetQuery("serverId") serverID, exists := c.GetQuery("serverId")
if !exists || serverID == "" { if !exists || serverID == "" {
h.logger.Warn("缺少服务器ID参数", zap.String("ip", clientIP)) h.logger.Warn("缺少服务器ID参数", zap.String("ip", clientIP))
standardResponse(c, http.StatusNoContent, nil, ErrServerIDRequired) c.Status(http.StatusNoContent)
return return
} }
username, exists := c.GetQuery("username") username, exists := c.GetQuery("username")
if !exists || username == "" { if !exists || username == "" {
h.logger.Warn("缺少用户名参数", zap.String("serverId", serverID), zap.String("ip", clientIP)) h.logger.Warn("缺少用户名参数", zap.String("serverId", serverID), zap.String("ip", clientIP))
standardResponse(c, http.StatusNoContent, nil, ErrUsernameRequired) c.Status(http.StatusNoContent)
return return
} }
@@ -667,14 +595,14 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
zap.String("username", username), zap.String("username", username),
zap.String("ip", clientIP), zap.String("ip", clientIP),
) )
standardResponse(c, http.StatusNoContent, nil, ErrSessionVerifyFailed) c.Status(http.StatusNoContent)
return return
} }
profile, err := h.container.ProfileService.GetByProfileName(c.Request.Context(), username) profile, err := h.container.ProfileService.GetByProfileName(c.Request.Context(), username)
if err != nil { if err != nil {
h.logger.Error("获取用户配置文件失败", zap.Error(err), zap.String("username", username)) h.logger.Error("获取用户配置文件失败", zap.Error(err), zap.String("username", username))
standardResponse(c, http.StatusNoContent, nil, ErrProfileNotFound) c.Status(http.StatusNoContent)
return return
} }
@@ -683,7 +611,7 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
zap.String("username", username), zap.String("username", username),
zap.String("uuid", profile.UUID), zap.String("uuid", profile.UUID),
) )
c.JSON(200, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile)) c.JSON(http.StatusOK, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile))
} }
// GetProfilesByName 批量获取配置文件 // GetProfilesByName 批量获取配置文件
@@ -694,14 +622,14 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
// @Produce json // @Produce json
// @Param request body []string true "用户名列表" // @Param request body []string true "用户名列表"
// @Success 200 {array} model.Profile "档案列表" // @Success 200 {array} model.Profile "档案列表"
// @Failure 400 {object} APIResponse "参数错误" // @Failure 400 {object} map[string]string "参数错误"
// @Router /api/yggdrasil/api/profiles/minecraft [post] // @Router /api/yggdrasil/api/profiles/minecraft [post]
func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) { func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) {
var names []string var names []string
if err := c.ShouldBindJSON(&names); err != nil { if err := c.ShouldBindJSON(&names); err != nil {
h.logger.Error("解析名称数组请求失败", zap.Error(err)) h.logger.Error("解析名称数组请求失败", zap.Error(err))
standardResponse(c, http.StatusBadRequest, nil, ErrInvalidParams) c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
return return
} }
@@ -723,7 +651,7 @@ func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Success 200 {object} map[string]interface{} "元数据" // @Success 200 {object} map[string]interface{} "元数据"
// @Failure 500 {object} APIResponse "服务器错误" // @Failure 500 {object} map[string]string "服务器错误"
// @Router /api/yggdrasil [get] // @Router /api/yggdrasil [get]
func (h *YggdrasilHandler) GetMetaData(c *gin.Context) { func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
meta := gin.H{ meta := gin.H{
@@ -765,7 +693,7 @@ func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param Authorization header string true "Bearer {token}" // @Param Authorization header string true "Bearer {token}"
// @Success 200 {object} map[string]interface{} "证书信息" // @Success 200 {object} service.PlayerCertificate "证书信息"
// @Failure 401 {object} errors.YggdrasilErrorResponse "未授权" // @Failure 401 {object} errors.YggdrasilErrorResponse "未授权"
// @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效" // @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效"
// @Failure 500 {object} errors.YggdrasilErrorResponse "服务器错误" // @Failure 500 {object} errors.YggdrasilErrorResponse "服务器错误"
@@ -805,10 +733,10 @@ func (h *YggdrasilHandler) GetPlayerCertificates(c *gin.Context) {
} }
uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), tokenID) uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), tokenID)
if uuid == "" { if err != nil || uuid == "" {
h.logger.Error("获取玩家UUID失败", zap.Error(err)) h.logger.Error("获取玩家UUID失败", zap.Error(err))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
"ForbiddenOperationException", "Unauthorized",
errors.YggErrInvalidToken, errors.YggErrInvalidToken,
"", "",
)) ))

View File

@@ -12,7 +12,14 @@ type Profile struct {
Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名 Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名
SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"` SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"`
CapeID *int64 `gorm:"column:cape_id;type:bigint;index:idx_profiles_cape_id" json:"cape_id,omitempty"` CapeID *int64 `gorm:"column:cape_id;type:bigint;index:idx_profiles_cape_id" json:"cape_id,omitempty"`
RSAPrivateKey string `gorm:"column:rsa_private_key;type:text;not null" json:"-"` // RSA 私钥不返回给前端 // RSA 私钥不返回给前端
RSAPrivateKey string `gorm:"column:rsa_private_key;type:text;not null" json:"-"`
// 玩家证书完整密钥对(持久化以避免每次请求都重新生成 RSA 4096 密钥)
RSAPublicKey string `gorm:"column:rsa_public_key;type:text" json:"-"`
PublicKeySignature string `gorm:"column:public_key_signature;type:text" json:"-"`
PublicKeySignatureV2 string `gorm:"column:public_key_signature_v2;type:text" json:"-"`
KeyExpiresAt *time.Time `gorm:"column:key_expires_at;type:timestamp" json:"-"`
KeyRefreshAt *time.Time `gorm:"column:key_refresh_at;type:timestamp" json:"-"`
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;index:idx_profiles_last_used,sort:desc" json:"last_used_at,omitempty"` LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;index:idx_profiles_last_used,sort:desc" json:"last_used_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_profiles_user_created,priority:2,sort:desc" json:"created_at"` CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_profiles_user_created,priority:2,sort:desc" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`

View File

@@ -132,7 +132,7 @@ func (r *profileRepository) GetKeyPair(ctx context.Context, profileId string) (*
var profile model.Profile var profile model.Profile
result := r.db.WithContext(ctx). result := r.db.WithContext(ctx).
Select("rsa_private_key"). Select("rsa_private_key", "rsa_public_key", "public_key_signature", "public_key_signature_v2", "key_expires_at", "key_refresh_at").
Where("uuid = ?", profileId). Where("uuid = ?", profileId).
First(&profile) First(&profile)
@@ -143,9 +143,19 @@ func (r *profileRepository) GetKeyPair(ctx context.Context, profileId string) (*
return nil, fmt.Errorf("获取key pair失败: %w", result.Error) return nil, fmt.Errorf("获取key pair失败: %w", result.Error)
} }
return &model.KeyPair{ keyPair := &model.KeyPair{
PrivateKey: profile.RSAPrivateKey, PrivateKey: profile.RSAPrivateKey,
}, nil PublicKey: profile.RSAPublicKey,
PublicKeySignature: profile.PublicKeySignature,
PublicKeySignatureV2: profile.PublicKeySignatureV2,
}
if profile.KeyExpiresAt != nil {
keyPair.Expiration = *profile.KeyExpiresAt
}
if profile.KeyRefreshAt != nil {
keyPair.Refresh = *profile.KeyRefreshAt
}
return keyPair, nil
} }
func (r *profileRepository) UpdateKeyPair(ctx context.Context, profileId string, keyPair *model.KeyPair) error { func (r *profileRepository) UpdateKeyPair(ctx context.Context, profileId string, keyPair *model.KeyPair) error {
@@ -159,7 +169,14 @@ func (r *profileRepository) UpdateKeyPair(ctx context.Context, profileId string,
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Profile{}). result := tx.Model(&model.Profile{}).
Where("uuid = ?", profileId). Where("uuid = ?", profileId).
Update("rsa_private_key", keyPair.PrivateKey) Updates(map[string]interface{}{
"rsa_private_key": keyPair.PrivateKey,
"rsa_public_key": keyPair.PublicKey,
"public_key_signature": keyPair.PublicKeySignature,
"public_key_signature_v2": keyPair.PublicKeySignatureV2,
"key_expires_at": keyPair.Expiration,
"key_refresh_at": keyPair.Refresh,
})
if result.Error != nil { if result.Error != nil {
return fmt.Errorf("更新 keyPair 失败: %w", result.Error) return fmt.Errorf("更新 keyPair 失败: %w", result.Error)

View File

@@ -134,7 +134,7 @@ type YggdrasilService interface {
SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{}
// 证书 // 证书
GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error) GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error)
GetPublicKey(ctx context.Context) (string, error) GetPublicKey(ctx context.Context) (string, error)
} }

View File

@@ -12,7 +12,7 @@ import (
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"strconv" "strconv"
"strings" "sync"
"time" "time"
"carrotskin/internal/model" "carrotskin/internal/model"
@@ -38,6 +38,11 @@ type SignatureService struct {
profileRepo repository.ProfileRepository profileRepo repository.ProfileRepository
redis *redis.Client redis *redis.Client
logger *zap.Logger logger *zap.Logger
// 缓存已解析的根密钥对,避免每次签名都重复 PEM 解析PKCS1 解析开销较大)
rootKeyMu sync.RWMutex
rootPrivateKey *rsa.PrivateKey
rootPublicKeyPEM string
} }
// NewSignatureService 创建SignatureService实例 // NewSignatureService 创建SignatureService实例
@@ -91,74 +96,77 @@ func (s *SignatureService) NewKeyPair(ctx context.Context) (*model.KeyPair, erro
return nil, fmt.Errorf("获取Yggdrasil根密钥失败: %w", err) return nil, fmt.Errorf("获取Yggdrasil根密钥失败: %w", err)
} }
// 构造签名消息 // 构造签名消息PEM 公钥 + 过期时间戳)
expiresAtMillis := expiration.UnixMilli() expiresAtMillis := expiration.UnixMilli()
message := []byte(string(publicKeyPEM) + strconv.FormatInt(expiresAtMillis, 10)) message := make([]byte, 0, len(publicKeyPEM)+20)
message = append(message, publicKeyPEM...)
message = strconv.AppendInt(message, expiresAtMillis, 10)
// 使用SHA1withRSA签名 // 使用SHA1withRSA签名
hashed := sha1.Sum(message) publicKeySignature, err := signWithRootKey(yggPrivateKey, message)
signature, err := rsa.SignPKCS1v15(rand.Reader, yggPrivateKey, crypto.SHA1, hashed[:])
if err != nil { if err != nil {
return nil, fmt.Errorf("签名失败: %w", err) return nil, fmt.Errorf("签名失败: %w", err)
} }
publicKeySignature := base64.StdEncoding.EncodeToString(signature)
// 构造V2签名消息DER编码
publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return nil, fmt.Errorf("DER编码公钥失败: %w", err)
}
// V2签名timestamp (8 bytes, big endian) + publicKey (DER) // V2签名timestamp (8 bytes, big endian) + publicKey (DER)
messageV2 := make([]byte, 8+len(publicKeyDER)) messageV2 := make([]byte, 8+len(publicKeyBytes))
binary.BigEndian.PutUint64(messageV2[0:8], uint64(expiresAtMillis)) binary.BigEndian.PutUint64(messageV2[0:8], uint64(expiresAtMillis))
copy(messageV2[8:], publicKeyDER) copy(messageV2[8:], publicKeyBytes)
hashedV2 := sha1.Sum(messageV2) publicKeySignatureV2, err := signWithRootKey(yggPrivateKey, messageV2)
signatureV2, err := rsa.SignPKCS1v15(rand.Reader, yggPrivateKey, crypto.SHA1, hashedV2[:])
if err != nil { if err != nil {
return nil, fmt.Errorf("V2签名失败: %w", err) return nil, fmt.Errorf("V2签名失败: %w", err)
} }
publicKeySignatureV2 := base64.StdEncoding.EncodeToString(signatureV2)
return &model.KeyPair{ return &model.KeyPair{
PrivateKey: string(privateKeyPEM), PrivateKey: string(privateKeyPEM),
PublicKey: string(publicKeyPEM), PublicKey: string(publicKeyPEM),
PublicKeySignature: publicKeySignature, PublicKeySignature: base64.StdEncoding.EncodeToString(publicKeySignature),
PublicKeySignatureV2: publicKeySignatureV2, PublicKeySignatureV2: base64.StdEncoding.EncodeToString(publicKeySignatureV2),
YggdrasilPublicKey: yggPublicKey, YggdrasilPublicKey: yggPublicKey,
Expiration: expiration, Expiration: expiration,
Refresh: refresh, Refresh: refresh,
}, nil }, nil
} }
// GetOrCreateYggdrasilKeyPair 获取或创建Yggdrasil根密钥对 // signWithRootKey 使用根密钥对消息做 SHA1withRSA 签名
func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (string, *rsa.PrivateKey, error) { func signWithRootKey(privateKey *rsa.PrivateKey, message []byte) ([]byte, error) {
// 尝试从Redis获取密钥 hashed := sha1.Sum(message)
publicKeyPEM, err := s.redis.Get(ctx, PublicKeyRedisKey) return rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
if err == nil && publicKeyPEM != "" {
privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey)
if err == nil && privateKeyPEM != "" {
// 检查密钥是否过期
expStr, err := s.redis.Get(ctx, KeyExpirationRedisKey)
if err == nil && expStr != "" {
expTime, err := time.Parse(time.RFC3339, expStr)
if err == nil && time.Now().Before(expTime) {
// 密钥有效,解析私钥
block, _ := pem.Decode([]byte(privateKeyPEM))
if block != nil {
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err == nil {
s.logger.Info("从Redis加载Yggdrasil根密钥")
return publicKeyPEM, privateKey, nil
}
}
}
}
}
} }
// 生成新的根密钥对 // GetOrCreateYggdrasilKeyPair 获取或创建Yggdrasil根密钥对
// 优先使用进程内缓存的已解析私钥缓存未命中时从Redis加载并解析再缓存。
func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (string, *rsa.PrivateKey, error) {
// 1. 读缓存(快路径)
s.rootKeyMu.RLock()
if s.rootPrivateKey != nil && s.isRootKeyValid() {
pub := s.rootPublicKeyPEM
priv := s.rootPrivateKey
s.rootKeyMu.RUnlock()
return pub, priv, nil
}
s.rootKeyMu.RUnlock()
// 2. 缓存未命中,加锁准备从 Redis 加载/生成
s.rootKeyMu.Lock()
defer s.rootKeyMu.Unlock()
// 双重检查:可能在等待锁期间已被其他 goroutine 填充
if s.rootPrivateKey != nil && s.isRootKeyValidLocked() {
return s.rootPublicKeyPEM, s.rootPrivateKey, nil
}
// 尝试从Redis获取密钥MGet 一次往返拿三个字段,减少网络往返)
pub, priv, err := s.loadRootKeyFromRedis(ctx)
if err == nil && priv != nil {
s.rootPrivateKey = priv
s.rootPublicKeyPEM = pub
s.logger.Info("从Redis加载Yggdrasil根密钥")
return pub, priv, nil
}
// Redis 没有,生成新的根密钥对
s.logger.Info("生成新的Yggdrasil根密钥对") s.logger.Info("生成新的Yggdrasil根密钥对")
privateKey, err := rsa.GenerateKey(rand.Reader, KeySize) privateKey, err := rsa.GenerateKey(rand.Reader, KeySize)
if err != nil { if err != nil {
@@ -177,7 +185,7 @@ func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (str
if err != nil { if err != nil {
return "", nil, fmt.Errorf("编码公钥失败: %w", err) return "", nil, fmt.Errorf("编码公钥失败: %w", err)
} }
publicKeyPEM = string(pem.EncodeToMemory(&pem.Block{ publicKeyPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY", Type: "PUBLIC KEY",
Bytes: publicKeyBytes, Bytes: publicKeyBytes,
})) }))
@@ -196,9 +204,57 @@ func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (str
s.logger.Warn("保存密钥过期时间到Redis失败", zap.Error(err)) s.logger.Warn("保存密钥过期时间到Redis失败", zap.Error(err))
} }
// 缓存已解析的私钥与公钥PEM
s.rootPrivateKey = privateKey
s.rootPublicKeyPEM = publicKeyPEM
return publicKeyPEM, privateKey, nil return publicKeyPEM, privateKey, nil
} }
// loadRootKeyFromRedis 从Redis加载根密钥使用 MGet 单次往返获取三字段
func (s *SignatureService) loadRootKeyFromRedis(ctx context.Context) (string, *rsa.PrivateKey, error) {
results, err := s.redis.MGet(ctx, PublicKeyRedisKey, PrivateKeyRedisKey, KeyExpirationRedisKey).Result()
if err != nil {
return "", nil, err
}
if len(results) < 3 {
return "", nil, fmt.Errorf("MGet 返回字段数不足")
}
publicKeyPEM, _ := results[0].(string)
privateKeyPEM, _ := results[1].(string)
expStr, _ := results[2].(string)
if publicKeyPEM == "" || privateKeyPEM == "" || expStr == "" {
return "", nil, fmt.Errorf("Redis中密钥字段不完整")
}
expTime, err := time.Parse(time.RFC3339, expStr)
if err != nil || !time.Now().Before(expTime) {
return "", nil, fmt.Errorf("密钥已过期或过期时间解析失败")
}
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return "", nil, fmt.Errorf("解析PEM私钥失败")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", nil, fmt.Errorf("解析RSA私钥失败: %w", err)
}
return publicKeyPEM, privateKey, nil
}
// isRootKeyValid 在持有读锁时判断缓存密钥是否过期。
// 注意:缓存的私钥本身不带过期时间,过期信息存于 Redis由 loadRootKeyFromRedis 校验)。
// 一旦加载成功即认为在进程生命周期内有效,由 Redis 过期触发重新生成。
func (s *SignatureService) isRootKeyValid() bool {
return s.isRootKeyValidLocked()
}
func (s *SignatureService) isRootKeyValidLocked() bool {
return s.rootPrivateKey != nil && s.rootPublicKeyPEM != ""
}
// GetPublicKeyFromRedis 从Redis获取公钥 // GetPublicKeyFromRedis 从Redis获取公钥
func (s *SignatureService) GetPublicKeyFromRedis(ctx context.Context) (string, error) { func (s *SignatureService) GetPublicKeyFromRedis(ctx context.Context) (string, error) {
publicKey, err := s.redis.Get(ctx, PublicKeyRedisKey) publicKey, err := s.redis.Get(ctx, PublicKeyRedisKey)
@@ -216,81 +272,16 @@ func (s *SignatureService) GetPublicKeyFromRedis(ctx context.Context) (string, e
} }
// SignStringWithSHA1withRSA 使用SHA1withRSA签名字符串 // SignStringWithSHA1withRSA 使用SHA1withRSA签名字符串
// 使用缓存的已解析根私钥;若缓存为空则触发加载/生成。
func (s *SignatureService) SignStringWithSHA1withRSA(ctx context.Context, data string) (string, error) { func (s *SignatureService) SignStringWithSHA1withRSA(ctx context.Context, data string) (string, error) {
// 从Redis获取私钥
privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey)
if err != nil || privateKeyPEM == "" {
// 如果没有私钥,创建新的密钥对
_, privateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx) _, privateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx)
if err != nil { if err != nil {
return "", fmt.Errorf("获取私钥失败: %w", err) return "", fmt.Errorf("获取签名私钥失败: %w", err)
} }
// 使用新生成的私钥签名
hashed := sha1.Sum([]byte(data)) signature, err := signWithRootKey(privateKey, []byte(data))
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
if err != nil { if err != nil {
return "", fmt.Errorf("签名失败: %w", err) return "", fmt.Errorf("签名失败: %w", err)
} }
return base64.StdEncoding.EncodeToString(signature), nil return base64.StdEncoding.EncodeToString(signature), nil
} }
// 解析PEM格式的私钥
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return "", fmt.Errorf("解析PEM私钥失败")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("解析RSA私钥失败: %w", err)
}
// 签名
hashed := sha1.Sum([]byte(data))
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
if err != nil {
return "", fmt.Errorf("签名失败: %w", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}
// FormatPublicKey 格式化公钥为单行格式去除PEM头尾和换行符
func FormatPublicKey(publicKeyPEM string) string {
// 移除PEM格式的头尾
lines := strings.Split(publicKeyPEM, "\n")
var keyLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" &&
!strings.HasPrefix(trimmed, "-----BEGIN") &&
!strings.HasPrefix(trimmed, "-----END") {
keyLines = append(keyLines, trimmed)
}
}
return strings.Join(keyLines, "")
}
// SignStringWithProfileRSA 使用Profile的RSA私钥签名字符串
func (s *SignatureService) SignStringWithProfileRSA(data string, privateKeyPEM string) (string, error) {
// 解析PEM格式的私钥
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return "", fmt.Errorf("解析PEM私钥失败")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("解析RSA私钥失败: %w", err)
}
// 签名
hashed := sha1.Sum([]byte(data))
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
if err != nil {
return "", fmt.Errorf("签名失败: %w", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}

View File

@@ -0,0 +1,403 @@
package service
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"encoding/pem"
"fmt"
"strconv"
"strings"
"sync"
"testing"
pkgRedis "carrotskin/pkg/redis"
miniredis "github.com/alicebob/miniredis/v2"
goRedis "github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// newTestSignatureService 构造一个基于 miniredis 的 SignatureService用于测试。
// 返回服务实例与清理函数。
func newTestSignatureService(t *testing.T) (*SignatureService, *miniredis.Miniredis, func()) {
t.Helper()
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("启动 miniredis 失败: %v", err)
}
rdb := goRedis.NewClient(&goRedis.Options{Addr: mr.Addr()})
client := &pkgRedis.Client{Client: rdb}
svc := NewSignatureService(nil, client, zap.NewNop())
cleanup := func() {
_ = rdb.Close()
mr.Close()
}
return svc, mr, cleanup
}
// TestSignatureService_SignString_Deterministic 验证 SHA1withRSA 签名的确定性:
// 相同 data + 相同根密钥应产生完全相同的签名PKCS#1v1.5 非随机化)。
// 这也是本次重构(缓存已解析私钥)后输出与重构前一致的核心保证。
func TestSignatureService_SignString_Deterministic(t *testing.T) {
svc, _, cleanup := newTestSignatureService(t)
defer cleanup()
ctx := context.Background()
// 首次签名会生成并缓存根密钥
sig1, err := svc.SignStringWithSHA1withRSA(ctx, "test-data-123")
if err != nil {
t.Fatalf("首次签名失败: %v", err)
}
// 第二次签名应命中缓存的已解析私钥,输出必须与首次完全一致
sig2, err := svc.SignStringWithSHA1withRSA(ctx, "test-data-123")
if err != nil {
t.Fatalf("第二次签名失败: %v", err)
}
if sig1 != sig2 {
t.Fatalf("签名不确定sig1=%q sig2=%q", sig1, sig2)
}
// 不同数据应产生不同签名
sig3, err := svc.SignStringWithSHA1withRSA(ctx, "different-data")
if err != nil {
t.Fatalf("不同数据签名失败: %v", err)
}
if sig1 == sig3 {
t.Fatal("不同数据的签名不应相同")
}
}
// TestSignatureService_SignString_MatchesReference 验证重构后的签名与
// 重构前的参考实现(直接从 Redis 读取私钥 PEM 并解析、逐次签名)产生相同字节序列。
// 这是签名一致性的回归测试:只要 data 与根私钥相同,新旧实现输出一致。
func TestSignatureService_SignString_MatchesReference(t *testing.T) {
svc, mr, cleanup := newTestSignatureService(t)
defer cleanup()
ctx := context.Background()
// 触发根密钥生成并写入 miniredis
if _, err := svc.SignStringWithSHA1withRSA(ctx, "consistency-check"); err != nil {
t.Fatalf("触发根密钥生成失败: %v", err)
}
// 从 miniredis 读取根私钥 PEM模拟重构前每次签名都重新解析的逻辑
privPEM, err := mr.Get(PrivateKeyRedisKey)
if err != nil {
t.Fatalf("读取根私钥失败: %v", err)
}
cases := []string{
"",
"a",
strings.Repeat("x", 1024),
"eyJ0aW1lc3RhbXAiOjE3MDAwMDAwMDAwMDAsInByb2ZpbGVJZCI6ImFiYzEyIiwicHJvZmlsZU5hbWUiOiJUZXN0In0=",
}
for _, data := range cases {
// 重构后实现
got, err := svc.SignStringWithSHA1withRSA(ctx, data)
if err != nil {
t.Fatalf("SignStringWithSHA1withRSA(%q) 失败: %v", data, err)
}
// 参考实现重构前逻辑pem.Decode + ParsePKCS1PrivateKey + SignPKCS1v15
block, _ := pem.Decode([]byte(privPEM))
if block == nil {
t.Fatalf("参考实现解析 PEM 失败")
}
priv, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
t.Fatalf("参考实现解析 RSA 私钥失败: %v", err)
}
hashed := sha1.Sum([]byte(data))
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA1, hashed[:])
if err != nil {
t.Fatalf("参考实现签名失败: %v", err)
}
want := base64.StdEncoding.EncodeToString(sig)
if got != want {
t.Fatalf("签名与参考实现不一致 data=%q\n got=%q\nwant=%q", data, got, want)
}
}
}
// TestSignatureService_RootKeyCachedAcrossReloads 验证:
// 同一进程内多次 GetOrCreateYggdrasilKeyPair 返回同一已解析私钥实例(缓存生效),
// 且不重复访问 Redis。
func TestSignatureService_RootKeyCachedAcrossReloads(t *testing.T) {
svc, mr, cleanup := newTestSignatureService(t)
defer cleanup()
ctx := context.Background()
// 首次生成
pub1, priv1, err := svc.GetOrCreateYggdrasilKeyPair(ctx)
if err != nil {
t.Fatalf("首次 GetOrCreate 失败: %v", err)
}
// 第二次应命中缓存,返回同一私钥指针
pub2, priv2, err := svc.GetOrCreateYggdrasilKeyPair(ctx)
if err != nil {
t.Fatalf("第二次 GetOrCreate 失败: %v", err)
}
if priv1 != priv2 {
t.Fatal("缓存未命中:返回了不同的私钥实例")
}
if pub1 != pub2 {
t.Fatal("公钥 PEM 不一致")
}
// Redis 仅应被写入一次(首次生成);记录当前 key 数量,再次调用不应增加
keysBefore := len(mr.Keys())
_, _, _ = svc.GetOrCreateYggdrasilKeyPair(ctx)
if got := len(mr.Keys()); got != keysBefore {
t.Fatalf("缓存命中后仍写入 Rediskeys before=%d after=%d", keysBefore, got)
}
}
// TestSignatureService_NewKeyPair_SignatureVerifies 验证 NewKeyPair 生成的
// publicKeySignature / publicKeySignatureV2 能用根公钥正确验签,
// 且消息构造与重构前格式一致PEM公钥+时间戳DER公钥+时间戳)。
func TestSignatureService_NewKeyPair_SignatureVerifies(t *testing.T) {
svc, mr, cleanup := newTestSignatureService(t)
defer cleanup()
ctx := context.Background()
kp, err := svc.NewKeyPair(ctx)
if err != nil {
t.Fatalf("NewKeyPair 失败: %v", err)
}
// 取根公钥用于验签
rootPubPEM, err := mr.Get(PublicKeyRedisKey)
if err != nil {
t.Fatalf("读取根公钥失败: %v", err)
}
block, _ := pem.Decode([]byte(rootPubPEM))
if block == nil {
t.Fatal("解析根公钥 PEM 失败")
}
rootPub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
t.Fatalf("解析根公钥失败: %v", err)
}
rsaRootPub, ok := rootPub.(*rsa.PublicKey)
if !ok {
t.Fatal("根公钥不是 RSA 公钥")
}
expiresAtMillis := kp.Expiration.UnixMilli()
// V1 消息:重构前为 string(publicKeyPEM) + strconv.FormatInt(expiresAtMillis, 10)
// 重构后用 append 拼接,字节序列应完全一致
pubPEM := []byte(kp.PublicKey)
v1Message := make([]byte, 0, len(pubPEM)+20)
v1Message = append(v1Message, pubPEM...)
v1Message = strconv.AppendInt(v1Message, expiresAtMillis, 10)
sig1, err := base64.StdEncoding.DecodeString(kp.PublicKeySignature)
if err != nil {
t.Fatalf("解码 publicKeySignature 失败: %v", err)
}
hashed1 := sha1.Sum(v1Message)
if err := rsa.VerifyPKCS1v15(rsaRootPub, crypto.SHA1, hashed1[:], sig1); err != nil {
t.Fatalf("V1 签名验签失败: %v", err)
}
// V2 消息:重构前为 timestamp(8 BE) + 玩家公钥 DER
// 玩家公钥 DER 来自 kp.PublicKey 的 PEM 解码
playerBlock, _ := pem.Decode([]byte(kp.PublicKey))
if playerBlock == nil {
t.Fatal("解析玩家公钥 PEM 失败")
}
playerDER := playerBlock.Bytes // PKIX 编码的 SubjectPublicKeyInfo DER
v2Message := make([]byte, 8+len(playerDER))
binary.BigEndian.PutUint64(v2Message[0:8], uint64(expiresAtMillis))
copy(v2Message[8:], playerDER)
sig2, err := base64.StdEncoding.DecodeString(kp.PublicKeySignatureV2)
if err != nil {
t.Fatalf("解码 publicKeySignatureV2 失败: %v", err)
}
hashed2 := sha1.Sum(v2Message)
if err := rsa.VerifyPKCS1v15(rsaRootPub, crypto.SHA1, hashed2[:], sig2); err != nil {
t.Fatalf("V2 签名验签失败: %v", err)
}
}
// TestSignatureService_NewKeyPair_V1MessageBytes 验证 V1 消息字节构造与重构前完全一致。
// 重构前:[]byte(string(publicKeyPEM) + strconv.FormatInt(expiresAtMillis, 10))
// 重构后append(publicKeyPEM...); strconv.AppendInt(msg, expiresAtMillis, 10)
func TestSignatureService_NewKeyPair_V1MessageBytes(t *testing.T) {
// 构造一个固定的 PEM 公钥与时间戳,对比两种构造方式的字节
pubPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: []byte("dummy-der-bytes"),
})
expiresAtMillis := int64(1700000000000)
oldStyle := []byte(string(pubPEM) + strconv.FormatInt(expiresAtMillis, 10))
newStyle := make([]byte, 0, len(pubPEM)+20)
newStyle = append(newStyle, pubPEM...)
newStyle = strconv.AppendInt(newStyle, expiresAtMillis, 10)
if string(oldStyle) != string(newStyle) {
t.Fatalf("V1 消息字节不一致\n old=%q\n new=%q", oldStyle, newStyle)
}
}
// TestSignatureService_GetOrCreateYggdrasilKeyPair_LoadsFromRedis 验证:
// 当进程内缓存为空(新实例)但 Redis 中已有有效密钥时,能正确加载而非重新生成。
// 使用同一 miniredis 实例,避免新旧实例切换时数据丢失。
func TestSignatureService_GetOrCreateYggdrasilKeyPair_LoadsFromRedis(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("启动 miniredis 失败: %v", err)
}
t.Cleanup(mr.Close)
rdb := goRedis.NewClient(&goRedis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
client := &pkgRedis.Client{Client: rdb}
ctx := context.Background()
// 实例1 生成根密钥(写入 miniredis
svc1 := NewSignatureService(nil, client, zap.NewNop())
pub1, priv1, err := svc1.GetOrCreateYggdrasilKeyPair(ctx)
if err != nil {
t.Fatalf("实例1生成失败: %v", err)
}
// 新建实例2缓存为空但 Redis 中已有密钥
svc2 := NewSignatureService(nil, client, zap.NewNop())
pub2, priv2, err := svc2.GetOrCreateYggdrasilKeyPair(ctx)
if err != nil {
t.Fatalf("实例2加载失败: %v", err)
}
if pub1 != pub2 {
t.Fatal("从 Redis 加载的公钥 PEM 不一致")
}
// 加载的私钥应与原始私钥在数学上等价N 相同)
if priv1.N.Cmp(priv2.N) != 0 {
t.Fatal("从 Redis 加载的私钥与原始私钥不匹配")
}
}
// TestSignatureService_SignConcurrent 验证签名缓存层在并发下的正确性(无数据竞争)。
func TestSignatureService_SignConcurrent(t *testing.T) {
svc, _, cleanup := newTestSignatureService(t)
defer cleanup()
ctx := context.Background()
// 先生成根密钥
seed, err := svc.SignStringWithSHA1withRSA(ctx, "seed")
if err != nil {
t.Fatalf("种子签名失败: %v", err)
}
const n = 32
var wg sync.WaitGroup
errs := make(chan error, n)
sigs := make(chan string, n)
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
sig, err := svc.SignStringWithSHA1withRSA(ctx, "seed")
if err != nil {
errs <- err
return
}
sigs <- sig
}()
}
wg.Wait()
close(errs)
close(sigs)
for err := range errs {
t.Fatalf("并发签名出错: %v", err)
}
for sig := range sigs {
if sig != seed {
t.Fatalf("并发签名结果与预期不一致got=%q want=%q", sig, seed)
}
}
}
// TestSignatureService_KeyPairFieldsPopulated 验证 NewKeyPair 返回的 KeyPair 字段完整。
func TestSignatureService_KeyPairFieldsPopulated(t *testing.T) {
svc, _, cleanup := newTestSignatureService(t)
defer cleanup()
ctx := context.Background()
kp, err := svc.NewKeyPair(ctx)
if err != nil {
t.Fatalf("NewKeyPair 失败: %v", err)
}
checks := []struct {
name string
got string
}{
{"PrivateKey", kp.PrivateKey},
{"PublicKey", kp.PublicKey},
{"PublicKeySignature", kp.PublicKeySignature},
{"PublicKeySignatureV2", kp.PublicKeySignatureV2},
{"YggdrasilPublicKey", kp.YggdrasilPublicKey},
}
for _, c := range checks {
if c.got == "" {
t.Errorf("KeyPair.%s 为空", c.name)
}
}
if kp.Expiration.IsZero() || kp.Refresh.IsZero() {
t.Error("KeyPair.Expiration/Refresh 未设置")
}
if !kp.Refresh.Before(kp.Expiration) {
t.Error("Refresh 应早于 Expiration")
}
}
// 确保引用的包被使用(避免未使用导入在编辑后报错)
var _ = fmt.Sprintf
// BenchmarkSignStringWithSHA1withRSA 衡量缓存命中路径的签名性能(无 PEM 解析、无 Redis 往返)。
func BenchmarkSignStringWithSHA1withRSA(b *testing.B) {
mr, err := miniredis.Run()
if err != nil {
b.Fatalf("启动 miniredis 失败: %v", err)
}
defer mr.Close()
rdb := goRedis.NewClient(&goRedis.Options{Addr: mr.Addr()})
defer func() { _ = rdb.Close() }()
client := &pkgRedis.Client{Client: rdb}
ctx := context.Background()
svc := NewSignatureService(nil, client, zap.NewNop())
// 预热:生成根密钥并填充缓存
if _, err := svc.SignStringWithSHA1withRSA(ctx, "warmup"); err != nil {
b.Fatalf("预热失败: %v", err)
}
data := strings.Repeat("x", 256)
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := svc.SignStringWithSHA1withRSA(ctx, data); err != nil {
b.Fatalf("签名失败: %v", err)
}
}
}

View File

@@ -14,11 +14,26 @@ import (
// CertificateService 证书服务接口 // CertificateService 证书服务接口
type CertificateService interface { type CertificateService interface {
// GeneratePlayerCertificate 生成玩家证书 // GeneratePlayerCertificate 生成玩家证书
GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error) GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error)
// GetPublicKey 获取公钥 // GetPublicKey 获取公钥
GetPublicKey(ctx context.Context) (string, error) GetPublicKey(ctx context.Context) (string, error)
} }
// PlayerCertificate 玩家证书
type PlayerCertificate struct {
KeyPair PlayerKeyPair `json:"keyPair"`
PublicKeySignature string `json:"publicKeySignature"`
PublicKeySignatureV2 string `json:"publicKeySignatureV2"`
ExpiresAt int64 `json:"expiresAt"`
RefreshedAfter int64 `json:"refreshedAfter"`
}
// PlayerKeyPair 玩家证书密钥对
type PlayerKeyPair struct {
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
}
// yggdrasilCertificateService 证书服务实现 // yggdrasilCertificateService 证书服务实现
type yggdrasilCertificateService struct { type yggdrasilCertificateService struct {
profileRepo repository.ProfileRepository profileRepo repository.ProfileRepository
@@ -40,7 +55,7 @@ func NewCertificateService(
} }
// GeneratePlayerCertificate 生成玩家证书 // GeneratePlayerCertificate 生成玩家证书
func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error) { func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error) {
if uuid == "" { if uuid == "" {
return nil, apperrors.ErrUUIDRequired return nil, apperrors.ErrUUIDRequired
} }
@@ -89,15 +104,15 @@ func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Cont
expiresAtMillis := keyPair.Expiration.UnixMilli() expiresAtMillis := keyPair.Expiration.UnixMilli()
// 返回玩家证书 // 返回玩家证书
certificate := map[string]interface{}{ certificate := &PlayerCertificate{
"keyPair": map[string]interface{}{ KeyPair: PlayerKeyPair{
"privateKey": keyPair.PrivateKey, PrivateKey: keyPair.PrivateKey,
"publicKey": keyPair.PublicKey, PublicKey: keyPair.PublicKey,
}, },
"publicKeySignature": keyPair.PublicKeySignature, PublicKeySignature: keyPair.PublicKeySignature,
"publicKeySignatureV2": keyPair.PublicKeySignatureV2, PublicKeySignatureV2: keyPair.PublicKeySignatureV2,
"expiresAt": expiresAtMillis, ExpiresAt: expiresAtMillis,
"refreshedAfter": keyPair.Refresh.UnixMilli(), RefreshedAfter: keyPair.Refresh.UnixMilli(),
} }
s.logger.Info("成功生成玩家证书", s.logger.Info("成功生成玩家证书",

View File

@@ -55,8 +55,8 @@ func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, pr
// SerializeProfileWithUnsigned 序列化档案为Yggdrasil格式支持unsigned参数 // SerializeProfileWithUnsigned 序列化档案为Yggdrasil格式支持unsigned参数
func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} { func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} {
// 创建基本材质数据 // 预分配材质 map最多 SKIN + CAPE 两个条目,避免运行时扩容)
texturesMap := make(map[string]interface{}) texturesMap := make(map[string]interface{}, 2)
textures := map[string]interface{}{ textures := map[string]interface{}{
"timestamp": time.Now().UnixMilli(), "timestamp": time.Now().UnixMilli(),
"profileId": profile.UUID, "profileId": profile.UUID,
@@ -64,7 +64,7 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
"textures": texturesMap, "textures": texturesMap,
} }
// 处理皮肤 // 处理皮肤metadata 仅对 SKIN 有效,值为 {"model":"slim"}classic/默认省略 metadata
if profile.SkinID != nil { if profile.SkinID != nil {
skin, err := s.textureRepo.FindByID(ctx, *profile.SkinID) skin, err := s.textureRepo.FindByID(ctx, *profile.SkinID)
if err != nil { if err != nil {
@@ -73,14 +73,19 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
zap.Int64("skinID", *profile.SkinID), zap.Int64("skinID", *profile.SkinID),
) )
} else if skin != nil { } else if skin != nil {
// slim 才需要 metadata 字段classic 直接仅含 url
if skin.IsSlim {
texturesMap["SKIN"] = map[string]interface{}{ texturesMap["SKIN"] = map[string]interface{}{
"url": skin.URL, "url": skin.URL,
"metadata": skin.Size, "metadata": map[string]string{"model": "slim"},
}
} else {
texturesMap["SKIN"] = map[string]interface{}{"url": skin.URL}
} }
} }
} }
// 处理披风 // 处理披风CAPE 不携带 metadata 字段
if profile.CapeID != nil { if profile.CapeID != nil {
cape, err := s.textureRepo.FindByID(ctx, *profile.CapeID) cape, err := s.textureRepo.FindByID(ctx, *profile.CapeID)
if err != nil { if err != nil {
@@ -89,15 +94,12 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
zap.Int64("capeID", *profile.CapeID), zap.Int64("capeID", *profile.CapeID),
) )
} else if cape != nil { } else if cape != nil {
texturesMap["CAPE"] = map[string]interface{}{ texturesMap["CAPE"] = map[string]interface{}{"url": cape.URL}
"url": cape.URL,
"metadata": cape.Size,
}
} }
} }
// 将 textures 编码为 base64 // 将 textures 编码为 base64
bytes, err := json.Marshal(textures) textureBytes, err := json.Marshal(textures)
if err != nil { if err != nil {
s.logger.Error("序列化textures失败", s.logger.Error("序列化textures失败",
zap.Error(err), zap.Error(err),
@@ -106,12 +108,15 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
return nil return nil
} }
textureData := base64.StdEncoding.EncodeToString(bytes) textureData := base64.StdEncoding.EncodeToString(textureBytes)
// 只有在 unsigned=false 时才签名 // 只有在 unsigned=false 时才签名
var signature string property := Property{
Name: "textures",
Value: textureData,
}
if !unsigned { if !unsigned {
signature, err = s.signatureService.SignStringWithSHA1withRSA(ctx, textureData) signature, err := s.signatureService.SignStringWithSHA1withRSA(ctx, textureData)
if err != nil { if err != nil {
s.logger.Error("签名textures失败", s.logger.Error("签名textures失败",
zap.Error(err), zap.Error(err),
@@ -119,26 +124,15 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
) )
return nil return nil
} }
}
// 构建属性
property := Property{
Name: "textures",
Value: textureData,
}
// 只有在 unsigned=false 时才添加签名
if !unsigned {
property.Signature = signature property.Signature = signature
} }
// 构建结果 // 构建结果
data := map[string]interface{}{ return map[string]interface{}{
"id": profile.UUID, "id": profile.UUID,
"name": profile.Name, "name": profile.Name,
"properties": []Property{property}, "properties": []Property{property},
} }
return data
} }
// SerializeUser 序列化用户为Yggdrasil格式 // SerializeUser 序列化用户为Yggdrasil格式

View File

@@ -125,7 +125,7 @@ func (s *yggdrasilServiceComposite) SerializeUser(ctx context.Context, user *mod
} }
// GeneratePlayerCertificate 生成玩家证书 // GeneratePlayerCertificate 生成玩家证书
func (s *yggdrasilServiceComposite) GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error) { func (s *yggdrasilServiceComposite) GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error) {
return s.certificateService.GeneratePlayerCertificate(ctx, uuid) return s.certificateService.GeneratePlayerCertificate(ctx, uuid)
} }

View File

@@ -83,7 +83,7 @@ func (s *yggdrasilSessionService) CreateSession(ctx context.Context, serverID, a
return apperrors.ErrInvalidAccessToken return apperrors.ErrInvalidAccessToken
} }
if username == "" { if username == "" {
return apperrors.ErrUsernameMismatch return apperrors.ErrUsernameRequired
} }
if profileUUID == "" { if profileUUID == "" {
return apperrors.ErrProfileMismatch return apperrors.ErrProfileMismatch
@@ -127,12 +127,8 @@ func (s *yggdrasilSessionService) CreateSession(ctx context.Context, serverID, a
return nil return nil
} }
// GetSession 获取会话数据 // GetSession 获取会话数据(内部调用前已对 serverID 校验过,跳过重复校验)
func (s *yggdrasilSessionService) GetSession(ctx context.Context, serverID string) (*SessionData, error) { func (s *yggdrasilSessionService) GetSession(ctx context.Context, serverID string) (*SessionData, error) {
if err := ValidateServerID(serverID); err != nil {
return nil, err
}
// 从Redis获取会话数据 // 从Redis获取会话数据
sessionKey := SessionKeyPrefix + serverID sessionKey := SessionKeyPrefix + serverID
data, err := s.redis.GetBytes(ctx, sessionKey) data, err := s.redis.GetBytes(ctx, sessionKey)
@@ -163,6 +159,7 @@ func (s *yggdrasilSessionService) ValidateSession(ctx context.Context, serverID,
return apperrors.ErrSessionMismatch return apperrors.ErrSessionMismatch
} }
// ValidateSession 由 HasJoinedServer 调用serverID 已经过校验,此处无需重复 ValidateServerID
sessionData, err := s.GetSession(ctx, serverID) sessionData, err := s.GetSession(ctx, serverID)
if err != nil { if err != nil {
return apperrors.ErrSessionNotFound return apperrors.ErrSessionNotFound

View File

@@ -1,103 +0,0 @@
package service
import (
"errors"
"net"
"regexp"
"strings"
)
// Validator Yggdrasil验证器
type Validator struct{}
// NewValidator 创建验证器实例
func NewValidator() *Validator {
return &Validator{}
}
var (
// emailRegex 邮箱正则表达式
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
)
// ValidateServerID 验证服务器ID格式
func (v *Validator) ValidateServerID(serverID string) error {
if serverID == "" {
return errors.New("服务器ID不能为空")
}
if len(serverID) > 100 {
return errors.New("服务器ID长度超过限制最大100字符")
}
// 防止注入攻击:检查危险字符
if strings.ContainsAny(serverID, "<>\"'&") {
return errors.New("服务器ID包含非法字符")
}
return nil
}
// ValidateIP 验证IP地址格式
func (v *Validator) ValidateIP(ip string) error {
if ip == "" {
return nil // IP是可选的
}
if net.ParseIP(ip) == nil {
return errors.New("IP地址格式无效")
}
return nil
}
// ValidateEmail 验证邮箱格式
func (v *Validator) ValidateEmail(email string) error {
if email == "" {
return errors.New("邮箱不能为空")
}
if !emailRegex.MatchString(email) {
return errors.New("邮箱格式不正确")
}
return nil
}
// ValidateUUID 验证UUID格式支持32位无符号和36位带连字符格式
func (v *Validator) ValidateUUID(uuid string) error {
if uuid == "" {
return errors.New("UUID不能为空")
}
// 验证32位无符号UUID格式纯十六进制字符串
if len(uuid) == 32 {
// 检查是否为有效的十六进制字符串
for _, c := range uuid {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return errors.New("UUID格式无效包含非十六进制字符")
}
}
return nil
}
// 验证36位标准UUID格式带连字符
if len(uuid) == 36 && uuid[8] == '-' && uuid[13] == '-' && uuid[18] == '-' && uuid[23] == '-' {
// 检查除连字符外的字符是否为有效的十六进制
for i, c := range uuid {
if i == 8 || i == 13 || i == 18 || i == 23 {
continue // 跳过连字符位置
}
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return errors.New("UUID格式无效包含非十六进制字符")
}
}
return nil
}
return errors.New("UUID格式无效长度应为32位或36位")
}
// ValidateAccessToken 验证访问令牌
func (v *Validator) ValidateAccessToken(token string) error {
if token == "" {
return errors.New("访问令牌不能为空")
}
if len(token) < 10 {
return errors.New("访问令牌格式无效")
}
return nil
}

View File

@@ -1,29 +1,13 @@
package auth package auth
import ( import (
"context"
"crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors" "errors"
"fmt"
"sync"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
const (
YggdrasilPrivateKeyRedisKey = "yggdrasil:private_key"
)
// RedisClient 定义Redis客户端接口用于测试
type RedisClient interface {
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
}
// YggdrasilJWTService Yggdrasil JWT服务使用RSA512 // YggdrasilJWTService Yggdrasil JWT服务使用RSA512
type YggdrasilJWTService struct { type YggdrasilJWTService struct {
privateKey *rsa.PrivateKey privateKey *rsa.PrivateKey
@@ -122,98 +106,3 @@ func (j *YggdrasilJWTService) ParseAccessToken(accessToken string, stalePolicy S
func (j *YggdrasilJWTService) GetPublicKey() *rsa.PublicKey { func (j *YggdrasilJWTService) GetPublicKey() *rsa.PublicKey {
return j.publicKey return j.publicKey
} }
// YggdrasilJWTManager Yggdrasil JWT管理器用于获取或创建JWT服务
type YggdrasilJWTManager struct {
redisClient RedisClient
jwtService *YggdrasilJWTService
privateKey *rsa.PrivateKey
mu sync.RWMutex
}
// NewYggdrasilJWTManager 创建Yggdrasil JWT管理器
func NewYggdrasilJWTManager(redisClient RedisClient) *YggdrasilJWTManager {
return &YggdrasilJWTManager{
redisClient: redisClient,
}
}
// GetJWTService 获取或创建Yggdrasil JWT服务线程安全
func (m *YggdrasilJWTManager) GetJWTService() (*YggdrasilJWTService, error) {
m.mu.RLock()
if m.jwtService != nil {
service := m.jwtService
m.mu.RUnlock()
return service, nil
}
m.mu.RUnlock()
m.mu.Lock()
defer m.mu.Unlock()
// 双重检查
if m.jwtService != nil {
return m.jwtService, nil
}
// 从Redis获取私钥
privateKey, err := m.getPrivateKeyFromRedis()
if err != nil {
return nil, fmt.Errorf("获取私钥失败: %w", err)
}
m.privateKey = privateKey
m.jwtService = NewYggdrasilJWTService(privateKey, "carrotskin")
return m.jwtService, nil
}
// SetPrivateKey 直接设置私钥用于测试或直接从signatureService获取
func (m *YggdrasilJWTManager) SetPrivateKey(privateKey *rsa.PrivateKey) {
m.mu.Lock()
defer m.mu.Unlock()
m.privateKey = privateKey
if privateKey != nil {
m.jwtService = NewYggdrasilJWTService(privateKey, "carrotskin")
}
}
// getPrivateKeyFromRedis 从Redis获取私钥
func (m *YggdrasilJWTManager) getPrivateKeyFromRedis() (*rsa.PrivateKey, error) {
if m.privateKey != nil {
return m.privateKey, nil
}
ctx := context.Background()
privateKeyPEM, err := m.redisClient.Get(ctx, YggdrasilPrivateKeyRedisKey)
if err != nil || privateKeyPEM == "" {
return nil, fmt.Errorf("从Redis获取私钥失败: %w", err)
}
// 解析PEM格式的私钥
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return nil, fmt.Errorf("解析PEM私钥失败")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("解析RSA私钥失败: %w", err)
}
return privateKey, nil
}
// GenerateKeyPair 生成RSA密钥对用于测试
func GenerateKeyPair() (*rsa.PrivateKey, error) {
return rsa.GenerateKey(rand.Reader, 2048)
}
// EncodePrivateKeyToPEM 将私钥编码为PEM格式用于测试
func EncodePrivateKeyToPEM(privateKey *rsa.PrivateKey) (string, error) {
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privateKeyBytes,
})
return string(privateKeyPEM), nil
}

View File

@@ -1,65 +1,16 @@
package auth package auth
import ( import (
"context" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"errors"
"testing" "testing"
"time" "time"
"github.com/redis/go-redis/v9"
) )
// MockRedisClient 模拟Redis客户端 // generateTestKeyPair 生成测试用的 RSA 密钥对
type MockRedisClient struct {
data map[string]string
err error
}
func NewMockRedisClient() *MockRedisClient {
return &MockRedisClient{
data: make(map[string]string),
}
}
func (m *MockRedisClient) Get(ctx context.Context, key string) (string, error) {
if m.err != nil {
return "", m.err
}
if val, ok := m.data[key]; ok {
return val, nil
}
return "", redis.Nil
}
func (m *MockRedisClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
if m.err != nil {
return m.err
}
m.data[key] = value.(string)
return nil
}
func (m *MockRedisClient) SetError(err error) {
m.err = err
}
func (m *MockRedisClient) ClearError() {
m.err = nil
}
func (m *MockRedisClient) SetData(key, value string) {
m.data[key] = value
}
func (m *MockRedisClient) Clear() {
m.data = make(map[string]string)
m.err = nil
}
// 测试辅助函数:生成测试用的密钥对
func generateTestKeyPair(t *testing.T) *rsa.PrivateKey { func generateTestKeyPair(t *testing.T) *rsa.PrivateKey {
privateKey, err := GenerateKeyPair() t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil { if err != nil {
t.Fatalf("生成密钥对失败: %v", err) t.Fatalf("生成密钥对失败: %v", err)
} }
@@ -285,169 +236,6 @@ func TestYggdrasilJWTService_GetPublicKey(t *testing.T) {
} }
} }
func TestNewYggdrasilJWTManager(t *testing.T) {
mockRedis := NewMockRedisClient()
manager := NewYggdrasilJWTManager(mockRedis)
if manager == nil {
t.Fatal("管理器创建失败")
}
if manager.redisClient != mockRedis {
t.Error("Redis客户端未正确设置")
}
}
func TestYggdrasilJWTManager_SetPrivateKey(t *testing.T) {
mockRedis := NewMockRedisClient()
manager := NewYggdrasilJWTManager(mockRedis)
privateKey := generateTestKeyPair(t)
manager.SetPrivateKey(privateKey)
// 验证JWT服务已创建
service, err := manager.GetJWTService()
if err != nil {
t.Fatalf("获取JWT服务失败: %v", err)
}
if service == nil {
t.Fatal("JWT服务不应为nil")
}
// 验证服务可以正常工作
if service.GetPublicKey() == nil {
t.Error("公钥不应为nil")
}
}
func TestYggdrasilJWTManager_GetJWTService_FromPrivateKey(t *testing.T) {
mockRedis := NewMockRedisClient()
manager := NewYggdrasilJWTManager(mockRedis)
privateKey := generateTestKeyPair(t)
manager.SetPrivateKey(privateKey)
// 第一次获取
service1, err := manager.GetJWTService()
if err != nil {
t.Fatalf("获取JWT服务失败: %v", err)
}
// 第二次获取应该返回同一个实例
service2, err := manager.GetJWTService()
if err != nil {
t.Fatalf("获取JWT服务失败: %v", err)
}
if service1 != service2 {
t.Error("应该返回同一个JWT服务实例")
}
}
func TestYggdrasilJWTManager_GetJWTService_FromRedis(t *testing.T) {
mockRedis := NewMockRedisClient()
manager := NewYggdrasilJWTManager(mockRedis)
privateKey := generateTestKeyPair(t)
privateKeyPEM, err := EncodePrivateKeyToPEM(privateKey)
if err != nil {
t.Fatalf("编码私钥失败: %v", err)
}
// 设置Redis数据
mockRedis.SetData(YggdrasilPrivateKeyRedisKey, privateKeyPEM)
// 获取JWT服务
service, err := manager.GetJWTService()
if err != nil {
t.Fatalf("获取JWT服务失败: %v", err)
}
if service == nil {
t.Error("JWT服务不应为nil")
}
// 验证服务可以正常工作
token, err := service.GenerateAccessToken(123, "client-uuid", 1, "profile-uuid",
time.Now().Add(24*time.Hour), time.Now().Add(30*24*time.Hour))
if err != nil {
t.Fatalf("生成Token失败: %v", err)
}
if token == "" {
t.Error("Token不应为空")
}
}
func TestYggdrasilJWTManager_GetJWTService_RedisError(t *testing.T) {
mockRedis := NewMockRedisClient()
manager := NewYggdrasilJWTManager(mockRedis)
// 设置Redis错误
mockRedis.SetError(errors.New("redis connection error"))
// 尝试获取JWT服务应该失败
_, err := manager.GetJWTService()
if err == nil {
t.Error("期望出现错误,但没有错误")
}
}
func TestYggdrasilJWTManager_GetJWTService_InvalidPEM(t *testing.T) {
mockRedis := NewMockRedisClient()
manager := NewYggdrasilJWTManager(mockRedis)
// 设置无效的PEM数据
mockRedis.SetData(YggdrasilPrivateKeyRedisKey, "invalid-pem-data")
// 尝试获取JWT服务应该失败
_, err := manager.GetJWTService()
if err == nil {
t.Error("期望出现错误,但没有错误")
}
}
func TestYggdrasilJWTManager_GetJWTService_Concurrent(t *testing.T) {
mockRedis := NewMockRedisClient()
manager := NewYggdrasilJWTManager(mockRedis)
privateKey := generateTestKeyPair(t)
privateKeyPEM, err := EncodePrivateKeyToPEM(privateKey)
if err != nil {
t.Fatalf("编码私钥失败: %v", err)
}
mockRedis.SetData(YggdrasilPrivateKeyRedisKey, privateKeyPEM)
// 并发获取JWT服务
const numGoroutines = 10
results := make(chan *YggdrasilJWTService, numGoroutines)
errors := make(chan error, numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func() {
service, err := manager.GetJWTService()
if err != nil {
errors <- err
return
}
results <- service
}()
}
// 收集结果
services := make(map[*YggdrasilJWTService]bool)
for i := 0; i < numGoroutines; i++ {
select {
case service := <-results:
services[service] = true
case err := <-errors:
t.Fatalf("获取JWT服务失败: %v", err)
}
}
// 所有goroutine应该返回同一个服务实例
if len(services) != 1 {
t.Errorf("期望所有goroutine返回同一个服务实例但得到 %d 个不同的实例", len(services))
}
}
func TestYggdrasilTokenClaims_EmptyProfileID(t *testing.T) { func TestYggdrasilTokenClaims_EmptyProfileID(t *testing.T) {
privateKey := generateTestKeyPair(t) privateKey := generateTestKeyPair(t)
service := NewYggdrasilJWTService(privateKey, "test-issuer") service := NewYggdrasilJWTService(privateKey, "test-issuer")