package main import ( "bytes" "context" "crypto/rand" "errors" "fmt" "github.com/google/uuid" "github.com/samber/mo" "github.com/zitadel/oidc/v3/pkg/client/rp" "github.com/zitadel/oidc/v3/pkg/oidc" "gorm.io/gorm" "io" "net/http" "slices" "time" ) // Must be in a region of the skin that supports translucency const SKIN_WINDOW_X_MIN = 40 const SKIN_WINDOW_X_MAX = 48 const SKIN_WINDOW_Y_MIN = 9 const SKIN_WINDOW_Y_MAX = 11 var InviteNotFoundError error = NewBadRequestUserError("Invite not found.") var InviteMissingError error = NewBadRequestUserError("Registration requires an invite.") func (app *App) ValidateIDToken(idToken string) (*OIDCProvider, oidc.IDTokenClaims, error) { var claims oidc.IDTokenClaims _, err := oidc.ParseToken(idToken, &claims) if err != nil { return nil, oidc.IDTokenClaims{}, NewBadRequestUserError("Invalid ID token from %s", claims.Issuer) } oidcProvider, ok := app.OIDCProvidersByIssuer[claims.Issuer] if !ok { return nil, oidc.IDTokenClaims{}, NewBadRequestUserError("Unknown OIDC issuer: %s", claims.Issuer) } verifier := oidcProvider.RelyingParty.IDTokenVerifier() _, err = rp.VerifyIDToken[*oidc.IDTokenClaims](context.Background(), idToken, verifier) if err != nil { return nil, oidc.IDTokenClaims{}, NewBadRequestUserError("Invalid ID token from %s", claims.Issuer) } return oidcProvider, claims, nil } type OIDCIdentitySpec struct { Issuer string Subject string } func (app *App) CreateUser( caller *User, username string, password *string, // You must verify that the caller owns these OIDC identities (or is an admin). oidcIdentitySpecs PotentiallyInsecure[[]OIDCIdentitySpec], isAdmin bool, isLocked bool, inviteCode *string, preferredLanguage *string, playerName *string, chosenUUID *string, existingPlayer bool, challengeToken *string, fallbackPlayer *string, maxPlayerCount *int, skinModel *string, skinReader *io.Reader, skinURL *string, capeReader *io.Reader, capeURL *string, ) (User, error) { callerIsAdmin := caller != nil && caller.IsAdmin userUUID := uuid.New().String() if err := app.ValidateUsername(username); err != nil { return User{}, NewBadRequestUserError("Invalid username: %s", err) } if password == nil && len(oidcIdentitySpecs.Value) == 0 { return User{}, NewBadRequestUserError("Must specify either a password or an OIDC identity.") } if password != nil { if !app.Config.AllowPasswordLogin { return User{}, NewBadRequestUserError("Password registration is not allowed.") } if err := app.ValidatePassword(*password); err != nil { return User{}, NewBadRequestUserError("Invalid password: %s", err) } } oidcIdentities := make([]UserOIDCIdentity, 0, len(oidcIdentitySpecs.Value)) for _, oidcIdentitySpec := range oidcIdentitySpecs.Value { provider, ok := app.OIDCProvidersByIssuer[oidcIdentitySpec.Issuer] if !ok { return User{}, NewBadRequestUserError("Unknown OIDC provider: %s", oidcIdentitySpec.Issuer) } if oidcIdentitySpec.Subject == "" { return User{}, NewBadRequestUserError("OIDC subject for provider %s can't be blank.", provider.Config.Issuer) } oidcIdentities = append(oidcIdentities, UserOIDCIdentity{ UserUUID: userUUID, Issuer: provider.Config.Issuer, Subject: oidcIdentitySpec.Subject, }) } if playerName == nil { playerName = &username } if err := app.ValidatePlayerName(*playerName); err != nil { return User{}, NewBadRequestUserError("Invalid player name: %s", err) } if preferredLanguage == nil { preferredLanguage = &app.Config.DefaultPreferredLanguage } if !IsValidPreferredLanguage(*preferredLanguage) { return User{}, NewBadRequestUserError("Invalid preferred language.") } getInvite := func(requireInvite bool) (*Invite, error) { var invite Invite if inviteCode == nil { if requireInvite && !callerIsAdmin { return nil, InviteMissingError } return nil, nil } else { result := app.DB.First(&invite, "code = ?", *inviteCode) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, InviteNotFoundError } return nil, result.Error } return &invite, nil } } var invite *Invite var playerUUID string if existingPlayer { // Existing player registration if !app.Config.RegistrationExistingPlayer.Allow && !callerIsAdmin { return User{}, NewBadRequestUserError("Registration from an existing player is not allowed.") } var err error invite, err = getInvite(app.Config.RegistrationExistingPlayer.RequireInvite) if err != nil { return User{}, err } if err := app.ValidatePlayerName(*playerName); err != nil { return User{}, NewBadRequestUserError("Invalid player name: %s", err) } details, err := app.ValidateChallenge(*playerName, challengeToken) if err != nil { if app.Config.ImportExistingPlayer.RequireSkinVerification { return User{}, NewBadRequestUserError("Couldn't verify your skin, maybe try again: %s", err) } else { return User{}, NewBadRequestUserError("Couldn't find your account, maybe try again: %s", err) } } playerName = &details.Username if err := app.ValidatePlayerName(*playerName); err != nil { return User{}, NewBadRequestUserError("Invalid player name: %s", err) } playerUUID = details.UUID } else { // New player registration if !app.Config.RegistrationNewPlayer.Allow && !callerIsAdmin { return User{}, NewBadRequestUserError("Registration without some existing player is not allowed.") } var err error invite, err = getInvite(app.Config.RegistrationNewPlayer.RequireInvite) if err != nil { return User{}, err } if chosenUUID == nil { playerUUID = uuid.New().String() } else { if !app.Config.CreateNewPlayer.AllowChoosingUUID && !callerIsAdmin { return User{}, NewBadRequestUserError("Choosing a UUID is not allowed.") } chosenUUIDStruct, err := uuid.Parse(*chosenUUID) if err != nil { return User{}, NewBadRequestUserError("Invalid UUID: %s", err) } playerUUID = chosenUUIDStruct.String() } } passwordSalt := []byte{} passwordHash := []byte{} if password != nil { passwordSalt = make([]byte, 16) _, err := rand.Read(passwordSalt) if err != nil { return User{}, err } passwordHash, err = HashPassword(*password, passwordSalt) if err != nil { return User{}, err } } if isAdmin && !callerIsAdmin { return User{}, NewBadRequestUserError("Cannot make a new admin user without having admin privileges yourself.") } if isLocked && !callerIsAdmin { return User{}, NewBadRequestUserError("Cannot make a new locked user without admin privileges.") } maxPlayerCountInt := Constants.MaxPlayerCountUseDefault if maxPlayerCount != nil { if !callerIsAdmin { return User{}, NewBadRequestUserError("Cannot set a max player count without admin privileges.") } err := app.ValidateMaxPlayerCount(*maxPlayerCount) if err != nil { return User{}, NewBadRequestUserError("Invalid max player count: %s", err) } maxPlayerCountInt = *maxPlayerCount } apiToken, err := MakeAPIToken() if err != nil { return User{}, err } minecraftToken, err := MakeMinecraftToken() if err != nil { return User{}, err } user := User{ IsAdmin: Contains(app.Config.DefaultAdmins, username) || isAdmin, IsLocked: isLocked, UUID: userUUID, Username: username, PasswordSalt: passwordSalt, PasswordHash: passwordHash, PreferredLanguage: app.Config.DefaultPreferredLanguage, MaxPlayerCount: maxPlayerCountInt, APIToken: apiToken, MinecraftToken: minecraftToken, OIDCIdentities: oidcIdentities, } // Player offlineUUID, err := OfflineUUID(*playerName) if err != nil { return User{}, err } if fallbackPlayer == nil { fallbackPlayer = &playerUUID } if err := app.ValidatePlayerNameOrUUID(*fallbackPlayer); err != nil { return User{}, NewBadRequestUserError("Invalid fallback player: %s", err) } if skinModel == nil { skinModel = Ptr(SkinModelClassic) } if !IsValidSkinModel(*skinModel) { return User{}, NewBadRequestUserError("Invalid skin model.") } skinHash, skinBuf, err := app.getTexture("skin", caller, skinReader, skinURL) if err != nil { return User{}, err } capeHash, capeBuf, err := app.getTexture("cape", caller, capeReader, capeURL) if err != nil { return User{}, err } tx := app.DB.Begin() defer tx.Rollback() if err := tx.Create(&user).Error; err != nil { if IsErrorUniqueFailedField(err, "users.username") { return User{}, NewBadRequestUserError("That username is taken.") } else if IsErrorUsernameTakenByPlayerName(err) { return User{}, NewBadRequestUserError("That username is in use as the name of another user's player.") } else { return User{}, err } } player := Player{ UUID: playerUUID, UserUUID: user.UUID, Clients: []Client{}, Name: *playerName, OfflineUUID: offlineUUID, FallbackPlayer: *fallbackPlayer, SkinModel: *skinModel, SkinHash: MakeNullString(skinHash), CapeHash: MakeNullString(capeHash), CreatedAt: time.Now(), NameLastChangedAt: time.Now(), } user.Players = append(user.Players, player) if err := tx.Create(&player).Error; err != nil { if IsErrorUniqueFailedField(err, "players.name") { return User{}, NewBadRequestUserError("That player name is taken.") } else if IsErrorUniqueFailedField(err, "players.uuid") { return User{}, NewBadRequestUserError("That UUID is taken.") } else if IsErrorPlayerNameTakenByUsername(err) { return User{}, NewBadRequestUserError("That player name is in use as another user's username.") } else { return User{}, err } } if invite != nil { if err := tx.Delete(invite).Error; err != nil { return User{}, err } } if err := tx.Commit().Error; err != nil { return User{}, err } if skinHash != nil { err = app.WriteSkin(*skinHash, skinBuf) if err != nil { return user, NewBadRequestUserError("Error saving the skin.") } } if capeHash != nil { err = app.WriteCape(*capeHash, capeBuf) if err != nil { return user, NewBadRequestUserError("Error saving the cape.") } } return user, nil } var PasswordLoginNotAllowedError error = NewUserError(http.StatusUnauthorized, "Password login is not allowed.") func (app *App) AuthenticateUserForMigration(username string, password string) (User, error) { var user User result := app.DB.First(&user, "username = ?", username) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return User{}, NewUserError(http.StatusUnauthorized, "User not found.") } return User{}, result.Error } if len(user.OIDCIdentities) > 0 { return User{}, PasswordLoginNotAllowedError } passwordHash, err := HashPassword(password, user.PasswordSalt) if err != nil { return User{}, err } if !bytes.Equal(passwordHash, user.PasswordHash) { return User{}, NewUserError(http.StatusUnauthorized, "Incorrect password.") } return user, nil } func (app *App) AuthenticateUser(username string, password string) (User, error) { var user User result := app.DB.First(&user, "username = ?", username) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return User{}, NewUserError(http.StatusUnauthorized, "User not found.") } return User{}, result.Error } if !app.Config.AllowPasswordLogin || len(user.OIDCIdentities) > 0 { return User{}, PasswordLoginNotAllowedError } passwordHash, err := HashPassword(password, user.PasswordSalt) if err != nil { return User{}, err } if !bytes.Equal(passwordHash, user.PasswordHash) { return User{}, NewUserError(http.StatusUnauthorized, "Incorrect password.") } if user.IsLocked { return User{}, NewUserError(http.StatusForbidden, "User is locked.") } return user, nil } func (app *App) UpdateUser( db *gorm.DB, caller *User, user User, password *string, isAdmin *bool, isLocked *bool, resetAPIToken bool, resetMinecraftToken bool, preferredLanguage *string, maxPlayerCount *int, ) (User, error) { if caller == nil { return User{}, NewBadRequestUserError("Caller cannot be null.") } callerIsAdmin := caller.IsAdmin if user.UUID != caller.UUID && !callerIsAdmin { return User{}, NewBadRequestUserError("You are not authorized to update that user.") } if password != nil { if err := app.ValidatePassword(*password); err != nil { return User{}, NewBadRequestUserError("Invalid password: %s", err) } passwordSalt := make([]byte, 16) _, err := rand.Read(passwordSalt) if err != nil { return User{}, err } user.PasswordSalt = passwordSalt passwordHash, err := HashPassword(*password, passwordSalt) if err != nil { return User{}, err } user.PasswordHash = passwordHash } if isAdmin != nil { if !callerIsAdmin { return User{}, NewBadRequestUserError("Cannot change admin status of user without having admin privileges yourself.") } if !(*isAdmin) && app.IsDefaultAdmin(&user) { return User{}, NewBadRequestUserError("Cannot revoke admin status of a default admin.") } user.IsAdmin = *isAdmin } if preferredLanguage != nil { if !IsValidPreferredLanguage(*preferredLanguage) { return User{}, NewBadRequestUserError("Invalid preferred language.") } user.PreferredLanguage = *preferredLanguage } if resetAPIToken { apiToken, err := MakeAPIToken() if err != nil { return User{}, err } user.APIToken = apiToken } if resetMinecraftToken { minecraftToken, err := MakeMinecraftToken() if err != nil { return User{}, err } user.MinecraftToken = minecraftToken } if maxPlayerCount != nil { if !callerIsAdmin { return User{}, NewBadRequestUserError("Cannot set a max player count without admin privileges.") } err := app.ValidateMaxPlayerCount(*maxPlayerCount) if err != nil { return User{}, NewBadRequestUserError("Invalid max player count: %s", err) } user.MaxPlayerCount = *maxPlayerCount } err := db.Transaction(func(tx *gorm.DB) error { if isLocked != nil { if !callerIsAdmin { return NewBadRequestUserError("Cannot change locked status of user without having admin privileges yourself.") } err := app.SetIsLocked(tx, &user, *isLocked) if err != nil { return err } } if err := tx.Save(&user).Error; err != nil { return err } return nil }) if err != nil { return User{}, err } return user, nil } func (app *App) SetIsLocked(db *gorm.DB, user *User, isLocked bool) error { user.IsLocked = isLocked if isLocked { user.BrowserToken = MakeNullString(nil) err := app.InvalidateUser(db, user) if err != nil { return err } } if err := db.Save(user).Error; err != nil { return err } return nil } func (app *App) DeleteUser(caller *User, user *User) error { if !caller.IsAdmin && caller.UUID != user.UUID { return NewUserError(http.StatusForbidden, "You are not an admin.") } oldSkinHashes := make([]*string, 0, len(user.Players)) oldCapeHashes := make([]*string, 0, len(user.Players)) var players []Player if err := app.DB.Where("user_uuid = ?", user.UUID).Find(&players).Error; err != nil { return err } for _, player := range players { oldSkinHashes = append(oldSkinHashes, UnmakeNullString(&player.SkinHash)) oldCapeHashes = append(oldCapeHashes, UnmakeNullString(&player.CapeHash)) } if err := app.DB.Delete(user).Error; err != nil { return err } for _, oldSkinHash := range oldSkinHashes { err := app.DeleteSkinIfUnused(oldSkinHash) if err != nil { return err } } for _, oldCapeHash := range oldCapeHashes { err := app.DeleteCapeIfUnused(oldCapeHash) if err != nil { return err } } return nil } func (app *App) CreateOIDCIdentity( caller *User, userUUID string, issuer string, subject string, ) (UserOIDCIdentity, error) { if caller == nil { return UserOIDCIdentity{}, NewBadRequestUserError("Caller cannot be null.") } callerIsAdmin := caller.IsAdmin if userUUID != caller.UUID && !callerIsAdmin { return UserOIDCIdentity{}, NewBadRequestUserError("Can't link an OIDC account for another user unless you're an admin.") } var user User if err := app.DB.First(&user, "uuid = ?", userUUID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return UserOIDCIdentity{}, NewBadRequestUserError("User not found.") } return UserOIDCIdentity{}, err } userOIDCIdentity := UserOIDCIdentity{ UserUUID: userUUID, Issuer: issuer, Subject: subject, } err := app.DB.Transaction(func(tx *gorm.DB) error { if err := tx.Create(&userOIDCIdentity).Error; err != nil { if IsErrorUniqueFailedField(err, "user_oidc_identities.issuer, user_oidc_identities.subject") { provider, ok := app.OIDCProvidersByIssuer[issuer] if !ok { return fmt.Errorf("Unknown OIDC provider: %s", issuer) } return NewBadRequestUserError("That %s account is already linked to another user.", provider.Config.Name) } if IsErrorUniqueFailedField(err, "user_oidc_identities.issuer") { provider, ok := app.OIDCProvidersByIssuer[issuer] if !ok { return fmt.Errorf("Unknown OIDC provider: %s", issuer) } return NewBadRequestUserError("That user is already linked to a %s account.", provider.Config.Name) } return err } user.OIDCIdentities = append(user.OIDCIdentities, userOIDCIdentity) if err := tx.Save(&user).Error; err != nil { return err } return nil }) if err != nil { return UserOIDCIdentity{}, err } return userOIDCIdentity, nil } func (app *App) DeleteOIDCIdentity( caller *User, userUUID string, providerName string, ) error { if caller == nil { return NewBadRequestUserError("Caller cannot be null.") } callerIsAdmin := caller.IsAdmin if userUUID != caller.UUID && !callerIsAdmin { return NewBadRequestUserError("Can't unlink an OIDC account for another user unless you're an admin.") } provider, ok := app.OIDCProvidersByName[providerName] if !ok { return NewBadRequestUserError("Unknown OIDC provider: %s", providerName) } return app.DB.Transaction(func(tx *gorm.DB) error { result := app.DB.Where("user_uuid = ? AND issuer = ?", userUUID, provider.Config.Issuer).Delete(&UserOIDCIdentity{}) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return NewUserError(http.StatusNotFound, "No linked %s account found.", providerName) } var count int64 if err := tx.Model(&UserOIDCIdentity{}).Where("user_uuid = ?", userUUID).Count(&count).Error; err != nil { return err } if count == 0 { return NewBadRequestUserError("Can't remove the last linked OIDC account.") } return nil }) } func (app *App) PrimaryPlayerSkinURL(user *User) (*string, error) { if len(user.Players) == 0 { return nil, nil } player := (func() mo.Option[*Player] { if len(user.Players) == 0 { return mo.None[*Player]() } for _, player := range user.Players { if player.Name == user.Username { if player.SkinHash.Valid { return mo.Some(&player) } break } } playersDecreasingAge := make([]*Player, 0, len(user.Players)) for i := range user.Players { playersDecreasingAge = append(playersDecreasingAge, &user.Players[i]) } slices.SortFunc(playersDecreasingAge, func(a *Player, b *Player) int { return a.CreatedAt.Compare(b.CreatedAt) }) for _, player := range playersDecreasingAge { if player.SkinHash.Valid { return mo.Some(player) } } return mo.None[*Player]() })() if p, ok := player.Get(); ok { skinURL, err := app.SkinURL(p.SkinHash.String) if err != nil { return nil, err } return &skinURL, nil } return nil, nil }