drasl/user.go
2025-07-27 11:49:08 -04:00

729 lines
19 KiB
Go

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, err = app.NewPlayerUUID(*playerName)
if err != nil {
return User{}, err
}
} 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 = NewUserErrorWithCode(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{}, NewUserErrorWithCode(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{}, NewUserErrorWithCode(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{}, NewUserErrorWithCode(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{}, NewUserErrorWithCode(http.StatusUnauthorized, "Incorrect password.")
}
if user.IsLocked {
return User{}, NewUserErrorWithCode(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 NewUserErrorWithCode(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 NewUserErrorWithCode(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
}