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,与前端约定对齐。
762 lines
25 KiB
Go
762 lines
25 KiB
Go
package handler
|
||
|
||
import (
|
||
"net/http"
|
||
"regexp"
|
||
|
||
"carrotskin/internal/container"
|
||
"carrotskin/internal/errors"
|
||
"carrotskin/internal/model"
|
||
"carrotskin/pkg/utils"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// 常量定义
|
||
const (
|
||
// 内存限制
|
||
MaxMultipartMemory = 32 << 20 // 32 MB
|
||
|
||
// 材质类型
|
||
TextureTypeSkin = "SKIN"
|
||
TextureTypeCape = "CAPE"
|
||
)
|
||
|
||
// 正则表达式
|
||
var (
|
||
// 邮箱正则表达式
|
||
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||
)
|
||
|
||
// 请求结构体
|
||
type (
|
||
// AuthenticateRequest 认证请求
|
||
AuthenticateRequest struct {
|
||
Agent map[string]interface{} `json:"agent"`
|
||
ClientToken string `json:"clientToken"`
|
||
Identifier string `json:"username" binding:"required"`
|
||
Password string `json:"password" binding:"required"`
|
||
RequestUser bool `json:"requestUser"`
|
||
}
|
||
|
||
// ValidTokenRequest 验证令牌请求
|
||
ValidTokenRequest struct {
|
||
AccessToken string `json:"accessToken" binding:"required"`
|
||
ClientToken string `json:"clientToken"`
|
||
}
|
||
|
||
// RefreshRequest 刷新令牌请求
|
||
RefreshRequest struct {
|
||
AccessToken string `json:"accessToken" binding:"required"`
|
||
ClientToken string `json:"clientToken"`
|
||
RequestUser bool `json:"requestUser"`
|
||
SelectedProfile map[string]interface{} `json:"selectedProfile"`
|
||
}
|
||
|
||
// SignOutRequest 登出请求
|
||
SignOutRequest struct {
|
||
Email string `json:"username" binding:"required"`
|
||
Password string `json:"password" binding:"required"`
|
||
}
|
||
|
||
// JoinServerRequest 加入服务器请求
|
||
JoinServerRequest struct {
|
||
ServerID string `json:"serverId" binding:"required"`
|
||
AccessToken string `json:"accessToken" binding:"required"`
|
||
SelectedProfile string `json:"selectedProfile" binding:"required"`
|
||
}
|
||
)
|
||
|
||
// 响应结构体
|
||
type (
|
||
// AuthenticateResponse 认证响应
|
||
AuthenticateResponse struct {
|
||
AccessToken string `json:"accessToken"`
|
||
ClientToken string `json:"clientToken"`
|
||
SelectedProfile map[string]interface{} `json:"selectedProfile,omitempty"`
|
||
AvailableProfiles []map[string]interface{} `json:"availableProfiles"`
|
||
User map[string]interface{} `json:"user,omitempty"`
|
||
}
|
||
|
||
// RefreshResponse 刷新令牌响应
|
||
RefreshResponse struct {
|
||
AccessToken string `json:"accessToken"`
|
||
ClientToken string `json:"clientToken"`
|
||
SelectedProfile map[string]interface{} `json:"selectedProfile,omitempty"`
|
||
AvailableProfiles []map[string]interface{} `json:"availableProfiles"`
|
||
User map[string]interface{} `json:"user,omitempty"`
|
||
}
|
||
)
|
||
|
||
// YggdrasilHandler Yggdrasil API处理器
|
||
type YggdrasilHandler struct {
|
||
container *container.Container
|
||
logger *zap.Logger
|
||
}
|
||
|
||
// NewYggdrasilHandler 创建YggdrasilHandler实例
|
||
func NewYggdrasilHandler(c *container.Container) *YggdrasilHandler {
|
||
return &YggdrasilHandler{
|
||
container: c,
|
||
logger: c.Logger,
|
||
}
|
||
}
|
||
|
||
// Authenticate 用户认证
|
||
// @Summary Yggdrasil认证
|
||
// @Description Yggdrasil协议: 用户登录认证
|
||
// @Tags Yggdrasil
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body AuthenticateRequest true "认证请求"
|
||
// @Success 200 {object} AuthenticateResponse
|
||
// @Failure 403 {object} map[string]string "认证失败"
|
||
// @Router /api/yggdrasil/authserver/authenticate [post]
|
||
func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
|
||
var request AuthenticateRequest
|
||
if err := c.ShouldBindJSON(&request); err != nil {
|
||
h.logger.Error("解析认证请求失败", zap.Error(err))
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
var userId int64
|
||
var profile *model.Profile
|
||
var UUID string
|
||
var err error
|
||
|
||
if emailRegex.MatchString(request.Identifier) {
|
||
// 邮箱登录:UUID 留空,后续 TokenService.Create 会自动选择该用户的角色
|
||
userId, err = h.container.YggdrasilService.GetUserIDByEmail(c.Request.Context(), request.Identifier)
|
||
} else {
|
||
profile, err = h.container.ProfileService.GetByProfileName(c.Request.Context(), request.Identifier)
|
||
if err != nil {
|
||
h.logger.Error("用户名不存在", zap.String("identifier", request.Identifier), zap.Error(err))
|
||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
||
"ForbiddenOperationException",
|
||
errors.YggErrInvalidCredentials,
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
userId = profile.UserID
|
||
UUID = profile.UUID
|
||
}
|
||
|
||
if err != nil {
|
||
h.logger.Warn("认证失败: 用户不存在", zap.String("identifier", request.Identifier), zap.Error(err))
|
||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
||
"ForbiddenOperationException",
|
||
errors.YggErrInvalidCredentials,
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
if err := h.container.YggdrasilService.VerifyPassword(c.Request.Context(), request.Password, userId); err != nil {
|
||
h.logger.Warn("认证失败: 密码错误", zap.Error(err))
|
||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
||
"ForbiddenOperationException",
|
||
errors.YggErrInvalidCredentials,
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
selectedProfile, availableProfiles, accessToken, clientToken, err := h.container.TokenService.Create(c.Request.Context(), userId, UUID, request.ClientToken)
|
||
if err != nil {
|
||
h.logger.Error("生成令牌失败", zap.Error(err), zap.Int64("userId", userId))
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
user, err := h.container.UserService.GetByID(c.Request.Context(), userId)
|
||
if err != nil {
|
||
h.logger.Error("获取用户信息失败", zap.Error(err), zap.Int64("userId", userId))
|
||
}
|
||
|
||
availableProfilesData := make([]map[string]interface{}, 0, len(availableProfiles))
|
||
for _, p := range availableProfiles {
|
||
availableProfilesData = append(availableProfilesData, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *p))
|
||
}
|
||
|
||
response := AuthenticateResponse{
|
||
AccessToken: accessToken,
|
||
ClientToken: clientToken,
|
||
AvailableProfiles: availableProfilesData,
|
||
}
|
||
|
||
if selectedProfile != nil {
|
||
response.SelectedProfile = h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *selectedProfile)
|
||
}
|
||
|
||
if request.RequestUser && user != nil {
|
||
response.User = h.container.YggdrasilService.SerializeUser(c.Request.Context(), user, UUID)
|
||
}
|
||
|
||
h.logger.Info("用户认证成功", zap.Int64("userId", userId))
|
||
c.JSON(http.StatusOK, response)
|
||
}
|
||
|
||
// ValidToken 验证令牌
|
||
// @Summary Yggdrasil验证令牌
|
||
// @Description Yggdrasil协议: 验证AccessToken是否有效
|
||
// @Tags Yggdrasil
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body ValidTokenRequest true "验证请求"
|
||
// @Success 204 "令牌有效"
|
||
// @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效"
|
||
// @Router /api/yggdrasil/authserver/validate [post]
|
||
func (h *YggdrasilHandler) ValidToken(c *gin.Context) {
|
||
var request ValidTokenRequest
|
||
if err := c.ShouldBindJSON(&request); err != nil {
|
||
h.logger.Error("解析验证令牌请求失败", zap.Error(err))
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
if h.container.TokenService.Validate(c.Request.Context(), request.AccessToken, request.ClientToken) {
|
||
h.logger.Info("令牌验证成功", zap.String("accessToken", request.AccessToken))
|
||
c.JSON(http.StatusNoContent, gin.H{})
|
||
} else {
|
||
h.logger.Warn("令牌验证失败", zap.String("accessToken", request.AccessToken))
|
||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
||
"ForbiddenOperationException",
|
||
errors.YggErrInvalidToken,
|
||
"",
|
||
))
|
||
}
|
||
}
|
||
|
||
// RefreshToken 刷新令牌
|
||
// @Summary Yggdrasil刷新令牌
|
||
// @Description Yggdrasil协议: 刷新AccessToken
|
||
// @Tags Yggdrasil
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body RefreshRequest true "刷新请求"
|
||
// @Success 200 {object} RefreshResponse
|
||
// @Failure 400 {object} map[string]string "刷新失败"
|
||
// @Router /api/yggdrasil/authserver/refresh [post]
|
||
func (h *YggdrasilHandler) RefreshToken(c *gin.Context) {
|
||
var request RefreshRequest
|
||
if err := c.ShouldBindJSON(&request); err != nil {
|
||
h.logger.Error("解析刷新令牌请求失败", zap.Error(err))
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
// 获取用户ID和当前绑定的Profile UUID
|
||
userID, err := h.container.TokenService.GetUserIDByAccessToken(c.Request.Context(), request.AccessToken)
|
||
if err != nil {
|
||
h.logger.Warn("刷新令牌失败: 无效的访问令牌", zap.String("token", request.AccessToken), zap.Error(err))
|
||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
||
"ForbiddenOperationException",
|
||
errors.YggErrInvalidToken,
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
currentUUID, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), request.AccessToken)
|
||
if err != nil {
|
||
h.logger.Warn("刷新令牌失败: 无法获取当前Profile", zap.String("token", request.AccessToken), zap.Error(err))
|
||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
||
"ForbiddenOperationException",
|
||
errors.YggErrInvalidToken,
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
// 获取用户的所有可用profiles
|
||
availableProfiles, err := h.container.ProfileService.GetByUserID(c.Request.Context(), userID)
|
||
if err != nil {
|
||
h.logger.Error("刷新令牌失败: 无法获取用户profiles", zap.Error(err), zap.Int64("userId", userID))
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
var profileData map[string]interface{}
|
||
var userData map[string]interface{}
|
||
var profileID string
|
||
|
||
// 处理selectedProfile
|
||
if request.SelectedProfile != nil {
|
||
profileIDValue, ok := request.SelectedProfile["id"]
|
||
if !ok {
|
||
h.logger.Error("刷新令牌失败: 缺少配置文件ID", zap.Int64("userId", userID))
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少配置文件ID"})
|
||
return
|
||
}
|
||
|
||
profileID, ok = profileIDValue.(string)
|
||
if !ok {
|
||
h.logger.Error("刷新令牌失败: 配置文件ID类型错误", zap.Int64("userId", userID))
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "配置文件ID必须是字符串"})
|
||
return
|
||
}
|
||
|
||
// 确保profileID是32位无符号格式(用于向后兼容)
|
||
profileID = utils.FormatUUIDToNoDash(profileID)
|
||
|
||
// 根据Yggdrasil规范:当指定selectedProfile时,原令牌所绑定的角色必须为空
|
||
// 如果原令牌已绑定角色,则不允许更换角色,但允许选择相同的角色以兼容部分启动器
|
||
if currentUUID != "" && currentUUID != profileID {
|
||
h.logger.Warn("刷新令牌失败: 令牌已绑定其他角色,不允许更换",
|
||
zap.Int64("userId", userID),
|
||
zap.String("currentUUID", currentUUID),
|
||
zap.String("requestedUUID", profileID),
|
||
)
|
||
c.JSON(http.StatusBadRequest, errors.NewYggdrasilErrorResponse(
|
||
"IllegalArgumentException",
|
||
errors.YggErrProfileAlreadyAssigned,
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
// 验证profile是否属于该用户
|
||
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), profileID)
|
||
if err != nil {
|
||
h.logger.Warn("刷新令牌失败: Profile不存在", zap.String("profileId", profileID), zap.Error(err))
|
||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
||
"ForbiddenOperationException",
|
||
errors.YggErrInvalidToken,
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
if profile.UserID != userID {
|
||
h.logger.Warn("刷新令牌失败: 用户不匹配",
|
||
zap.Int64("userId", userID),
|
||
zap.Int64("profileUserId", profile.UserID),
|
||
)
|
||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
||
"ForbiddenOperationException",
|
||
errors.YggErrInvalidToken,
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
profileData = h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile)
|
||
} else {
|
||
// 如果未指定selectedProfile,使用当前token绑定的profile(如果存在)
|
||
if currentUUID != "" {
|
||
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), currentUUID)
|
||
if err == nil && profile != nil {
|
||
profileID = currentUUID
|
||
profileData = h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取用户信息(如果请求)
|
||
user, _ := h.container.UserService.GetByID(c.Request.Context(), userID)
|
||
if request.RequestUser && user != nil {
|
||
userData = h.container.YggdrasilService.SerializeUser(c.Request.Context(), user, currentUUID)
|
||
}
|
||
|
||
// 刷新token
|
||
newAccessToken, newClientToken, err := h.container.TokenService.Refresh(c.Request.Context(),
|
||
request.AccessToken,
|
||
request.ClientToken,
|
||
profileID,
|
||
)
|
||
if err != nil {
|
||
h.logger.Error("刷新令牌失败", zap.Error(err), zap.Int64("userId", userID))
|
||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
||
"ForbiddenOperationException",
|
||
errors.YggErrInvalidToken,
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
// 序列化可用profiles
|
||
availableProfilesData := make([]map[string]interface{}, 0, len(availableProfiles))
|
||
for _, p := range availableProfiles {
|
||
availableProfilesData = append(availableProfilesData, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *p))
|
||
}
|
||
|
||
h.logger.Info("刷新令牌成功", zap.Int64("userId", userID))
|
||
c.JSON(http.StatusOK, RefreshResponse{
|
||
AccessToken: newAccessToken,
|
||
ClientToken: newClientToken,
|
||
SelectedProfile: profileData,
|
||
AvailableProfiles: availableProfilesData,
|
||
User: userData,
|
||
})
|
||
}
|
||
|
||
// InvalidToken 使令牌失效
|
||
// @Summary Yggdrasil注销令牌
|
||
// @Description Yggdrasil协议: 使AccessToken失效
|
||
// @Tags Yggdrasil
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body ValidTokenRequest true "失效请求"
|
||
// @Success 204 "操作成功"
|
||
// @Router /api/yggdrasil/authserver/invalidate [post]
|
||
func (h *YggdrasilHandler) InvalidToken(c *gin.Context) {
|
||
var request ValidTokenRequest
|
||
if err := c.ShouldBindJSON(&request); err != nil {
|
||
h.logger.Error("解析使令牌失效请求失败", zap.Error(err))
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
h.container.TokenService.Invalidate(c.Request.Context(), request.AccessToken)
|
||
h.logger.Info("令牌已失效", zap.String("token", request.AccessToken))
|
||
c.JSON(http.StatusNoContent, gin.H{})
|
||
}
|
||
|
||
// SignOut 用户登出
|
||
// @Summary Yggdrasil登出
|
||
// @Description Yggdrasil协议: 用户登出,使所有令牌失效
|
||
// @Tags Yggdrasil
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body SignOutRequest true "登出请求"
|
||
// @Success 204 "操作成功"
|
||
// @Failure 400 {object} map[string]string "参数错误"
|
||
// @Router /api/yggdrasil/authserver/signout [post]
|
||
func (h *YggdrasilHandler) SignOut(c *gin.Context) {
|
||
var request SignOutRequest
|
||
if err := c.ShouldBindJSON(&request); err != nil {
|
||
h.logger.Error("解析登出请求失败", zap.Error(err))
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
if !emailRegex.MatchString(request.Email) {
|
||
h.logger.Warn("登出失败: 邮箱格式不正确", zap.String("email", request.Email))
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
|
||
return
|
||
}
|
||
|
||
user, err := h.container.UserService.GetByEmail(c.Request.Context(), request.Email)
|
||
if err != nil || user == nil {
|
||
h.logger.Warn("登出失败: 用户不存在", zap.String("email", request.Email), zap.Error(err))
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户不存在"})
|
||
return
|
||
}
|
||
|
||
if err := h.container.YggdrasilService.VerifyPassword(c.Request.Context(), request.Password, user.ID); err != nil {
|
||
h.logger.Warn("登出失败: 密码错误", zap.Int64("userId", user.ID))
|
||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
||
"ForbiddenOperationException",
|
||
errors.YggErrInvalidCredentials,
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
h.container.TokenService.InvalidateUserTokens(c.Request.Context(), user.ID)
|
||
h.logger.Info("用户登出成功", zap.Int64("userId", user.ID))
|
||
c.JSON(http.StatusNoContent, gin.H{"valid": true})
|
||
}
|
||
|
||
// GetProfileByUUID 根据UUID获取档案
|
||
// @Summary Yggdrasil获取档案
|
||
// @Description Yggdrasil协议: 根据UUID获取用户档案信息
|
||
// @Tags Yggdrasil
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param uuid path string true "用户UUID"
|
||
// @Param unsigned query string false "是否不返回签名(true/false,默认false)"
|
||
// @Success 200 {object} map[string]interface{} "档案信息"
|
||
// @Failure 404 {object} errors.YggdrasilErrorResponse "档案不存在"
|
||
// @Router /api/yggdrasil/sessionserver/session/minecraft/profile/{uuid} [get]
|
||
func (h *YggdrasilHandler) GetProfileByUUID(c *gin.Context) {
|
||
uuid := c.Param("uuid")
|
||
h.logger.Info("获取配置文件请求", zap.String("uuid", uuid))
|
||
|
||
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), uuid)
|
||
if err != nil {
|
||
h.logger.Error("获取配置文件失败", zap.Error(err), zap.String("uuid", uuid))
|
||
c.JSON(http.StatusNotFound, errors.NewYggdrasilErrorResponse(
|
||
"Not Found",
|
||
"Profile not found",
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
// 读取 unsigned 查询参数
|
||
unsignedParam := c.DefaultQuery("unsigned", "false")
|
||
unsigned := unsignedParam == "true"
|
||
|
||
h.logger.Info("成功获取配置文件",
|
||
zap.String("uuid", uuid),
|
||
zap.String("name", profile.Name),
|
||
zap.Bool("unsigned", unsigned))
|
||
|
||
c.JSON(http.StatusOK, h.container.YggdrasilService.SerializeProfileWithUnsigned(c.Request.Context(), *profile, unsigned))
|
||
}
|
||
|
||
// JoinServer 加入服务器
|
||
// @Summary Yggdrasil加入服务器
|
||
// @Description Yggdrasil协议: 客户端加入服务器
|
||
// @Tags Yggdrasil
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body JoinServerRequest true "加入请求"
|
||
// @Success 204 "加入成功"
|
||
// @Failure 400 {object} map[string]string "参数错误"
|
||
// @Failure 500 {object} map[string]string "服务器错误"
|
||
// @Router /api/yggdrasil/sessionserver/session/minecraft/join [post]
|
||
func (h *YggdrasilHandler) JoinServer(c *gin.Context) {
|
||
var request JoinServerRequest
|
||
clientIP := c.ClientIP()
|
||
|
||
if err := c.ShouldBindJSON(&request); err != nil {
|
||
h.logger.Error("解析加入服务器请求失败", zap.Error(err), zap.String("ip", clientIP))
|
||
c.JSON(http.StatusBadRequest, errors.NewYggdrasilErrorResponse(
|
||
"Bad Request",
|
||
"Invalid request format",
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
h.logger.Info("收到加入服务器请求",
|
||
zap.String("serverId", request.ServerID),
|
||
zap.String("userUUID", request.SelectedProfile),
|
||
zap.String("ip", clientIP),
|
||
)
|
||
|
||
if err := h.container.YggdrasilService.JoinServer(c.Request.Context(), request.ServerID, request.AccessToken, request.SelectedProfile, clientIP); err != nil {
|
||
h.logger.Error("加入服务器失败",
|
||
zap.Error(err),
|
||
zap.String("serverId", request.ServerID),
|
||
zap.String("userUUID", request.SelectedProfile),
|
||
zap.String("ip", clientIP),
|
||
)
|
||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
||
"ForbiddenOperationException",
|
||
errors.YggErrInvalidToken,
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
h.logger.Info("加入服务器成功",
|
||
zap.String("serverId", request.ServerID),
|
||
zap.String("userUUID", request.SelectedProfile),
|
||
zap.String("ip", clientIP),
|
||
)
|
||
c.Status(http.StatusNoContent)
|
||
}
|
||
|
||
// HasJoinedServer 验证玩家是否已加入服务器
|
||
// @Summary Yggdrasil验证加入
|
||
// @Description Yggdrasil协议: 服务端验证客户端是否已加入
|
||
// @Tags Yggdrasil
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param username query string true "用户名"
|
||
// @Param serverId query string true "服务器ID"
|
||
// @Param ip query string false "客户端IP"
|
||
// @Success 200 {object} map[string]interface{} "验证成功,返回档案"
|
||
// @Failure 204 "验证失败"
|
||
// @Router /api/yggdrasil/sessionserver/session/minecraft/hasJoined [get]
|
||
func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
|
||
clientIP, _ := c.GetQuery("ip")
|
||
|
||
serverID, exists := c.GetQuery("serverId")
|
||
if !exists || serverID == "" {
|
||
h.logger.Warn("缺少服务器ID参数", zap.String("ip", clientIP))
|
||
c.Status(http.StatusNoContent)
|
||
return
|
||
}
|
||
|
||
username, exists := c.GetQuery("username")
|
||
if !exists || username == "" {
|
||
h.logger.Warn("缺少用户名参数", zap.String("serverId", serverID), zap.String("ip", clientIP))
|
||
c.Status(http.StatusNoContent)
|
||
return
|
||
}
|
||
|
||
h.logger.Info("收到会话验证请求",
|
||
zap.String("serverId", serverID),
|
||
zap.String("username", username),
|
||
zap.String("ip", clientIP),
|
||
)
|
||
|
||
if err := h.container.YggdrasilService.HasJoinedServer(c.Request.Context(), serverID, username, clientIP); err != nil {
|
||
h.logger.Warn("会话验证失败",
|
||
zap.Error(err),
|
||
zap.String("serverId", serverID),
|
||
zap.String("username", username),
|
||
zap.String("ip", clientIP),
|
||
)
|
||
c.Status(http.StatusNoContent)
|
||
return
|
||
}
|
||
|
||
profile, err := h.container.ProfileService.GetByProfileName(c.Request.Context(), username)
|
||
if err != nil {
|
||
h.logger.Error("获取用户配置文件失败", zap.Error(err), zap.String("username", username))
|
||
c.Status(http.StatusNoContent)
|
||
return
|
||
}
|
||
|
||
h.logger.Info("会话验证成功",
|
||
zap.String("serverId", serverID),
|
||
zap.String("username", username),
|
||
zap.String("uuid", profile.UUID),
|
||
)
|
||
c.JSON(http.StatusOK, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile))
|
||
}
|
||
|
||
// GetProfilesByName 批量获取配置文件
|
||
// @Summary Yggdrasil批量获取档案
|
||
// @Description Yggdrasil协议: 根据名称批量获取用户档案
|
||
// @Tags Yggdrasil
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param request body []string true "用户名列表"
|
||
// @Success 200 {array} model.Profile "档案列表"
|
||
// @Failure 400 {object} map[string]string "参数错误"
|
||
// @Router /api/yggdrasil/api/profiles/minecraft [post]
|
||
func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) {
|
||
var names []string
|
||
|
||
if err := c.ShouldBindJSON(&names); err != nil {
|
||
h.logger.Error("解析名称数组请求失败", zap.Error(err))
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
|
||
return
|
||
}
|
||
|
||
h.logger.Info("接收到批量获取配置文件请求", zap.Int("count", len(names)))
|
||
|
||
profiles, err := h.container.ProfileService.GetByNames(c.Request.Context(), names)
|
||
if err != nil {
|
||
h.logger.Error("获取配置文件失败", zap.Error(err))
|
||
}
|
||
|
||
h.logger.Info("成功获取配置文件", zap.Int("requested", len(names)), zap.Int("returned", len(profiles)))
|
||
c.JSON(http.StatusOK, profiles)
|
||
}
|
||
|
||
// GetMetaData 获取Yggdrasil元数据
|
||
// @Summary Yggdrasil元数据
|
||
// @Description Yggdrasil协议: 获取服务器元数据
|
||
// @Tags Yggdrasil
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Success 200 {object} map[string]interface{} "元数据"
|
||
// @Failure 500 {object} map[string]string "服务器错误"
|
||
// @Router /api/yggdrasil [get]
|
||
func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
|
||
meta := gin.H{
|
||
"implementationName": "CellAuth",
|
||
"implementationVersion": "0.0.1",
|
||
"serverName": "LittleLan's Yggdrasil Server Implementation.",
|
||
"links": gin.H{
|
||
"homepage": "https://skin.littlelan.cn",
|
||
"register": "https://skin.littlelan.cn/auth",
|
||
},
|
||
"feature.non_email_login": true,
|
||
"feature.enable_profile_key": true,
|
||
}
|
||
|
||
skinDomains := []string{".hitwh.games", ".littlelan.cn"}
|
||
signature, err := h.container.YggdrasilService.GetPublicKey(c.Request.Context())
|
||
if err != nil {
|
||
h.logger.Error("获取公钥失败", zap.Error(err))
|
||
c.JSON(http.StatusInternalServerError, errors.NewYggdrasilErrorResponse(
|
||
"Internal Server Error",
|
||
"Failed to get public key",
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
h.logger.Info("提供元数据")
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"meta": meta,
|
||
"skinDomains": skinDomains,
|
||
"signaturePublickey": signature,
|
||
})
|
||
}
|
||
|
||
// GetPlayerCertificates 获取玩家证书
|
||
// @Summary Yggdrasil获取证书
|
||
// @Description Yggdrasil协议: 获取玩家证书
|
||
// @Tags Yggdrasil
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param Authorization header string true "Bearer {token}"
|
||
// @Success 200 {object} service.PlayerCertificate "证书信息"
|
||
// @Failure 401 {object} errors.YggdrasilErrorResponse "未授权"
|
||
// @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效"
|
||
// @Failure 500 {object} errors.YggdrasilErrorResponse "服务器错误"
|
||
// @Router /api/yggdrasil/minecraftservices/player/certificates [post]
|
||
func (h *YggdrasilHandler) GetPlayerCertificates(c *gin.Context) {
|
||
authHeader := c.GetHeader("Authorization")
|
||
if authHeader == "" {
|
||
c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
|
||
"Unauthorized",
|
||
"Authorization header not provided",
|
||
"",
|
||
))
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
bearerPrefix := "Bearer "
|
||
if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix {
|
||
c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
|
||
"Unauthorized",
|
||
"Invalid Authorization format",
|
||
"",
|
||
))
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
tokenID := authHeader[len(bearerPrefix):]
|
||
if tokenID == "" {
|
||
c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
|
||
"Unauthorized",
|
||
"Invalid Authorization format",
|
||
"",
|
||
))
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), tokenID)
|
||
if err != nil || uuid == "" {
|
||
h.logger.Error("获取玩家UUID失败", zap.Error(err))
|
||
c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
|
||
"Unauthorized",
|
||
errors.YggErrInvalidToken,
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
// UUID已经是32位无符号格式,无需转换
|
||
|
||
certificate, err := h.container.YggdrasilService.GeneratePlayerCertificate(c.Request.Context(), uuid)
|
||
if err != nil {
|
||
h.logger.Error("生成玩家证书失败", zap.Error(err))
|
||
c.JSON(http.StatusInternalServerError, errors.NewYggdrasilErrorResponse(
|
||
"Internal Server Error",
|
||
"Failed to generate player certificate",
|
||
"",
|
||
))
|
||
return
|
||
}
|
||
|
||
h.logger.Info("成功生成玩家证书")
|
||
c.JSON(http.StatusOK, certificate)
|
||
}
|