refactor(yggdrasil): 整理与性能优化,修复若干 Bug
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:
@@ -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("请求参数错误")
|
||||||
|
|||||||
@@ -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": "验证失败,请重试",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
"",
|
"",
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signWithRootKey 使用根密钥对消息做 SHA1withRSA 签名
|
||||||
|
func signWithRootKey(privateKey *rsa.PrivateKey, message []byte) ([]byte, error) {
|
||||||
|
hashed := sha1.Sum(message)
|
||||||
|
return rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
|
||||||
|
}
|
||||||
|
|
||||||
// GetOrCreateYggdrasilKeyPair 获取或创建Yggdrasil根密钥对
|
// GetOrCreateYggdrasilKeyPair 获取或创建Yggdrasil根密钥对
|
||||||
|
// 优先使用进程内缓存的已解析私钥;缓存未命中时从Redis加载并解析,再缓存。
|
||||||
func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (string, *rsa.PrivateKey, error) {
|
func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (string, *rsa.PrivateKey, error) {
|
||||||
// 尝试从Redis获取密钥
|
// 1. 读缓存(快路径)
|
||||||
publicKeyPEM, err := s.redis.Get(ctx, PublicKeyRedisKey)
|
s.rootKeyMu.RLock()
|
||||||
if err == nil && publicKeyPEM != "" {
|
if s.rootPrivateKey != nil && s.isRootKeyValid() {
|
||||||
privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey)
|
pub := s.rootPublicKeyPEM
|
||||||
if err == nil && privateKeyPEM != "" {
|
priv := s.rootPrivateKey
|
||||||
// 检查密钥是否过期
|
s.rootKeyMu.RUnlock()
|
||||||
expStr, err := s.redis.Get(ctx, KeyExpirationRedisKey)
|
return pub, priv, nil
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析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
|
return base64.StdEncoding.EncodeToString(signature), nil
|
||||||
}
|
}
|
||||||
|
|||||||
403
internal/service/signature_service_test.go
Normal file
403
internal/service/signature_service_test.go
Normal 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("缓存命中后仍写入 Redis:keys 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("成功生成玩家证书",
|
||||||
|
|||||||
@@ -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格式
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user