diff --git a/main.go b/main.go index 4f688db..a267fec 100644 --- a/main.go +++ b/main.go @@ -104,6 +104,7 @@ func main() { serverMeta.Meta.ImplementationName = meta.ImplementationName serverMeta.Meta.ImplementationVersion = meta.ImplementationVersion serverMeta.Meta.FeatureNoMojangNamespace = true + serverMeta.Meta.FeatureEnableProfileKey = true serverMeta.Meta.Links.Homepage = meta.SkinRootUrl + "/profile/user.html" serverMeta.Meta.Links.Register = meta.SkinRootUrl + "/profile/index.html" serverMeta.SkinDomains = meta.SkinDomains @@ -160,15 +161,18 @@ func checkRsaKeyFile(privateKeyPath string, publicKeyPath string) { log.Fatalln("无法序列化 RSA 密钥", err) } err = pem.Encode(privatePem, &pem.Block{ - Type: "PRIVATE", + Type: "PRIVATE KEY", Bytes: privateKeyBytes, }) if err != nil { log.Fatalln("无法写入私钥文件", err) } - publicKeyBytes := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey) + publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + log.Fatalln("无法序列化 RSA 公钥", err) + } err = pem.Encode(publicPem, &pem.Block{ - Type: "PUBLIC", + Type: "PUBLIC KEY", Bytes: publicKeyBytes, }) if err != nil { diff --git a/router/home_router.go b/router/home_router.go index ba84cf5..642833e 100644 --- a/router/home_router.go +++ b/router/home_router.go @@ -33,6 +33,7 @@ type MetaInfo struct { FeatureNonEmailLogin bool `json:"feature.non_email_login,omitempty"` FeatureLegacySkinApi bool `json:"feature.legacy_skin_api,omitempty"` FeatureNoMojangNamespace bool `json:"feature.no_mojang_namespace,omitempty"` + FeatureEnableProfileKey bool `json:"feature.enable_profile_key,omitempty"` } type ServerMeta struct { diff --git a/router/init.go b/router/init.go index 6e121b1..b6407a9 100644 --- a/router/init.go +++ b/router/init.go @@ -76,4 +76,5 @@ func InitRouters(router *gin.Engine, db *gorm.DB, meta *ServerMeta, skinRootUrl api.DELETE("/user/profile/:uuid/:textureType", textureRouter.DeleteTexture) api.GET("/users/profiles/minecraft/:username", userRouter.UsernameToUUID) } + router.POST("/minecraftservices/player/certificates", userRouter.ProfileKey) } diff --git a/router/user_router.go b/router/user_router.go index 2b14374..923d420 100644 --- a/router/user_router.go +++ b/router/user_router.go @@ -37,6 +37,7 @@ type UserRouter interface { UsernameToUUID(c *gin.Context) QueryUUIDs(c *gin.Context) QueryProfile(c *gin.Context) + ProfileKey(c *gin.Context) } type userRouterImpl struct { @@ -263,3 +264,18 @@ func (u *userRouterImpl) QueryProfile(c *gin.Context) { } c.JSON(http.StatusOK, response) } + +func (u *userRouterImpl) ProfileKey(c *gin.Context) { + bearerToken := c.GetHeader("Authorization") + if len(bearerToken) < 8 { + c.AbortWithStatusJSON(http.StatusUnauthorized, util.NewForbiddenOperationError(util.MessageInvalidToken)) + return + } + accessToken := bearerToken[7:] + response, err := u.userService.ProfileKey(accessToken) + if err != nil { + util.HandleError(c, err) + return + } + c.JSON(http.StatusOK, response) +} diff --git a/service/session_service.go b/service/session_service.go index 202ee70..d81575a 100644 --- a/service/session_service.go +++ b/service/session_service.go @@ -47,7 +47,11 @@ func NewSessionService(service TokenService) SessionService { func (s *sessionStore) JoinServer(accessToken string, serverId string, selectedProfile string, ip string) error { token, ok := s.tokenService.GetToken(accessToken) - if ok && util.UnsignedString(token.SelectedProfile.Id) == selectedProfile { + if ok { + if token.GetAvailableLevel() != model.Valid || + util.UnsignedString(token.SelectedProfile.Id) != selectedProfile { + return util.NewForbiddenOperationError(util.MessageInvalidToken) + } session := model.NewAuthenticationSession(serverId, token, ip) s.sessionCache.Add(serverId, &session) } else { diff --git a/service/user_service.go b/service/user_service.go index e328ddd..0b17a09 100644 --- a/service/user_service.go +++ b/service/user_service.go @@ -18,6 +18,11 @@ package service import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" "fmt" "github.com/google/uuid" lru "github.com/hashicorp/golang-lru" @@ -28,6 +33,7 @@ import ( "net/url" "regexp" "strings" + "time" "yggdrasil-go/model" "yggdrasil-go/util" ) @@ -43,6 +49,7 @@ type UserService interface { UsernameToUUID(username string) (*model.ProfileResponse, error) QueryUUIDs(usernames []string) ([]model.ProfileResponse, error) QueryProfile(profileId uuid.UUID, unsigned bool, textureBaseUrl string) (map[string]interface{}, error) + ProfileKey(accessToken string) (*ProfileKeyResponse, error) } type LoginResponse struct { @@ -53,23 +60,43 @@ type LoginResponse struct { SelectedProfile *model.ProfileResponse `json:"selectedProfile"` } -type userSrviceImpl struct { - tokenService TokenService - db *gorm.DB - limitLruCache *lru.Cache +type ProfileKeyResponse struct { + ExpiresAt time.Time `json:"expiresAt,omitempty"` + KeyPair *ProfileKeyPair `json:"keyPair,omitempty"` + PublicKeySignature string `json:"publicKeySignature,omitempty"` + PublicKeySignatureV2 string `json:"publicKeySignatureV2,omitempty"` + RefreshedAfter time.Time `json:"refreshedAfter,omitempty"` +} + +type ProfileKeyPair struct { + PrivateKey string `json:"privateKey,omitempty"` + PublicKey string `json:"publicKey,omitempty"` +} + +type userServiceImpl struct { + tokenService TokenService + db *gorm.DB + limitLruCache *lru.Cache + profileKeyCache *lru.Cache + keyPairCh chan ProfileKeyPair } func NewUserService(tokenService TokenService, db *gorm.DB) UserService { - cache, _ := lru.New(10000) - userSrvice := userSrviceImpl{ - tokenService: tokenService, - db: db, - limitLruCache: cache, + cache0, _ := lru.New(10000) + cache1, _ := lru.New(10000) + ch := make(chan ProfileKeyPair, 100) + userService := userServiceImpl{ + tokenService: tokenService, + db: db, + limitLruCache: cache0, + profileKeyCache: cache1, + keyPairCh: ch, } - return &userSrvice + go userService.genKeyPair() + return &userService } -func (u *userSrviceImpl) Register(username string, password string, profileName string) (*model.UserResponse, error) { +func (u *userServiceImpl) Register(username string, password string, profileName string) (*model.UserResponse, error) { var count int64 if err := u.db.Table("users").Where("email = ?", username).Count(&count).Error; err != nil { return nil, err @@ -117,7 +144,7 @@ func isInvalidProfileName(name string) bool { //return name == "" || !name.matches("^[0-1a-zA-Z_]{2,16}$"); } -func (u *userSrviceImpl) Login(username string, password string, clientToken *string, requestUser bool) (*LoginResponse, error) { +func (u *userServiceImpl) Login(username string, password string, clientToken *string, requestUser bool) (*LoginResponse, error) { if !u.allowUser(username) { return nil, util.YggdrasilError{ Status: http.StatusTooManyRequests, @@ -175,7 +202,7 @@ func (u *userSrviceImpl) Login(username string, password string, clientToken *st } } -func (u *userSrviceImpl) ChangeProfile(accessToken string, clientToken *string, changeTo string) error { +func (u *userServiceImpl) ChangeProfile(accessToken string, clientToken *string, changeTo string) error { if u.tokenService.VerifyToken(accessToken, clientToken) != model.Valid { return util.NewForbiddenOperationError(util.MessageInvalidToken) } @@ -210,7 +237,7 @@ func (u *userSrviceImpl) ChangeProfile(accessToken string, clientToken *string, return nil } -func (u *userSrviceImpl) Refresh(accessToken string, clientToken *string, requestUser bool, selectedProfile *model.ProfileResponse) (*LoginResponse, error) { +func (u *userServiceImpl) Refresh(accessToken string, clientToken *string, requestUser bool, selectedProfile *model.ProfileResponse) (*LoginResponse, error) { if len(accessToken) <= 36 { user := model.User{} if selectedProfile != nil { @@ -259,7 +286,7 @@ func (u *userSrviceImpl) Refresh(accessToken string, clientToken *string, reques } } -func (u *userSrviceImpl) Validate(accessToken string, clientToken *string) error { +func (u *userServiceImpl) Validate(accessToken string, clientToken *string) error { if len(accessToken) <= 36 { if u.tokenService.VerifyToken(accessToken, clientToken) != model.Valid { return util.NewForbiddenOperationError(util.MessageInvalidToken) @@ -280,7 +307,7 @@ func (u *userSrviceImpl) Validate(accessToken string, clientToken *string) error } } -func (u *userSrviceImpl) Invalidate(accessToken string) error { +func (u *userServiceImpl) Invalidate(accessToken string) error { if len(accessToken) <= 36 { u.tokenService.RemoveAccessToken(accessToken) } else { @@ -295,7 +322,7 @@ func (u *userSrviceImpl) Invalidate(accessToken string) error { return nil } -func (u *userSrviceImpl) Signout(username string, password string) error { +func (u *userServiceImpl) Signout(username string, password string) error { if !u.allowUser(username) { return util.YggdrasilError{ Status: http.StatusTooManyRequests, @@ -325,7 +352,7 @@ func (u *userSrviceImpl) Signout(username string, password string) error { } } -func (u *userSrviceImpl) UsernameToUUID(username string) (*model.ProfileResponse, error) { +func (u *userServiceImpl) UsernameToUUID(username string) (*model.ProfileResponse, error) { user := model.User{} if result := u.db.Where("profile_name = ?", username).First(&user); result.Error == nil { return &model.ProfileResponse{ @@ -342,7 +369,7 @@ func (u *userSrviceImpl) UsernameToUUID(username string) (*model.ProfileResponse } } -func (u *userSrviceImpl) QueryUUIDs(usernames []string) ([]model.ProfileResponse, error) { +func (u *userServiceImpl) QueryUUIDs(usernames []string) ([]model.ProfileResponse, error) { var users []model.User var names []string if len(usernames) > 10 { @@ -362,7 +389,7 @@ func (u *userSrviceImpl) QueryUUIDs(usernames []string) ([]model.ProfileResponse return responses, nil } -func (u *userSrviceImpl) QueryProfile(profileId uuid.UUID, unsigned bool, textureBaseUrl string) (map[string]interface{}, error) { +func (u *userServiceImpl) QueryProfile(profileId uuid.UUID, unsigned bool, textureBaseUrl string) (map[string]interface{}, error) { user := model.User{} if err := u.db.First(&user, profileId).Error; err == nil { profile, err := user.Profile() @@ -386,7 +413,35 @@ func (u *userSrviceImpl) QueryProfile(profileId uuid.UUID, unsigned bool, textur } } -func (u *userSrviceImpl) allowUser(username string) bool { +func (u *userServiceImpl) ProfileKey(accessToken string) (resp *ProfileKeyResponse, err error) { + token, ok := u.tokenService.GetToken(accessToken) + if ok && token.GetAvailableLevel() == model.Valid { + resp = new(ProfileKeyResponse) + now := time.Now().UTC() + resp.RefreshedAfter = now + resp.ExpiresAt = now.Add(time.Hour * 24 * 90) + keyPair, err := u.getProfileKey(token.SelectedProfile.Id) + if err != nil { + return nil, err + } + resp.KeyPair = keyPair + signStr := fmt.Sprintf("%d%s", resp.ExpiresAt.UnixMilli(), keyPair.PublicKey) + sign, err := util.Sign(signStr) + if err != nil { + return nil, err + } + resp.PublicKeySignature = sign + resp.PublicKeySignatureV2 = sign + } else { + err = util.PostForString("https://api.minecraftservices.com/player/certificates", accessToken, []byte(""), resp) + if err != nil { + return nil, err + } + } + return resp, nil +} + +func (u *userServiceImpl) allowUser(username string) bool { if value, ok := u.limitLruCache.Get(username); ok { if limiter, ok := value.(*rate.Limiter); ok { return limiter.Allow() @@ -400,6 +455,51 @@ func (u *userSrviceImpl) allowUser(username string) bool { return true } +func (u *userServiceImpl) getProfileKey(profileId uuid.UUID) (*ProfileKeyPair, error) { + if value, ok := u.profileKeyCache.Get(profileId); ok { + if keyPair, ok := value.(*ProfileKeyPair); ok { + return keyPair, nil + } + } + if keyPair, ok := <-u.keyPairCh; ok { + u.profileKeyCache.Add(profileId, &keyPair) + return &keyPair, nil + } else { + return nil, errors.New("unable to generate rsa key pair") + } +} + +func (u *userServiceImpl) genKeyPair() { + for { + keyPair := ProfileKeyPair{} + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + util.PrivateKey = privateKey + if err != nil { + close(u.keyPairCh) + panic(err) + } + privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + close(u.keyPairCh) + panic(err) + } + keyPair.PrivateKey = string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privateKeyBytes, + })) + publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + close(u.keyPairCh) + panic(err) + } + keyPair.PublicKey = string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: publicKeyBytes, + })) + u.keyPairCh <- keyPair + } +} + func mojangUsernameToUUID(username string) (model.ProfileResponse, error) { response := model.ProfileResponse{} reqUrl := fmt.Sprintf("https://api.mojang.com/users/profiles/minecraft/%s", url.PathEscape(username)) diff --git a/util/http_utils.go b/util/http_utils.go index 1523de6..6e173be 100644 --- a/util/http_utils.go +++ b/util/http_utils.go @@ -19,6 +19,7 @@ package util import ( "bytes" + "context" "encoding/json" "io/ioutil" "net/http" @@ -138,28 +139,39 @@ func PostObjectForError(url string, data interface{}) error { } } -func PostForString(url string, data []byte) (string, error) { +func PostForString(url string, accessToken string, data []byte, value interface{}) error { reader := bytes.NewReader(data) - resp, err := http.Post(url, "application/json", reader) + request, err := http.NewRequestWithContext(context.Background(), "POST", url, reader) if err != nil { - return "", err + return err + } + if accessToken != "" { + request.Header.Set("Authorization", "Bearer "+accessToken) + } + resp, err := http.DefaultClient.Do(request) + if err != nil { + return err } defer resp.Body.Close() if resp.StatusCode == http.StatusNoContent { - return "", nil + return nil } else if resp.StatusCode/100 == 4 { - decoder := json.NewDecoder(resp.Body) errResp := YggdrasilError{} + if resp.ContentLength <= 0 { + errResp.Status = resp.StatusCode + return errResp + } + decoder := json.NewDecoder(resp.Body) err = decoder.Decode(&errResp) if err != nil { - return "", err + return err } - return "", errResp + return errResp } else { body, err := ioutil.ReadAll(resp.Body) if err != nil { - return "", err + return err } - return string(body), nil + return json.Unmarshal(body, value) } }