package main import ( "bytes" "encoding/json" "errors" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/samber/mo" "gorm.io/gorm" "net/http" ) /* Authentication server */ func getAvailableProfiles(user *User) ([]Profile, error) { var availableProfiles []Profile for _, player := range user.Players { id, err := UUIDToID(player.UUID) if err != nil { return nil, err } availableProfiles = append(availableProfiles, Profile{ ID: id, Name: player.Name, }) } return availableProfiles, nil } type UserProperty struct { Name string `json:"name"` Value string `json:"value"` } type UserResponse struct { ID string `json:"id"` Properties []UserProperty `json:"properties"` } var invalidCredentialsError = &YggdrasilError{ Code: http.StatusUnauthorized, Error_: mo.Some("ForbiddenOperationException"), ErrorMessage: mo.Some("Invalid credentials. Invalid username or password."), } var invalidAccessTokenError = &YggdrasilError{ Code: http.StatusForbidden, Error_: mo.Some("ForbiddenOperationException"), ErrorMessage: mo.Some("Invalid token"), } var playerNotFoundError = &YggdrasilError{ Code: http.StatusBadRequest, Error_: mo.Some("IllegalArgumentException"), ErrorMessage: mo.Some("Player not found."), } type serverInfoResponse struct { Status string `json:"Status"` RuntimeMode string `json:"RuntimeMode"` ApplicationAuthor string `json:"ApplicationAuthor"` ApplicationDescription string `json:"ApplcationDescription"` SpecificationVersion string `json:"SpecificationVersion"` ImplementationVersion string `json:"ImplementationVersion"` ApplicationOwner string `json:"ApplicationOwner"` } // GET / func AuthServerInfo(app *App) func(c echo.Context) error { info := serverInfoResponse{ Status: "OK", RuntimeMode: "productionMode", ApplicationAuthor: "Unmojang", ApplicationDescription: "", SpecificationVersion: "2.13.34", ImplementationVersion: "0.1.0", ApplicationOwner: app.Config.ApplicationOwner, } infoBlob := Unwrap(json.Marshal(info)) return func(c echo.Context) error { return c.JSONBlob(http.StatusOK, infoBlob) } } type authenticateRequest struct { Username string `json:"username"` Password string `json:"password"` ClientToken *string `json:"clientToken"` Agent *Agent `json:"agent"` RequestUser bool `json:"requestUser"` } type authenticateResponse struct { AccessToken string `json:"accessToken"` ClientToken string `json:"clientToken"` SelectedProfile *Profile `json:"selectedProfile,omitempty"` AvailableProfiles *[]Profile `json:"availableProfiles,omitempty"` User *UserResponse `json:"user,omitempty"` } func (app *App) AuthAuthenticateUser(c echo.Context, playerNameOrUsername string, password string) (*User, mo.Option[Player], error) { var user *User player := mo.None[Player]() var playerStruct Player if err := app.DB.Preload("User").First(&playerStruct, "name = ?", playerNameOrUsername).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { var userStruct User if err := app.DB.First(&userStruct, "username = ?", playerNameOrUsername).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, mo.None[Player](), invalidCredentialsError } return nil, mo.None[Player](), err } user = &userStruct if len(user.Players) == 1 { player = mo.Some(user.Players[0]) } } else { return nil, mo.None[Player](), err } } else { // player query succeeded player = mo.Some(playerStruct) user = &player.ToPointer().User } if password == user.MinecraftToken { return user, player, nil } if !app.Config.AllowPasswordLogin || len(user.OIDCIdentities) > 0 { return nil, mo.None[Player](), invalidCredentialsError } passwordHash, err := HashPassword(password, user.PasswordSalt) if err != nil { return nil, mo.None[Player](), err } if !bytes.Equal(passwordHash, user.PasswordHash) { return nil, mo.None[Player](), invalidCredentialsError } if user.IsLocked { return nil, mo.None[Player](), invalidCredentialsError } return user, player, nil } // POST /authenticate // https://minecraft.wiki/w/Yggdrasil#Authenticate func AuthAuthenticate(app *App) func(c echo.Context) error { return func(c echo.Context) (err error) { req := new(authenticateRequest) if err = c.Bind(req); err != nil { return err } user, player, err := app.AuthAuthenticateUser(c, req.Username, req.Password) if err != nil { return err } playerUUID := mo.None[string]() if p, ok := player.Get(); ok { playerUUID = mo.Some(p.UUID) } var client Client if req.ClientToken == nil { clientToken, err := RandomHex(16) if err != nil { return err } client = Client{ UUID: uuid.New().String(), ClientToken: clientToken, Version: 0, PlayerUUID: OptionToNullString(playerUUID), } user.Clients = append(user.Clients, client) } else { clientToken := *req.ClientToken clientExists := false for i := range user.Clients { if user.Clients[i].ClientToken == clientToken { clientExists = true user.Clients[i].Version += 1 client = user.Clients[i] break } else { // If AllowMultipleAccessTokens is disabled, invalidate all // clients associated with the same player if !app.Config.AllowMultipleAccessTokens && NullStringToOption(&user.Clients[i].PlayerUUID) == playerUUID { user.Clients[i].Version += 1 } } } if !clientExists { client = Client{ UUID: uuid.New().String(), ClientToken: clientToken, Version: 0, PlayerUUID: OptionToNullString(playerUUID), } user.Clients = append(user.Clients, client) } } var selectedProfile *Profile = nil var availableProfiles *[]Profile = nil if req.Agent != nil { if p, ok := player.Get(); ok { id, err := UUIDToID(p.UUID) if err != nil { return err } selectedProfile = &Profile{ ID: id, Name: p.Name, } } availableProfilesArray, err := getAvailableProfiles(user) if err != nil { return err } availableProfiles = &availableProfilesArray } var userResponse *UserResponse if req.RequestUser { id, err := UUIDToID(user.UUID) if err != nil { return err } userResponse = &UserResponse{ ID: id, Properties: []UserProperty{{ Name: "preferredLanguage", Value: user.PreferredLanguage, }}, } } accessToken, err := app.MakeAccessToken(client) if err != nil { return err } // Save changes to user.Clients if err := app.DB.Session(&gorm.Session{FullSaveAssociations: true}).Save(&user).Error; err != nil { return err } res := authenticateResponse{ ClientToken: client.ClientToken, AccessToken: accessToken, SelectedProfile: selectedProfile, AvailableProfiles: availableProfiles, User: userResponse, } return c.JSON(http.StatusOK, &res) } } type refreshRequest struct { AccessToken string `json:"accessToken"` ClientToken string `json:"clientToken"` RequestUser bool `json:"requestUser"` SelectedProfile *Profile `json:"selectedProfile"` } type refreshResponse struct { AccessToken string `json:"accessToken"` ClientToken string `json:"clientToken"` SelectedProfile *Profile `json:"selectedProfile,omitempty"` AvailableProfiles []Profile `json:"availableProfiles,omitempty"` User *UserResponse `json:"user,omitempty"` } // POST /refresh // https://minecraft.wiki/w/Yggdrasil#Refresh func AuthRefresh(app *App) func(c echo.Context) error { return func(c echo.Context) error { req := new(refreshRequest) if err := c.Bind(req); err != nil { return err } client := app.GetClient(req.AccessToken, StalePolicyAllow) if client == nil || client.ClientToken != req.ClientToken { return invalidAccessTokenError } user := client.User player := client.Player if req.SelectedProfile != nil { if player == nil { // Just ignore if there is already a selectedProfile for the // client for _, userPlayer := range user.Players { requestedUUID, err := IDToUUID(req.SelectedProfile.ID) if err != nil { return err } if userPlayer.UUID == requestedUUID { client.PlayerUUID = MakeNullString(&userPlayer.UUID) player = &userPlayer break } } if player == nil { return playerNotFoundError } } } var selectedProfile *Profile = nil if player != nil { id, err := UUIDToID(player.UUID) if err != nil { return err } selectedProfile = &Profile{ ID: id, Name: player.Name, } } availableProfiles, err := getAvailableProfiles(&user) if err != nil { return err } var userResponse *UserResponse if req.RequestUser && selectedProfile != nil { userResponse = &UserResponse{ ID: selectedProfile.ID, Properties: []UserProperty{{ Name: "preferredLanguage", Value: user.PreferredLanguage, }}, } } client.Version += 1 accessToken, err := app.MakeAccessToken(*client) if err != nil { return err } if err := app.DB.Save(client).Error; err != nil { return err } res := refreshResponse{ AccessToken: accessToken, ClientToken: client.ClientToken, SelectedProfile: selectedProfile, AvailableProfiles: availableProfiles, User: userResponse, } return c.JSON(http.StatusOK, &res) } } type validateRequest struct { AccessToken string `json:"accessToken"` ClientToken string `json:"clientToken"` } // POST /validate // https://minecraft.wiki/w/Yggdrasil#Validate func AuthValidate(app *App) func(c echo.Context) error { return func(c echo.Context) error { req := new(validateRequest) if err := c.Bind(req); err != nil { return err } client := app.GetClient(req.AccessToken, StalePolicyDeny) if client == nil || client.ClientToken != req.ClientToken { return c.NoContent(http.StatusForbidden) } return c.NoContent(http.StatusNoContent) } } type signoutRequest struct { Username string `json:"username"` Password string `json:"password"` } // POST /signout // https://minecraft.wiki/w/Yggdrasil#Signout func AuthSignout(app *App) func(c echo.Context) error { return func(c echo.Context) error { req := new(signoutRequest) if err := c.Bind(req); err != nil { return err } user, _, err := app.AuthAuthenticateUser(c, req.Username, req.Password) if err != nil { return err } err = app.InvalidateUser(app.DB, user) if err != nil { return err } return c.NoContent(http.StatusNoContent) } } type invalidateRequest struct { AccessToken string `json:"accessToken"` ClientToken string `json:"clientToken"` } // POST /invalidate // https://minecraft.wiki/w/Yggdrasil#Invalidate func AuthInvalidate(app *App) func(c echo.Context) error { return func(c echo.Context) error { req := new(invalidateRequest) if err := c.Bind(req); err != nil { return err } client := app.GetClient(req.AccessToken, StalePolicyAllow) if client == nil { return invalidAccessTokenError } if client.Player == nil { err := app.InvalidateUser(app.DB, &client.User) if err != nil { return err } } else { err := app.InvalidatePlayer(app.DB, client.Player) if err != nil { return err } } return c.NoContent(http.StatusNoContent) } }