availableProfiles, selectedProfile

This commit is contained in:
Evan Goode 2024-11-27 15:21:52 -05:00
parent aa6f8d314b
commit b034fd5a51
7 changed files with 218 additions and 104 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@
/node_modules /node_modules
/public/bundle.js /public/bundle.js
/swagger /swagger
result

204
auth.go
View File

@ -14,6 +14,21 @@ import (
Authentication server 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 { type UserProperty struct {
Name string `json:"name"` Name string `json:"name"`
Value string `json:"value"` Value string `json:"value"`
@ -31,6 +46,10 @@ var invalidAccessTokenBlob []byte = Unwrap(json.Marshal(ErrorResponse{
Error: Ptr("ForbiddenOperationException"), Error: Ptr("ForbiddenOperationException"),
ErrorMessage: Ptr("Invalid token."), ErrorMessage: Ptr("Invalid token."),
})) }))
var playerNotFoundBlob []byte = Unwrap(json.Marshal(ErrorResponse{
Error: Ptr("IllegalArgumentException"),
ErrorMessage: Ptr("Player not found."),
}))
type serverInfoResponse struct { type serverInfoResponse struct {
Status string `json:"Status"` Status string `json:"Status"`
@ -83,27 +102,43 @@ func AuthAuthenticate(app *App) func(c echo.Context) error {
return err return err
} }
playerName := req.Username playerNameOrUsername := req.Username
var player Player var user User
result := app.DB.Preload("User").First(&player, "name = ?", playerName) var player *Player
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) { var playerStruct Player
return c.JSONBlob(http.StatusUnauthorized, invalidCredentialsBlob) if err := app.DB.Preload("User").First(&playerStruct, "name = ?", playerNameOrUsername).Error; err == nil {
player = &playerStruct
user = player.User
} else {
if errors.Is(err, gorm.ErrRecordNotFound) {
if err := app.DB.First(&user, "username = ?", playerNameOrUsername).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSONBlob(http.StatusUnauthorized, invalidCredentialsBlob)
} else {
return err
}
}
} else { } else {
return result.Error return err
} }
} }
passwordHash, err := HashPassword(req.Password, player.User.PasswordSalt) passwordHash, err := HashPassword(req.Password, user.PasswordSalt)
if err != nil { if err != nil {
return err return err
} }
if !bytes.Equal(passwordHash, player.User.PasswordHash) { if !bytes.Equal(passwordHash, user.PasswordHash) {
return c.JSONBlob(http.StatusUnauthorized, invalidCredentialsBlob) return c.JSONBlob(http.StatusUnauthorized, invalidCredentialsBlob)
} }
var playerUUID *string = nil
if player != nil {
playerUUID = &player.UUID
}
var client Client var client Client
if req.ClientToken == nil { if req.ClientToken == nil {
clientToken, err := RandomHex(16) clientToken, err := RandomHex(16)
@ -114,20 +149,24 @@ func AuthAuthenticate(app *App) func(c echo.Context) error {
UUID: uuid.New().String(), UUID: uuid.New().String(),
ClientToken: clientToken, ClientToken: clientToken,
Version: 0, Version: 0,
PlayerUUID: playerUUID,
} }
player.Clients = append(player.Clients, client) user.Clients = append(user.Clients, client)
} else { } else {
clientToken := *req.ClientToken clientToken := *req.ClientToken
clientExists := false clientExists := false
for i := range player.Clients {
if player.Clients[i].ClientToken == clientToken { for i := range user.Clients {
if user.Clients[i].ClientToken == clientToken {
clientExists = true clientExists = true
player.Clients[i].Version += 1 user.Clients[i].Version += 1
client = player.Clients[i] client = user.Clients[i]
break break
} else { } else {
if !app.Config.AllowMultipleAccessTokens { // If AllowMultipleAccessTokens is disabled, invalidate all
player.Clients[i].Version += 1 // clients associated with the same player
if !app.Config.AllowMultipleAccessTokens && player != nil && user.Clients[i].PlayerUUID != nil && *user.Clients[i].PlayerUUID == player.UUID {
user.Clients[i].Version += 1
} }
} }
} }
@ -137,34 +176,38 @@ func AuthAuthenticate(app *App) func(c echo.Context) error {
UUID: uuid.New().String(), UUID: uuid.New().String(),
ClientToken: clientToken, ClientToken: clientToken,
Version: 0, Version: 0,
PlayerUUID: playerUUID,
} }
player.Clients = append(player.Clients, client) user.Clients = append(user.Clients, client)
} }
} }
// Save changes to player.Clients var selectedProfile *Profile = nil
result = app.DB.Session(&gorm.Session{FullSaveAssociations: true}).Save(&player) var availableProfiles *[]Profile = nil
if result.Error != nil {
return result.Error
}
id, err := UUIDToID(player.UUID)
if err != nil {
return err
}
var selectedProfile *Profile
var availableProfiles *[]Profile
if req.Agent != nil { if req.Agent != nil {
selectedProfile = &Profile{ if player != nil {
ID: id, id, err := UUIDToID(player.UUID)
Name: player.Name, if err != nil {
return err
}
selectedProfile = &Profile{
ID: id,
Name: player.Name,
}
} }
availableProfiles = &[]Profile{*selectedProfile} availableProfilesArray, err := getAvailableProfiles(&user)
if err != nil {
return err
}
availableProfiles = &availableProfilesArray
} }
var userResponse *UserResponse var userResponse *UserResponse
if req.RequestUser { if req.RequestUser && player != nil {
id, err := UUIDToID(player.UUID)
if err != nil {
return err
}
userResponse = &UserResponse{ userResponse = &UserResponse{
ID: id, ID: id,
Properties: []UserProperty{{ Properties: []UserProperty{{
@ -179,6 +222,11 @@ func AuthAuthenticate(app *App) func(c echo.Context) error {
return err 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{ res := authenticateResponse{
ClientToken: client.ClientToken, ClientToken: client.ClientToken,
AccessToken: accessToken, AccessToken: accessToken,
@ -191,14 +239,15 @@ func AuthAuthenticate(app *App) func(c echo.Context) error {
} }
type refreshRequest struct { type refreshRequest struct {
AccessToken string `json:"accessToken"` AccessToken string `json:"accessToken"`
ClientToken string `json:"clientToken"` ClientToken string `json:"clientToken"`
RequestUser bool `json:"requestUser"` RequestUser bool `json:"requestUser"`
SelectedProfile *Profile `json:"selectedProfile"`
} }
type refreshResponse struct { type refreshResponse struct {
AccessToken string `json:"accessToken"` AccessToken string `json:"accessToken"`
ClientToken string `json:"clientToken"` ClientToken string `json:"clientToken"`
SelectedProfile Profile `json:"selectedProfile,omitempty"` SelectedProfile *Profile `json:"selectedProfile,omitempty"`
AvailableProfiles []Profile `json:"availableProfiles,omitempty"` AvailableProfiles []Profile `json:"availableProfiles,omitempty"`
User *UserResponse `json:"user,omitempty"` User *UserResponse `json:"user,omitempty"`
} }
@ -216,26 +265,53 @@ func AuthRefresh(app *App) func(c echo.Context) error {
if client == nil || client.ClientToken != req.ClientToken { if client == nil || client.ClientToken != req.ClientToken {
return c.JSONBlob(http.StatusUnauthorized, invalidAccessTokenBlob) return c.JSONBlob(http.StatusUnauthorized, invalidAccessTokenBlob)
} }
user := client.User
player := client.Player player := client.Player
id, err := UUIDToID(player.UUID) 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 = &userPlayer.UUID
player = &userPlayer
break
}
}
if player == nil {
return c.JSONBlob(http.StatusBadRequest, playerNotFoundBlob)
}
}
}
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 { if err != nil {
return err return err
} }
selectedProfile := Profile{
ID: id,
Name: player.Name,
}
availableProfiles := []Profile{selectedProfile}
var userResponse *UserResponse var userResponse *UserResponse
if req.RequestUser { if req.RequestUser && selectedProfile != nil {
userResponse = &UserResponse{ userResponse = &UserResponse{
ID: id, ID: selectedProfile.ID,
Properties: []UserProperty{{ Properties: []UserProperty{{
Name: "preferredLanguage", Name: "preferredLanguage",
Value: player.User.PreferredLanguage, Value: user.PreferredLanguage,
}}, }},
} }
} }
@ -246,9 +322,8 @@ func AuthRefresh(app *App) func(c echo.Context) error {
return err return err
} }
result := app.DB.Save(client) if err := app.DB.Save(client).Error; err != nil {
if result.Error != nil { return err
return result.Error
} }
res := refreshResponse{ res := refreshResponse{
@ -300,22 +375,22 @@ func AuthSignout(app *App) func(c echo.Context) error {
return err return err
} }
var player Player var user User
result := app.DB.Preload("User").First(&player, "name = ?", req.Username) result := app.DB.First(&user, "username = ?", req.Username)
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
} }
passwordHash, err := HashPassword(req.Password, player.User.PasswordSalt) passwordHash, err := HashPassword(req.Password, user.PasswordSalt)
if err != nil { if err != nil {
return err return err
} }
if !bytes.Equal(passwordHash, player.User.PasswordHash) { if !bytes.Equal(passwordHash, user.PasswordHash) {
return c.JSONBlob(http.StatusUnauthorized, invalidCredentialsBlob) return c.JSONBlob(http.StatusUnauthorized, invalidCredentialsBlob)
} }
err = app.InvalidatePlayer(app.DB, &player) err = app.InvalidateUser(app.DB, &user)
if err != nil { if err != nil {
return err return err
} }
@ -339,13 +414,20 @@ func AuthInvalidate(app *App) func(c echo.Context) error {
} }
client := app.GetClient(req.AccessToken, StalePolicyAllow) client := app.GetClient(req.AccessToken, StalePolicyAllow)
if client == nil || client.ClientToken != req.ClientToken { if client == nil {
return c.JSONBlob(http.StatusUnauthorized, invalidAccessTokenBlob) return c.JSONBlob(http.StatusUnauthorized, invalidAccessTokenBlob)
} }
err := app.InvalidatePlayer(app.DB, &client.Player) if client.Player == nil {
if err != nil { err := app.InvalidateUser(app.DB, &client.User)
return err if err != nil {
return err
}
} else {
err := app.InvalidatePlayer(app.DB, client.Player)
if err != nil {
return err
}
} }
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)

View File

@ -93,12 +93,15 @@ func (ts *TestSuite) testAuthenticate(t *testing.T) {
// Check that the database was updated // Check that the database was updated
var client Client var client Client
result := ts.App.DB.Preload("Player.User").First(&client, "client_token = ?", response.ClientToken) result := ts.App.DB.Preload("Player").First(&client, "client_token = ?", response.ClientToken)
assert.Nil(t, result.Error) assert.Nil(t, result.Error)
assert.NotNil(t, client.Player)
assert.Equal(t, TEST_PLAYER_NAME, client.Player.Name) assert.Equal(t, TEST_PLAYER_NAME, client.Player.Name)
accessTokenClient := ts.App.GetClient(response.AccessToken, StalePolicyDeny) accessTokenClient := ts.App.GetClient(response.AccessToken, StalePolicyDeny)
assert.NotNil(t, accessTokenClient) assert.NotNil(t, accessTokenClient)
accessTokenClient.Player = client.Player
accessTokenClient.User = client.User
assert.Equal(t, client, *accessTokenClient) assert.Equal(t, client, *accessTokenClient)
@ -261,19 +264,6 @@ func (ts *TestSuite) testInvalidate(t *testing.T) {
authenticateRes = ts.authenticate(t, TEST_PLAYER_NAME, TEST_PASSWORD) authenticateRes = ts.authenticate(t, TEST_PLAYER_NAME, TEST_PASSWORD)
clientToken = authenticateRes.ClientToken clientToken = authenticateRes.ClientToken
accessToken = authenticateRes.AccessToken accessToken = authenticateRes.AccessToken
{
// Invalidation should fail when client token is invalid
payload := refreshRequest{
ClientToken: "invalid",
AccessToken: accessToken,
}
rec := ts.PostJSON(t, ts.Server, "/invalidate", payload, nil, nil)
// Invalidate should fail
var response ErrorResponse
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
assert.Equal(t, "ForbiddenOperationException", *response.Error)
}
{ {
// Invalidate should fail if we send an invalid access token // Invalidate should fail if we send an invalid access token
payload := refreshRequest{ payload := refreshRequest{
@ -285,6 +275,7 @@ func (ts *TestSuite) testInvalidate(t *testing.T) {
// Invalidate should fail // Invalidate should fail
var response ErrorResponse var response ErrorResponse
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response)) assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
assert.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Equal(t, "ForbiddenOperationException", *response.Error) assert.Equal(t, "ForbiddenOperationException", *response.Error)
assert.Equal(t, "Invalid token.", *response.ErrorMessage) assert.Equal(t, "Invalid token.", *response.ErrorMessage)
} }
@ -327,7 +318,7 @@ func (ts *TestSuite) testRefresh(t *testing.T) {
ID: Unwrap(UUIDToID(player.UUID)), ID: Unwrap(UUIDToID(player.UUID)),
Name: player.Name, Name: player.Name,
} }
assert.Equal(t, expectedProfile, refreshRes.SelectedProfile) assert.Equal(t, expectedProfile, *refreshRes.SelectedProfile)
assert.Equal(t, []Profile{expectedProfile}, refreshRes.AvailableProfiles) assert.Equal(t, []Profile{expectedProfile}, refreshRes.AvailableProfiles)
// We did not pass requestUser // We did not pass requestUser
@ -400,15 +391,15 @@ func (ts *TestSuite) testSignout(t *testing.T) {
accessToken := authenticateRes.AccessToken accessToken := authenticateRes.AccessToken
{ {
// Successful signout // Successful signout
var player Player var user User
result := ts.App.DB.First(&player, "name = ?", TEST_PLAYER_NAME) result := ts.App.DB.First(&user, "username = ?", TEST_USERNAME)
assert.Nil(t, result.Error) assert.Nil(t, result.Error)
// We should start with valid clients in the database // We should start with valid clients in the database
client := ts.App.GetClient(accessToken, StalePolicyDeny) client := ts.App.GetClient(accessToken, StalePolicyDeny)
assert.NotNil(t, client) assert.NotNil(t, client)
var clients []Client var clients []Client
result = ts.App.DB.Model(Client{}).Where("player_uuid = ?", client.Player.UUID).Find(&clients) result = ts.App.DB.Model(Client{}).Where("user_uuid = ?", client.UserUUID).Find(&clients)
assert.Nil(t, result.Error) assert.Nil(t, result.Error)
assert.True(t, len(clients) > 0) assert.True(t, len(clients) > 0)
oldVersions := make(map[string]int) oldVersions := make(map[string]int)
@ -417,7 +408,7 @@ func (ts *TestSuite) testSignout(t *testing.T) {
} }
payload := signoutRequest{ payload := signoutRequest{
Username: TEST_PLAYER_NAME, Username: TEST_USERNAME,
Password: TEST_PASSWORD, Password: TEST_PASSWORD,
} }
rec := ts.PostJSON(t, ts.Server, "/signout", payload, nil, nil) rec := ts.PostJSON(t, ts.Server, "/signout", payload, nil, nil)
@ -428,7 +419,7 @@ func (ts *TestSuite) testSignout(t *testing.T) {
// The token version of each client should have been incremented, // The token version of each client should have been incremented,
// invalidating all previously-issued JWTs // invalidating all previously-issued JWTs
assert.Nil(t, ts.App.GetClient(accessToken, StalePolicyDeny)) assert.Nil(t, ts.App.GetClient(accessToken, StalePolicyDeny))
result = ts.App.DB.Model(Client{}).Where("player_uuid = ?", client.Player.UUID).Find(&clients) result = ts.App.DB.Model(Client{}).Where("user_uuid = ?", client.UserUUID).Find(&clients)
assert.Nil(t, result.Error) assert.Nil(t, result.Error)
assert.True(t, len(clients) > 0) assert.True(t, len(clients) > 0)
for _, client := range clients { for _, client := range clients {
@ -438,7 +429,7 @@ func (ts *TestSuite) testSignout(t *testing.T) {
{ {
// Should fail when incorrect password is sent // Should fail when incorrect password is sent
payload := signoutRequest{ payload := signoutRequest{
Username: TEST_PLAYER_NAME, Username: TEST_USERNAME,
Password: "incorrect", Password: "incorrect",
} }
rec := ts.PostJSON(t, ts.Server, "/signout", payload, nil, nil) rec := ts.PostJSON(t, ts.Server, "/signout", payload, nil, nil)
@ -446,6 +437,7 @@ func (ts *TestSuite) testSignout(t *testing.T) {
// Signout should fail // Signout should fail
var response ErrorResponse var response ErrorResponse
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response)) assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
assert.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Equal(t, "ForbiddenOperationException", *response.Error) assert.Equal(t, "ForbiddenOperationException", *response.Error)
assert.Equal(t, "Invalid credentials. Invalid username or password.", *response.ErrorMessage) assert.Equal(t, "Invalid credentials. Invalid username or password.", *response.ErrorMessage)
} }

3
db.go
View File

@ -249,7 +249,8 @@ func migrate(db *gorm.DB, alreadyExisted bool) error {
UUID: v3Client.UUID, UUID: v3Client.UUID,
ClientToken: v3Client.ClientToken, ClientToken: v3Client.ClientToken,
Version: v3Client.Version, Version: v3Client.Version,
PlayerUUID: v3Client.UserUUID, UserUUID: v3Client.UserUUID,
PlayerUUID: &v3Client.UserUUID,
}) })
} }
player := V4Player{ player := V4Player{

View File

@ -333,7 +333,7 @@ func (app *App) GetClient(accessToken string, stalePolicy StaleTokenPolicy) *Cli
} }
var client Client var client Client
result := app.DB.Preload("Player.User").First(&client, "uuid = ?", claims.RegisteredClaims.Subject) result := app.DB.Preload("User").Preload("Player").First(&client, "uuid = ?", claims.RegisteredClaims.Subject)
if result.Error != nil { if result.Error != nil {
return nil return nil
} }
@ -368,6 +368,7 @@ type User struct {
PreferredLanguage string PreferredLanguage string
Players []Player Players []Player
MaxPlayerCount int MaxPlayerCount int
Clients []Client
} }
func (user *User) BeforeDelete(tx *gorm.DB) error { func (user *User) BeforeDelete(tx *gorm.DB) error {
@ -378,11 +379,50 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
if len(players) > 0 { if len(players) > 0 {
return tx.Delete(&players).Error return tx.Delete(&players).Error
} }
var clients []Client
if err := tx.Where("user_uuid = ?", user.UUID).Find(&clients).Error; err != nil {
return err
}
if len(clients) > 0 {
if err := tx.Delete(&clients).Error; err != nil {
return err
}
}
return nil
}
func (player *Player) BeforeDelete(tx *gorm.DB) error {
var clients []Client
if err := tx.Where("player_uuid = ?", player.UUID).Find(&clients).Error; err != nil {
return err
}
if len(clients) > 0 {
if err := tx.Delete(&clients).Error; err != nil {
return err
}
}
return nil
}
func (player *Player) AfterFind(tx *gorm.DB) error {
if err := tx.Find(&player.Clients, "player_uuid = ?", player.UUID).Error; err != nil {
return err
}
return nil return nil
} }
func (user *User) AfterFind(tx *gorm.DB) error { func (user *User) AfterFind(tx *gorm.DB) error {
return tx.Find(&user.Players, "user_uuid = ?", user.UUID).Error err := tx.Find(&user.Players, "user_uuid = ?", user.UUID).Error
if err != nil {
return err
}
err = tx.Find(&user.Clients, "user_uuid = ?", user.UUID).Error
if err != nil {
return err
}
return nil
} }
type Player struct { type Player struct {
@ -396,32 +436,19 @@ type Player struct {
CapeHash sql.NullString `gorm:"index"` CapeHash sql.NullString `gorm:"index"`
ServerID sql.NullString ServerID sql.NullString
FallbackPlayer string FallbackPlayer string
Clients []Client
User User User User
UserUUID string `gorm:"not null"` UserUUID string `gorm:"not null"`
} Clients []Client
func (player *Player) BeforeDelete(tx *gorm.DB) (err error) {
var clients []Client
if err := tx.Where("player_uuid = ?", player.UUID).Find(&clients).Error; err != nil {
return err
}
if len(clients) > 0 {
return tx.Delete(&clients).Error
}
return nil
}
func (player *Player) AfterFind(tx *gorm.DB) error {
return tx.Find(&player.Clients, "player_uuid = ?", player.UUID).Error
} }
type Client struct { type Client struct {
UUID string `gorm:"primaryKey"` UUID string `gorm:"primaryKey"`
ClientToken string ClientToken string
Version int Version int
PlayerUUID string `gorm:"not null"` UserUUID string `gorm:"not null"`
Player Player User User
PlayerUUID *string
Player *Player
} }
func (app *App) GetSkinURL(player *Player) (*string, error) { func (app *App) GetSkinURL(player *Player) (*string, error) {

View File

@ -572,10 +572,18 @@ func (app *App) GetChallengeSkin(playerName string, challengeToken string) ([]by
} }
func (app *App) InvalidatePlayer(db *gorm.DB, player *Player) error { func (app *App) InvalidatePlayer(db *gorm.DB, player *Player) error {
if player == nil {
return nil
}
result := db.Model(Client{}).Where("player_uuid = ?", player.UUID).Update("version", gorm.Expr("version + ?", 1)) result := db.Model(Client{}).Where("player_uuid = ?", player.UUID).Update("version", gorm.Expr("version + ?", 1))
return result.Error return result.Error
} }
func (app *App) InvalidateUser(db *gorm.DB, user *User) error {
result := db.Model(Client{}).Where("user_uuid = ?", user.UUID).Update("version", gorm.Expr("version + ?", 1))
return result.Error
}
func (app *App) DeletePlayer(player *Player) error { func (app *App) DeletePlayer(player *Player) error {
if err := app.DB.Delete(player).Error; err != nil { if err := app.DB.Delete(player).Error; err != nil {
return err return err

View File

@ -41,8 +41,11 @@ func withBearerAuthentication(app *App, f func(c echo.Context, player *Player) e
return c.JSON(http.StatusUnauthorized, ErrorResponse{Path: Ptr(c.Request().URL.Path)}) return c.JSON(http.StatusUnauthorized, ErrorResponse{Path: Ptr(c.Request().URL.Path)})
} }
player := client.Player player := client.Player
if player == nil {
return c.JSON(http.StatusBadRequest, ErrorResponse{Path: Ptr(c.Request().URL.Path), ErrorMessage: Ptr("Access token does not have a selected profile.")})
}
return f(c, &player) return f(c, player)
} }
} }