586 lines
18 KiB
Go
586 lines
18 KiB
Go
/*
|
||
* Copyright (C) 2022-2025. Gardel <sunxinao@hotmail.com> and contributors
|
||
*
|
||
* This program is free software: you can redistribute it and/or modify
|
||
* it under the terms of the GNU Affero General Public License as published by
|
||
* the Free Software Foundation, either version 3 of the License, or
|
||
* (at your option) any later version.
|
||
*
|
||
* This program is distributed in the hope that it will be useful,
|
||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
* GNU Affero General Public License for more details.
|
||
*
|
||
* You should have received a copy of the GNU Affero General Public License
|
||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
*/
|
||
|
||
package service
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/rsa"
|
||
"crypto/x509"
|
||
"encoding/pem"
|
||
"errors"
|
||
"fmt"
|
||
"github.com/google/uuid"
|
||
lru "github.com/hashicorp/golang-lru"
|
||
"golang.org/x/crypto/bcrypt"
|
||
"golang.org/x/time/rate"
|
||
"gorm.io/gorm"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
"yggdrasil-go/model"
|
||
"yggdrasil-go/util"
|
||
)
|
||
|
||
type UserService interface {
|
||
Register(username, password, profileName, ip string) (*model.UserResponse, error)
|
||
Login(username string, password string, clientToken *string, requestUser bool) (*LoginResponse, error)
|
||
ChangeProfile(accessToken string, clientToken *string, changeTo string) error
|
||
Refresh(accessToken string, clientToken *string, requestUser bool, selectedProfile *model.ProfileResponse) (*LoginResponse, error)
|
||
Validate(accessToken string, clientToken *string) error
|
||
Invalidate(accessToken string) error
|
||
Signout(username string, password string) error
|
||
UsernameToUUID(username string) (*model.ProfileResponse, error)
|
||
UUIDToUUID(profileId uuid.UUID) (*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)
|
||
SendEmail(email string, tokenType RegTokenType, ip string) error
|
||
VerifyEmail(accessToken string) error
|
||
ResetPassword(email string, password string, accessToken string) error
|
||
}
|
||
|
||
type LoginResponse struct {
|
||
User *model.UserResponse `json:"user"`
|
||
ClientToken string `json:"clientToken"`
|
||
AccessToken string `json:"accessToken"`
|
||
AvailableProfiles []model.ProfileResponse `json:"availableProfiles,omitempty"`
|
||
SelectedProfile *model.ProfileResponse `json:"selectedProfile"`
|
||
}
|
||
|
||
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
|
||
regTokenService RegTokenService
|
||
db *gorm.DB
|
||
limitLruCache *lru.Cache
|
||
profileKeyCache *lru.Cache
|
||
keyPairCh chan ProfileKeyPair
|
||
}
|
||
|
||
func NewUserService(tokenService TokenService, regTokenService RegTokenService, db *gorm.DB) UserService {
|
||
cache0, _ := lru.New(10000)
|
||
cache1, _ := lru.New(10000)
|
||
ch := make(chan ProfileKeyPair, 100)
|
||
userService := userServiceImpl{
|
||
tokenService: tokenService,
|
||
regTokenService: regTokenService,
|
||
db: db,
|
||
limitLruCache: cache0,
|
||
profileKeyCache: cache1,
|
||
keyPairCh: ch,
|
||
}
|
||
go userService.genKeyPair()
|
||
return &userService
|
||
}
|
||
|
||
func (u *userServiceImpl) Register(username, password, profileName, ip string) (*model.UserResponse, error) {
|
||
var count int64
|
||
if err := u.db.Table("users").Where("email = ?", username).Count(&count).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if count > 0 {
|
||
return nil, util.NewForbiddenOperationError("email exist")
|
||
}
|
||
if err := u.db.Table("users").Where("profile_name = ?", profileName).Count(&count).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if count > 0 {
|
||
return nil, util.NewForbiddenOperationError("profileName exist")
|
||
} else if _, err := mojangUsernameToUUID(profileName); err == nil {
|
||
return nil, util.NewForbiddenOperationError("profileName duplicate")
|
||
}
|
||
matched, err := regexp.MatchString("^(\\w){3,}(\\.\\w+)*@(\\w){2,}((\\.\\w+)+)$", username)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !matched || len(password) < 6 || isInvalidProfileName(profileName) {
|
||
return nil, util.NewIllegalArgumentError("bad format(valid email, password longer than 5, profileName longer than 1)")
|
||
}
|
||
hashedPass, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
user := model.User{
|
||
ID: uuid.New(),
|
||
Email: username,
|
||
Password: string(hashedPass),
|
||
}
|
||
profile := model.NewProfile(user.ID, profileName, model.STEVE, "")
|
||
user.SetProfile(&profile)
|
||
|
||
if err := u.db.Create(&user).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
_ = u.SendEmail(user.Email, RegisterToken, ip)
|
||
response := user.ToResponse()
|
||
return &response, nil
|
||
}
|
||
|
||
func isInvalidProfileName(name string) bool {
|
||
// To support Unicode (like Chinese) profile name, abandoned treatment.
|
||
return name == "" || strings.ContainsRune(name, ' ') || len(name) <= 1
|
||
//return name == "" || !name.matches("^[0-1a-zA-Z_]{2,16}$");
|
||
}
|
||
|
||
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,
|
||
ErrorCode: "ForbiddenOperationException",
|
||
ErrorMessage: "Forbidden",
|
||
}
|
||
}
|
||
user := model.User{}
|
||
if err := u.db.Where("email = ?", username).First(&user).Error; err == nil {
|
||
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil {
|
||
if !user.EmailVerified {
|
||
return nil, util.NewForbiddenOperationError("Email not verified")
|
||
}
|
||
var useClientToken string
|
||
if clientToken == nil || *clientToken == "" {
|
||
useClientToken = util.RandomUUID()
|
||
} else {
|
||
useClientToken = *clientToken
|
||
}
|
||
token := u.tokenService.AcquireToken(&user, &useClientToken, nil)
|
||
profile, err := user.Profile()
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
simpleResponse := profile.ToSimpleResponse()
|
||
var response = LoginResponse{
|
||
AccessToken: token.AccessToken,
|
||
ClientToken: token.ClientToken,
|
||
AvailableProfiles: []model.ProfileResponse{simpleResponse},
|
||
SelectedProfile: &simpleResponse,
|
||
}
|
||
userResponse := user.ToResponse()
|
||
if requestUser {
|
||
response.User = &userResponse
|
||
}
|
||
return &response, nil
|
||
}
|
||
}
|
||
|
||
return nil, util.NewForbiddenOperationError(util.MessageInvalidCredentials)
|
||
}
|
||
|
||
func (u *userServiceImpl) ChangeProfile(accessToken string, clientToken *string, changeTo string) error {
|
||
if u.tokenService.VerifyToken(accessToken, clientToken) != model.Valid {
|
||
return util.NewForbiddenOperationError(util.MessageInvalidToken)
|
||
}
|
||
token, ok := u.tokenService.GetToken(accessToken)
|
||
if !ok {
|
||
return util.NewForbiddenOperationError(util.MessageInvalidToken)
|
||
}
|
||
user := model.User{}
|
||
profile := token.SelectedProfile
|
||
err := u.db.First(&user, profile.Id).Error
|
||
if err != nil {
|
||
return util.NewForbiddenOperationError("User not found")
|
||
}
|
||
var count int64
|
||
if err := u.db.Table("users").Where("profile_name = ?", changeTo).Count(&count).Error; err != nil {
|
||
return err
|
||
}
|
||
if count > 0 {
|
||
return util.NewForbiddenOperationError("profileName exist")
|
||
} else if _, err := mojangUsernameToUUID(changeTo); err == nil {
|
||
return util.NewForbiddenOperationError("profileName duplicate")
|
||
}
|
||
if isInvalidProfileName(changeTo) {
|
||
return util.NewForbiddenOperationError("bad format(profileName longer than 1)")
|
||
}
|
||
|
||
if err = u.db.Model(&user).Update("profile_name", changeTo).Error; err != nil {
|
||
return err
|
||
}
|
||
profile.Name = changeTo
|
||
u.tokenService.UpdateProfile(user.ID, &profile)
|
||
return nil
|
||
}
|
||
|
||
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 {
|
||
// 由于当前实现把用户 UUID 作为角色 UUID,所以不支持角色选择,只要选择了就会报错
|
||
return nil, util.NewForbiddenOperationError(util.MessageTokenAlreadyAssigned)
|
||
}
|
||
if u.tokenService.VerifyToken(accessToken, clientToken) == model.Invalid {
|
||
return nil, util.NewForbiddenOperationError(util.MessageInvalidToken)
|
||
}
|
||
token, ok := u.tokenService.GetToken(accessToken)
|
||
if !ok {
|
||
return nil, util.NewForbiddenOperationError(util.MessageInvalidToken)
|
||
}
|
||
|
||
if err := u.db.First(&user, token.SelectedProfile.Id).Error; err != nil {
|
||
return nil, util.NewIllegalArgumentError(util.MessageProfileNotFound)
|
||
}
|
||
newToken := u.tokenService.AcquireToken(&user, clientToken, nil)
|
||
u.tokenService.RemoveAccessToken(accessToken)
|
||
simpleResponse := newToken.SelectedProfile.ToSimpleResponse()
|
||
var response = LoginResponse{
|
||
AccessToken: newToken.AccessToken,
|
||
ClientToken: newToken.ClientToken,
|
||
AvailableProfiles: []model.ProfileResponse{},
|
||
SelectedProfile: &simpleResponse,
|
||
}
|
||
userResponse := user.ToResponse()
|
||
if requestUser {
|
||
response.User = &userResponse
|
||
}
|
||
return &response, nil
|
||
} else {
|
||
return nil, util.NewForbiddenOperationError(util.MessageInvalidToken)
|
||
}
|
||
}
|
||
|
||
func (u *userServiceImpl) Validate(accessToken string, clientToken *string) error {
|
||
if len(accessToken) <= 36 && u.tokenService.VerifyToken(accessToken, clientToken) == model.Valid {
|
||
return nil
|
||
} else {
|
||
return util.NewForbiddenOperationError(util.MessageInvalidToken)
|
||
}
|
||
}
|
||
|
||
func (u *userServiceImpl) Invalidate(accessToken string) error {
|
||
if len(accessToken) <= 36 {
|
||
u.tokenService.RemoveAccessToken(accessToken)
|
||
return nil
|
||
}
|
||
return util.NewForbiddenOperationError(util.MessageInvalidToken)
|
||
}
|
||
|
||
func (u *userServiceImpl) Signout(username string, password string) error {
|
||
if !u.allowUser(username) {
|
||
return util.YggdrasilError{
|
||
Status: http.StatusTooManyRequests,
|
||
ErrorCode: "ForbiddenOperationException",
|
||
ErrorMessage: "Forbidden",
|
||
}
|
||
}
|
||
user := model.User{}
|
||
if err := u.db.Where("email = ?", username).First(&user).Error; err == nil {
|
||
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil {
|
||
u.tokenService.RemoveAll(user.ID)
|
||
return nil
|
||
}
|
||
}
|
||
return util.NewForbiddenOperationError(util.MessageInvalidCredentials)
|
||
}
|
||
|
||
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{
|
||
Name: user.ProfileName,
|
||
Id: util.UnsignedString(user.ID),
|
||
}, nil
|
||
} else {
|
||
response, err := mojangUsernameToUUID(username)
|
||
if err != nil {
|
||
return nil, nil
|
||
} else {
|
||
return &response, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
func (u *userServiceImpl) UUIDToUUID(profileId uuid.UUID) (*model.ProfileResponse, error) {
|
||
user := model.User{}
|
||
if result := u.db.First(&user, profileId); result.Error == nil {
|
||
return &model.ProfileResponse{
|
||
Name: user.ProfileName,
|
||
Id: util.UnsignedString(user.ID),
|
||
}, nil
|
||
} else {
|
||
response, err := mojangUUIDToUUID(util.UnsignedString(profileId))
|
||
if err != nil {
|
||
return nil, nil
|
||
} else {
|
||
return &response, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
func (u *userServiceImpl) QueryUUIDs(usernames []string) ([]model.ProfileResponse, error) {
|
||
var users []model.User
|
||
var names []string
|
||
if len(usernames) > 10 {
|
||
names = usernames[:10]
|
||
} else {
|
||
names = usernames
|
||
}
|
||
responses := make([]model.ProfileResponse, 0)
|
||
notFoundUsers := make([]string, 0)
|
||
foundUsernames := make(map[string]bool)
|
||
if err := u.db.Table("users").Where("profile_name in ?", names).Find(&users).Error; err == nil {
|
||
for _, user := range users {
|
||
responses = append(responses, model.ProfileResponse{
|
||
Name: user.ProfileName,
|
||
Id: util.UnsignedString(user.ID),
|
||
})
|
||
foundUsernames[user.ProfileName] = true
|
||
}
|
||
for _, name := range names {
|
||
if !foundUsernames[name] {
|
||
notFoundUsers = append(notFoundUsers, name)
|
||
}
|
||
}
|
||
}
|
||
if len(notFoundUsers) > 0 {
|
||
mojangResponses, _ := mojangUsernamesToUUIDs(notFoundUsers)
|
||
for _, resp := range mojangResponses {
|
||
responses = append(responses, resp)
|
||
}
|
||
}
|
||
return responses, nil
|
||
}
|
||
|
||
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()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
response, err := profile.ToCompleteResponse(!unsigned, textureBaseUrl)
|
||
if err != nil {
|
||
return nil, err
|
||
} else {
|
||
return response, err
|
||
}
|
||
} else {
|
||
result := map[string]interface{}{}
|
||
err := util.GetObject(fmt.Sprintf("https://sessionserver.mojang.com/session/minecraft/profile/%s?unsigned=%t", util.UnsignedString(profileId), unsigned), &result)
|
||
if err != nil {
|
||
return nil, err
|
||
} else {
|
||
return result, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
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) SendEmail(email string, tokenType RegTokenType, ip string) error {
|
||
if !u.allowEmail("ip:"+ip) || !u.allowEmail("email:"+email) {
|
||
return util.YggdrasilError{
|
||
Status: http.StatusTooManyRequests,
|
||
ErrorCode: "ForbiddenOperationException",
|
||
ErrorMessage: "Forbidden",
|
||
}
|
||
}
|
||
var count int64
|
||
if err := u.db.Table("users").Where("email = ?", email).Count(&count).Error; err != nil {
|
||
return util.NewIllegalArgumentError(err.Error())
|
||
}
|
||
if count == 0 {
|
||
return util.NewForbiddenOperationError("user not found")
|
||
}
|
||
return u.regTokenService.SendTokenEmail(tokenType, email)
|
||
}
|
||
|
||
func (u *userServiceImpl) VerifyEmail(accessToken string) error {
|
||
email, err := u.regTokenService.VerifyToken(accessToken)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
user := model.User{}
|
||
err = u.db.Where("email = ?", email).First(&user).Error
|
||
if err != nil {
|
||
return util.NewIllegalArgumentError("user not found")
|
||
}
|
||
|
||
user.EmailVerified = true
|
||
return u.db.Model(&user).Update("email_verified", user.EmailVerified).Error
|
||
}
|
||
|
||
func (u *userServiceImpl) ResetPassword(email string, password string, accessToken string) error {
|
||
user := model.User{}
|
||
err := u.db.Where("email = ?", email).First(&user).Error
|
||
if err != nil {
|
||
return util.NewIllegalArgumentError("user not found")
|
||
}
|
||
tokenEmail, err := u.regTokenService.VerifyToken(accessToken)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tokenEmail != email {
|
||
return util.NewIllegalArgumentError("email invalid")
|
||
}
|
||
|
||
if len(password) < 6 {
|
||
return util.NewIllegalArgumentError("bad format(password longer than 5)")
|
||
}
|
||
hashedPass, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||
user.Password = string(hashedPass)
|
||
user.EmailVerified = true
|
||
return u.db.Model(&user).Updates(model.User{
|
||
EmailVerified: user.EmailVerified,
|
||
Password: user.Password,
|
||
}).Error
|
||
}
|
||
|
||
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()
|
||
} else {
|
||
u.limitLruCache.Remove(username)
|
||
}
|
||
} else {
|
||
limiter := rate.NewLimiter(0.2, 3)
|
||
u.limitLruCache.Add(username, limiter)
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (u *userServiceImpl) allowEmail(key string) bool {
|
||
if value, ok := u.limitLruCache.Get(key); ok {
|
||
if limiter, ok := value.(*rate.Limiter); ok {
|
||
return limiter.Allow()
|
||
} else {
|
||
u.limitLruCache.Remove(key)
|
||
}
|
||
} else {
|
||
limiter := rate.NewLimiter(0.02, 1)
|
||
u.limitLruCache.Add(key, limiter)
|
||
}
|
||
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)
|
||
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.minecraftservices.com/minecraft/profile/lookup/name/%s", url.PathEscape(username))
|
||
err := util.GetObject(reqUrl, &response)
|
||
if err != nil {
|
||
return response, err
|
||
} else {
|
||
return response, nil
|
||
}
|
||
}
|
||
|
||
func mojangUUIDToUUID(uid string) (model.ProfileResponse, error) {
|
||
response := model.ProfileResponse{}
|
||
reqUrl := fmt.Sprintf("https://api.minecraftservices.com/minecraft/profile/lookup/%s", uid)
|
||
err := util.GetObject(reqUrl, &response)
|
||
if err != nil {
|
||
return response, err
|
||
} else {
|
||
return response, nil
|
||
}
|
||
}
|
||
|
||
func mojangUsernamesToUUIDs(username []string) ([]model.ProfileResponse, error) {
|
||
response := make([]model.ProfileResponse, 0)
|
||
err := util.PostObject("https://api.minecraftservices.com/minecraft/profile/lookup/bulk/byname", username, &response)
|
||
if err != nil {
|
||
return response, err
|
||
} else {
|
||
return response, nil
|
||
}
|
||
}
|