mirror of
https://github.com/unmojang/drasl.git
synced 2025-08-03 02:46:03 -04:00
Implement SSO via OIDC (#127)
Resolves https://github.com/unmojang/drasl/issues/39 * Use __Host- cookie prefix instead of setting Domain See https://stackoverflow.com/a/64735551 * Unlinking OIDC accounts * AllowPasswordLogin, OIDC docs, cleanup * YggdrasilError * Migrate existing password users without login * API query/create/delete user OIDC identities * test APICreateOIDCIdentity * test APIDeleteeOIDCIdentity * API Create users with OIDC identities * OIDC: PKCE * Use YggdrasilError in authlib-injector routes * OIDC: AllowChoosingPlayerName * recipes.md: Update for OIDC and deprecated config options * OIDC: fix APICreateUser without password, validate oidcIdentities * OIDC: error at complete-registration if no preferred player name * Proper error pages * MC_ prefix for Minecraft Tokens
This commit is contained in:
parent
09c9192cca
commit
5c1f6c1cfa
@ -86,7 +86,7 @@ Drasl also implements (almost all of) the authlib-injector API at `/authlib-inje
|
||||
|
||||
If using Nix (with flakes), simply run `nix build`.
|
||||
|
||||
Otherwise, install build dependencies. Go 1.19 or later is required:
|
||||
Otherwise, install build dependencies. Go 1.21 or later is required:
|
||||
|
||||
```
|
||||
sudo apt install make golang gcc nodejs npm # Debian
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/samber/mo"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"net/http"
|
||||
@ -60,7 +61,7 @@ func AccountPlayerNameToID(app *App) func(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
errorMessage := fmt.Sprintf("Couldn't find any profile with name %s", playerName)
|
||||
return MakeErrorResponse(&c, http.StatusNotFound, nil, Ptr(errorMessage))
|
||||
return &YggdrasilError{Code: http.StatusNotFound, ErrorMessage: mo.Some(errorMessage)}
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
@ -90,7 +91,11 @@ func AccountPlayerNamesToIDs(app *App) func(c echo.Context) error {
|
||||
|
||||
n := len(playerNames)
|
||||
if !(1 <= n && n <= 10) {
|
||||
return MakeErrorResponse(&c, http.StatusBadRequest, Ptr("CONSTRAINT_VIOLATION"), Ptr("getProfileName.profileNames: size must be between 1 and 10"))
|
||||
return &YggdrasilError{
|
||||
Code: http.StatusBadRequest,
|
||||
Error_: mo.Some("CONSTRAINT_VIOLATION"),
|
||||
ErrorMessage: mo.Some("getProfileName.profileNames: size must be between 1 and 10"),
|
||||
}
|
||||
}
|
||||
|
||||
response := make([]playerNameToUUIDResponse, 0, n)
|
||||
|
@ -140,7 +140,7 @@ func (ts *TestSuite) testAccountPlayerNamesToIDsFallback(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, "CONSTRAINT_VIOLATION", *response.Error)
|
||||
}
|
||||
|
209
api.go
209
api.go
@ -64,7 +64,7 @@ func (app *App) HandleAPIError(err error, c *echo.Context) error {
|
||||
|
||||
var userError *UserError
|
||||
if errors.As(err, &userError) {
|
||||
code = userError.Code
|
||||
code = userError.Code.OrElse(http.StatusInternalServerError)
|
||||
message = userError.Error()
|
||||
log = false
|
||||
}
|
||||
@ -164,18 +164,18 @@ func (app *App) withAPITokenAdmin(f func(c echo.Context, user *User) error) func
|
||||
}
|
||||
|
||||
type APIUser struct {
|
||||
IsAdmin bool `json:"isAdmin" example:"true"` // Whether the user is an admin
|
||||
IsLocked bool `json:"isLocked" example:"false"` // Whether the user is locked (disabled)
|
||||
UUID string `json:"uuid" example:"557e0c92-2420-4704-8840-a790ea11551c"`
|
||||
Username string `json:"username" example:"MyUsername"` // Username. Can be different from the user's player name.
|
||||
PreferredLanguage string `json:"preferredLanguage" example:"en"` // One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft.
|
||||
Players []APIPlayer `json:"players"` // A user can have multiple players.
|
||||
MaxPlayerCount int `json:"maxPlayerCount" example:"3"` // Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value.
|
||||
IsAdmin bool `json:"isAdmin" example:"true"` // Whether the user is an admin
|
||||
IsLocked bool `json:"isLocked" example:"false"` // Whether the user is locked (disabled)
|
||||
UUID string `json:"uuid" example:"557e0c92-2420-4704-8840-a790ea11551c"`
|
||||
Username string `json:"username" example:"MyUsername"` // Username. Can be different from the user's player name.
|
||||
PreferredLanguage string `json:"preferredLanguage" example:"en"` // One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft.
|
||||
MaxPlayerCount int `json:"maxPlayerCount" example:"3"` // Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value.
|
||||
Players []APIPlayer `json:"players"` // A user can have multiple players.
|
||||
OIDCIdentities []APIOIDCIdentity `json:"oidcIdentities"` // OIDC identities linked to the user
|
||||
}
|
||||
|
||||
func (app *App) userToAPIUser(user *User) (APIUser, error) {
|
||||
apiPlayers := make([]APIPlayer, 0, len(user.Players))
|
||||
|
||||
for _, player := range user.Players {
|
||||
apiPlayer, err := app.playerToAPIPlayer(&player)
|
||||
if err != nil {
|
||||
@ -184,6 +184,15 @@ func (app *App) userToAPIUser(user *User) (APIUser, error) {
|
||||
apiPlayers = append(apiPlayers, apiPlayer)
|
||||
}
|
||||
|
||||
apiOIDCIdentities := make([]APIOIDCIdentity, 0, len(user.OIDCIdentities))
|
||||
for _, oidcIdentity := range user.OIDCIdentities {
|
||||
apiOIDCIdentity, err := app.oidcIdentityToAPIOIDCIdentity(&oidcIdentity)
|
||||
if err != nil {
|
||||
return APIUser{}, err
|
||||
}
|
||||
apiOIDCIdentities = append(apiOIDCIdentities, apiOIDCIdentity)
|
||||
}
|
||||
|
||||
return APIUser{
|
||||
IsAdmin: user.IsAdmin,
|
||||
IsLocked: user.IsLocked,
|
||||
@ -191,6 +200,7 @@ func (app *App) userToAPIUser(user *User) (APIUser, error) {
|
||||
Username: user.Username,
|
||||
PreferredLanguage: user.PreferredLanguage,
|
||||
Players: apiPlayers,
|
||||
OIDCIdentities: apiOIDCIdentities,
|
||||
MaxPlayerCount: user.MaxPlayerCount,
|
||||
}, nil
|
||||
}
|
||||
@ -231,6 +241,26 @@ func (app *App) playerToAPIPlayer(player *Player) (APIPlayer, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
type APIOIDCIdentity struct {
|
||||
UserUUID string `json:"userUuid" example:"918bd04e-1bc4-4ccd-860f-60c15c5f1cec"`
|
||||
OIDCProviderName string `json:"oidcProviderName" example:"Kanidm"`
|
||||
Issuer string `json:"issuer" example:"https://idm.example.com/oauth2/openid/drasl"`
|
||||
Subject string `json:"subject" example:"f85f8c18-9bdf-49ad-a76e-719f9ba3ed25"`
|
||||
}
|
||||
|
||||
func (app *App) oidcIdentityToAPIOIDCIdentity(oidcIdentity *UserOIDCIdentity) (APIOIDCIdentity, error) {
|
||||
oidcProvider, ok := app.OIDCProvidersByIssuer[oidcIdentity.Issuer]
|
||||
if !ok {
|
||||
return APIOIDCIdentity{}, InternalServerError
|
||||
}
|
||||
return APIOIDCIdentity{
|
||||
UserUUID: oidcIdentity.UserUUID,
|
||||
OIDCProviderName: oidcProvider.Config.Name,
|
||||
Issuer: oidcIdentity.Issuer,
|
||||
Subject: oidcIdentity.Subject,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type APIInvite struct {
|
||||
Code string `json:"code" example:"rqjJwh0yMjO"` // The base62 invite code
|
||||
URL string `json:"url" example:"https://drasl.example.com/drasl/registration?invite=rqjJwh0yMjO"` // Link to register using the invite
|
||||
@ -343,24 +373,30 @@ func (app *App) APIGetUser() func(c echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
type APIOIDCIdentitySpec struct {
|
||||
Issuer string `json:"issuer" example:"https://idm.example.com/oauth2/openid/drasl"`
|
||||
Subject string `json:"subject" example:"f85f8c18-9bdf-49ad-a76e-719f9ba3ed25"`
|
||||
}
|
||||
|
||||
type APICreateUserRequest struct {
|
||||
Username string `json:"username" example:"MyUsername"` // Username of the new user. Can be different from the user's player name.
|
||||
Password string `json:"password" example:"hunter2"` // Plaintext password
|
||||
IsAdmin bool `json:"isAdmin" example:"true"` // Whether the user is an admin
|
||||
IsLocked bool `json:"isLocked" example:"false"` // Whether the user is locked (disabled)
|
||||
RequestAPIToken bool `json:"requestApiToken" example:"true"` // Whether to include an API token for the user in the response
|
||||
ChosenUUID *string `json:"chosenUuid" example:"557e0c92-2420-4704-8840-a790ea11551c"` // Optional. Specify a UUID for the player of the new user. If omitted, a random UUID will be generated.
|
||||
ExistingPlayer bool `json:"existingPlayer" example:"false"` // If true, the new user's player will get the UUID of the existing player with the specified PlayerName. See `RegistrationExistingPlayer` in configuration.md.
|
||||
InviteCode *string `json:"inviteCode" example:"rqjJwh0yMjO"` // Invite code to use. Optional even if the `RequireInvite` configuration option is set; admin API users can bypass `RequireInvite`.
|
||||
PlayerName *string `json:"playerName" example:"MyPlayerName"` // Optional. Player name. Can be different from the user's username. If omitted, the user's username will be used.
|
||||
FallbackPlayer *string `json:"fallbackPlayer" example:"Notch"` // Can be a UUID or a player name. If you don't set a skin or cape, this player's skin on one of the fallback API servers will be used instead.
|
||||
PreferredLanguage *string `json:"preferredLanguage" example:"en"` // Optional. One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft. If omitted, the value of the `DefaultPreferredLanguage` configuration option will be used.
|
||||
SkinModel *string `json:"skinModel" example:"classic"` // Skin model. Either "classic" or "slim". If omitted, `"classic"` will be assumed.
|
||||
SkinBase64 *string `json:"skinBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI"` // Optional. Base64-encoded skin PNG. Example value truncated for brevity. Do not specify both `skinBase64` and `skinUrl`.
|
||||
SkinURL *string `json:"skinUrl" example:"https://example.com/skin.png"` // Optional. URL to skin file. Do not specify both `skinBase64` and `skinUrl`.
|
||||
CapeBase64 *string `json:"capeBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf"` // Optional. Base64-encoded cape PNG. Example value truncated for brevity. Do not specify both `capeBase64` and `capeUrl`.
|
||||
CapeURL *string `json:"capeUrl" example:"https://example.com/cape.png"` // Optional. URL to cape file. Do not specify both `capeBase64` and `capeUrl`.
|
||||
MaxPlayerCount *int `json:"maxPlayerCount" example:"3"` // Optional. Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value.
|
||||
Username string `json:"username" example:"MyUsername"` // Username of the new user. Can be different from the user's player name.
|
||||
Password *string `json:"password" example:"hunter2"` // Plaintext password. Not needed if OIDCIdentitySpecs are supplied.
|
||||
OIDCIdentitySpecs []APIOIDCIdentitySpec `json:"oidcIdentities"`
|
||||
IsAdmin bool `json:"isAdmin" example:"true"` // Whether the user is an admin
|
||||
IsLocked bool `json:"isLocked" example:"false"` // Whether the user is locked (disabled)
|
||||
RequestAPIToken bool `json:"requestApiToken" example:"true"` // Whether to include an API token for the user in the response
|
||||
ChosenUUID *string `json:"chosenUuid" example:"557e0c92-2420-4704-8840-a790ea11551c"` // Optional. Specify a UUID for the player of the new user. If omitted, a random UUID will be generated.
|
||||
ExistingPlayer bool `json:"existingPlayer" example:"false"` // If true, the new user's player will get the UUID of the existing player with the specified PlayerName. See `RegistrationExistingPlayer` in configuration.md.
|
||||
InviteCode *string `json:"inviteCode" example:"rqjJwh0yMjO"` // Invite code to use. Optional even if the `RequireInvite` configuration option is set; admin API users can bypass `RequireInvite`.
|
||||
PlayerName *string `json:"playerName" example:"MyPlayerName"` // Optional. Player name. Can be different from the user's username. If omitted, the user's username will be used.
|
||||
FallbackPlayer *string `json:"fallbackPlayer" example:"Notch"` // Can be a UUID or a player name. If you don't set a skin or cape, this player's skin on one of the fallback API servers will be used instead.
|
||||
PreferredLanguage *string `json:"preferredLanguage" example:"en"` // Optional. One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft. If omitted, the value of the `DefaultPreferredLanguage` configuration option will be used.
|
||||
SkinModel *string `json:"skinModel" example:"classic"` // Skin model. Either "classic" or "slim". If omitted, `"classic"` will be assumed.
|
||||
SkinBase64 *string `json:"skinBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI"` // Optional. Base64-encoded skin PNG. Example value truncated for brevity. Do not specify both `skinBase64` and `skinUrl`.
|
||||
SkinURL *string `json:"skinUrl" example:"https://example.com/skin.png"` // Optional. URL to skin file. Do not specify both `skinBase64` and `skinUrl`.
|
||||
CapeBase64 *string `json:"capeBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf"` // Optional. Base64-encoded cape PNG. Example value truncated for brevity. Do not specify both `capeBase64` and `capeUrl`.
|
||||
CapeURL *string `json:"capeUrl" example:"https://example.com/cape.png"` // Optional. URL to cape file. Do not specify both `capeBase64` and `capeUrl`.
|
||||
MaxPlayerCount *int `json:"maxPlayerCount" example:"3"` // Optional. Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value.
|
||||
}
|
||||
|
||||
type APICreateUserResponse struct {
|
||||
@ -385,6 +421,8 @@ type APICreateUserResponse struct {
|
||||
// @Router /drasl/api/v2/users [post]
|
||||
func (app *App) APICreateUser() func(c echo.Context) error {
|
||||
return app.withAPIToken(false, func(c echo.Context, caller *User) error {
|
||||
callerIsAdmin := caller != nil && caller.IsAdmin
|
||||
|
||||
req := new(APICreateUserRequest)
|
||||
if err := c.Bind(req); err != nil {
|
||||
return err
|
||||
@ -402,10 +440,19 @@ func (app *App) APICreateUser() func(c echo.Context) error {
|
||||
capeReader = &decoder
|
||||
}
|
||||
|
||||
if !callerIsAdmin && len(req.OIDCIdentitySpecs) > 0 {
|
||||
return NewBadRequestUserError("Can't create a user with OIDC identities without admin privileges.")
|
||||
}
|
||||
oidcIdentitySpecs := make([]OIDCIdentitySpec, 0, len(req.OIDCIdentitySpecs))
|
||||
for _, ois := range req.OIDCIdentitySpecs {
|
||||
oidcIdentitySpecs = append(oidcIdentitySpecs, OIDCIdentitySpec(ois))
|
||||
}
|
||||
|
||||
user, err := app.CreateUser(
|
||||
caller,
|
||||
req.Username,
|
||||
req.Password,
|
||||
PotentiallyInsecure[[]OIDCIdentitySpec]{Value: oidcIdentitySpecs},
|
||||
req.IsAdmin,
|
||||
req.IsLocked,
|
||||
req.InviteCode,
|
||||
@ -440,12 +487,13 @@ func (app *App) APICreateUser() func(c echo.Context) error {
|
||||
}
|
||||
|
||||
type APIUpdateUserRequest struct {
|
||||
Password *string `json:"password" example:"hunter2"` // Optional. New plaintext password
|
||||
IsAdmin *bool `json:"isAdmin" example:"true"` // Optional. Pass`true` to grant, `false` to revoke admin privileges.
|
||||
IsLocked *bool `json:"isLocked" example:"false"` // Optional. Pass `true` to lock (disable), `false` to unlock user.
|
||||
ResetAPIToken bool `json:"resetApiToken" example:"true"` // Pass `true` to reset the user's API token
|
||||
PreferredLanguage *string `json:"preferredLanguage" example:"en"` // Optional. One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft.
|
||||
MaxPlayerCount *int `json:"maxPlayerCount" example:"3"` // Optional. Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value.
|
||||
Password *string `json:"password" example:"hunter2"` // Optional. New plaintext password
|
||||
IsAdmin *bool `json:"isAdmin" example:"true"` // Optional. Pass`true` to grant, `false` to revoke admin privileges.
|
||||
IsLocked *bool `json:"isLocked" example:"false"` // Optional. Pass `true` to lock (disable), `false` to unlock user.
|
||||
ResetAPIToken bool `json:"resetApiToken" example:"true"` // Pass `true` to reset the user's API token
|
||||
ResetMinecraftToken bool `json:"resetMinecraftToken" example:"true"` // Pass `true` to reset the user's Minecraft token
|
||||
PreferredLanguage *string `json:"preferredLanguage" example:"en"` // Optional. One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft.
|
||||
MaxPlayerCount *int `json:"maxPlayerCount" example:"3"` // Optional. Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value.
|
||||
}
|
||||
|
||||
// APIUpdateUser godoc
|
||||
@ -492,6 +540,7 @@ func (app *App) APIUpdateUser() func(c echo.Context) error {
|
||||
req.IsAdmin,
|
||||
req.IsLocked,
|
||||
req.ResetAPIToken,
|
||||
req.ResetMinecraftToken,
|
||||
req.PreferredLanguage,
|
||||
req.MaxPlayerCount,
|
||||
)
|
||||
@ -537,6 +586,7 @@ func (app *App) APIUpdateSelf() func(c echo.Context) error {
|
||||
req.IsAdmin,
|
||||
req.IsLocked,
|
||||
req.ResetAPIToken,
|
||||
req.ResetMinecraftToken,
|
||||
req.PreferredLanguage,
|
||||
req.MaxPlayerCount,
|
||||
)
|
||||
@ -861,6 +911,7 @@ func (app *App) APIUpdatePlayer() func(c echo.Context) error {
|
||||
// @Produce json
|
||||
// @Param uuid path string true "Player UUID"
|
||||
// @Success 204
|
||||
// @Failure 401 {object} APIError
|
||||
// @Failure 403 {object} APIError
|
||||
// @Failure 404 {object} APIError
|
||||
// @Failure 500 {object} APIError
|
||||
@ -890,6 +941,92 @@ func (app *App) APIDeletePlayer() func(c echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
type APICreateOIDCIdentityRequest struct {
|
||||
UserUUID *string `json:"userUUID" example:"f9b9af62-da83-4ec7-aeea-de48c621822c"`
|
||||
Issuer string `json:"issuer" example:"https://idm.example.com/oauth2/openid/drasl"`
|
||||
Subject string `json:"subject" example:"f85f8c18-9bdf-49ad-a76e-719f9ba3ed25"`
|
||||
}
|
||||
|
||||
// APICreateOIDCIdentity godoc
|
||||
//
|
||||
// @Summary Link an OIDC identity to a user
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200
|
||||
// @Failure 400 {object} APIError
|
||||
// @Failure 401 {object} APIError
|
||||
// @Failure 403 {object} APIError
|
||||
// @Failure 500 {object} APIError
|
||||
// @Router /drasl/api/v2/oidc-identities [post]
|
||||
func (app *App) APICreateOIDCIdentity() func(c echo.Context) error {
|
||||
return app.withAPIToken(true, func(c echo.Context, caller *User) error {
|
||||
req := new(APICreateOIDCIdentityRequest)
|
||||
if err := c.Bind(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userUUID := caller.UUID
|
||||
if req.UserUUID != nil {
|
||||
userUUID = *req.UserUUID
|
||||
}
|
||||
|
||||
oidcIdentity, err := app.CreateOIDCIdentity(caller, userUUID, req.Issuer, req.Subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiOIDCIdentity, err := app.oidcIdentityToAPIOIDCIdentity(&oidcIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, apiOIDCIdentity)
|
||||
})
|
||||
}
|
||||
|
||||
type APIDeleteOIDCIdentityRequest struct {
|
||||
UserUUID *string `json:"userUUID" example:"f9b9af62-da83-4ec7-aeea-de48c621822c"`
|
||||
Issuer string `json:"issuer" example:"https://idm.example.com/oauth2/openid/drasl"`
|
||||
}
|
||||
|
||||
// APIDeleteOIDCIdentity godoc
|
||||
//
|
||||
// @Summary Unlink an OIDC identity from a user
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 204
|
||||
// @Failure 401 {object} APIError
|
||||
// @Failure 403 {object} APIError
|
||||
// @Failure 404 {object} APIError
|
||||
// @Failure 500 {object} APIError
|
||||
// @Router /drasl/api/v2/oidc-identities [delete]
|
||||
func (app *App) APIDeleteOIDCIdentity() func(c echo.Context) error {
|
||||
return app.withAPIToken(true, func(c echo.Context, caller *User) error {
|
||||
req := new(APIDeleteOIDCIdentityRequest)
|
||||
if err := c.Bind(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userUUID := caller.UUID
|
||||
if req.UserUUID != nil {
|
||||
userUUID = *req.UserUUID
|
||||
}
|
||||
|
||||
oidcProvider, ok := app.OIDCProvidersByIssuer[req.Issuer]
|
||||
if !ok {
|
||||
return NewBadRequestUserError("Unknown OIDC provider: %s", req.Issuer)
|
||||
}
|
||||
|
||||
err := app.DeleteOIDCIdentity(caller, userUUID, oidcProvider.Config.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
|
||||
// APIGetInvites godoc
|
||||
//
|
||||
// @Summary Get invites
|
||||
@ -966,11 +1103,11 @@ func (app *App) APIDeleteInvite() func(c echo.Context) error {
|
||||
|
||||
result := app.DB.Where("code = ?", code).Delete(&Invite{})
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Unknown invite code")
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return NewUserError(http.StatusNotFound, "Unknown invite code")
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
})
|
||||
@ -1045,7 +1182,7 @@ func (app *App) APILogin() func(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformed JSON request")
|
||||
}
|
||||
|
||||
user, err := app.Login(req.Username, req.Password)
|
||||
user, err := app.AuthenticateUser(req.Username, req.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
234
api_test.go
234
api_test.go
@ -38,6 +38,8 @@ func TestAPI(t *testing.T) {
|
||||
t.Run("Test DELETE /drasl/api/vX/players/{uuid}", ts.testAPIDeletePlayer)
|
||||
t.Run("Test PATCH /drasl/api/vX/players/{uuid}", ts.testAPIUpdatePlayer)
|
||||
|
||||
t.Run("Test POST/DELETE /drasl/api/vX/oidc-identities", ts.testAPICreateDeleteOIDCIdentity)
|
||||
|
||||
t.Run("Test DELETE /drasl/api/vX/invites/{code}", ts.testAPIDeleteInvite)
|
||||
t.Run("Test GET /drasl/api/vX/invites", ts.testAPIGetInvites)
|
||||
t.Run("Test POST /drasl/api/vX/invites", ts.testAPICreateInvite)
|
||||
@ -65,14 +67,14 @@ func (ts *TestSuite) testAPIGetSelf(t *testing.T) {
|
||||
username := "user"
|
||||
user, _ := ts.CreateTestUser(t, ts.App, ts.Server, username)
|
||||
|
||||
// admin (admin) should get a response
|
||||
// admin should get a response
|
||||
rec := ts.Get(t, ts.Server, DRASL_API_PREFIX+"/user", nil, &admin.APIToken)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
var response APIUser
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, admin.UUID, response.UUID)
|
||||
|
||||
// user2 (not admin) should also get a response
|
||||
// user (not admin) should also get a response
|
||||
rec = ts.Get(t, ts.Server, DRASL_API_PREFIX+"/user", nil, &user.APIToken)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
@ -141,13 +143,13 @@ func (ts *TestSuite) testAPIDeleteUser(t *testing.T) {
|
||||
user2, _ := ts.CreateTestUser(t, ts.App, ts.Server, username2)
|
||||
|
||||
// user2 (not admin) should get a StatusForbidden
|
||||
rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/users/"+admin.UUID, nil, &user2.APIToken)
|
||||
rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/users/"+admin.UUID, nil, nil, &user2.APIToken)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
var err APIError
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&err))
|
||||
|
||||
// admin should get a response
|
||||
rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/users/"+user2.UUID, nil, &admin.APIToken)
|
||||
rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/users/"+user2.UUID, nil, nil, &admin.APIToken)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
|
||||
// user2 should no longer exist in the database
|
||||
@ -162,7 +164,7 @@ func (ts *TestSuite) testAPIDeleteSelf(t *testing.T) {
|
||||
username := "user"
|
||||
user, _ := ts.CreateTestUser(t, ts.App, ts.Server, username)
|
||||
|
||||
rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/user", nil, &user.APIToken)
|
||||
rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/user", nil, nil, &user.APIToken)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
|
||||
// user should no longer exist in the database
|
||||
@ -241,7 +243,7 @@ func (ts *TestSuite) testAPICreateUser(t *testing.T) {
|
||||
// Simple case
|
||||
payload := APICreateUserRequest{
|
||||
Username: createdUsername,
|
||||
Password: TEST_PASSWORD,
|
||||
Password: Ptr(TEST_PASSWORD),
|
||||
}
|
||||
|
||||
rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users", payload, nil, &admin.APIToken)
|
||||
@ -261,7 +263,7 @@ func (ts *TestSuite) testAPICreateUser(t *testing.T) {
|
||||
// With skin and cape
|
||||
payload := APICreateUserRequest{
|
||||
Username: createdUsername,
|
||||
Password: TEST_PASSWORD,
|
||||
Password: Ptr(TEST_PASSWORD),
|
||||
SkinBase64: Ptr(RED_SKIN_BASE64_STRING),
|
||||
CapeBase64: Ptr(RED_CAPE_BASE64_STRING),
|
||||
}
|
||||
@ -284,7 +286,7 @@ func (ts *TestSuite) testAPICreateUser(t *testing.T) {
|
||||
// Username in use as another user's player name
|
||||
payload := APICreateUserRequest{
|
||||
Username: adminPlayerName,
|
||||
Password: TEST_PASSWORD,
|
||||
Password: Ptr(TEST_PASSWORD),
|
||||
}
|
||||
|
||||
rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users", payload, nil, &admin.APIToken)
|
||||
@ -545,13 +547,13 @@ func (ts *TestSuite) testAPIDeletePlayer(t *testing.T) {
|
||||
assert.Nil(t, err)
|
||||
|
||||
// user (not admin) should get a StatusForbidden when deleting admin's player
|
||||
rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+adminPlayer.UUID, nil, &user.APIToken)
|
||||
rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+adminPlayer.UUID, nil, nil, &user.APIToken)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
var apiError APIError
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
|
||||
|
||||
// admin should get a response
|
||||
rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+adminPlayer.UUID, nil, &admin.APIToken)
|
||||
rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+adminPlayer.UUID, nil, nil, &admin.APIToken)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
|
||||
// adminPlayer should no longer exist in the database
|
||||
@ -560,7 +562,7 @@ func (ts *TestSuite) testAPIDeletePlayer(t *testing.T) {
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// user should be able to delete its own player
|
||||
rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+player.UUID, nil, &user.APIToken)
|
||||
rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+player.UUID, nil, nil, &user.APIToken)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
|
||||
// player should no longer exist in the database
|
||||
@ -568,7 +570,7 @@ func (ts *TestSuite) testAPIDeletePlayer(t *testing.T) {
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// admin should be able to delete any user's player
|
||||
rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+secondPlayer.UUID, nil, &admin.APIToken)
|
||||
rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+secondPlayer.UUID, nil, nil, &admin.APIToken)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
|
||||
// secondPlayer should no longer exist in the database
|
||||
@ -579,6 +581,205 @@ func (ts *TestSuite) testAPIDeletePlayer(t *testing.T) {
|
||||
assert.Nil(t, ts.App.DeleteUser(&GOD, user))
|
||||
}
|
||||
|
||||
func (ts *TestSuite) testAPICreateDeleteOIDCIdentity(t *testing.T) {
|
||||
adminUsername := "admin"
|
||||
admin, _ := ts.CreateTestUser(t, ts.App, ts.Server, adminUsername)
|
||||
assert.True(t, admin.IsAdmin)
|
||||
username := "user"
|
||||
user, _ := ts.CreateTestUser(t, ts.App, ts.Server, username)
|
||||
|
||||
fakeOIDCProvider1 := OIDCProvider{
|
||||
Config: RegistrationOIDCConfig{
|
||||
Name: "Fake IDP 1",
|
||||
Issuer: "https://idm.example.com/oauth2/openid/drasl1",
|
||||
},
|
||||
}
|
||||
fakeOIDCProvider2 := OIDCProvider{
|
||||
Config: RegistrationOIDCConfig{
|
||||
Name: "Fake IDP 2",
|
||||
Issuer: "https://idm.example.com/oauth2/openid/drasl2",
|
||||
},
|
||||
}
|
||||
provider1Subject1 := "11111111-1111-1111-1111-111111111111"
|
||||
provider1Subject2 := "11111111-1111-1111-1111-222222222222"
|
||||
provider1Subject3 := "11111111-1111-1111-1111-333333333333"
|
||||
|
||||
provider2Subject1 := "22222222-2222-2222-2222-111111111111"
|
||||
provider2Subject2 := "22222222-2222-2222-2222-222222222222"
|
||||
provider2Subject3 := "22222222-2222-2222-2222-333333333333"
|
||||
// Monkey-patch these until we can properly mock an OIDC IDP in the test environment...
|
||||
ts.App.OIDCProvidersByName[fakeOIDCProvider1.Config.Name] = &fakeOIDCProvider1
|
||||
ts.App.OIDCProvidersByName[fakeOIDCProvider2.Config.Name] = &fakeOIDCProvider2
|
||||
ts.App.OIDCProvidersByIssuer[fakeOIDCProvider1.Config.Issuer] = &fakeOIDCProvider1
|
||||
ts.App.OIDCProvidersByIssuer[fakeOIDCProvider2.Config.Issuer] = &fakeOIDCProvider2
|
||||
|
||||
{
|
||||
// admin should be able to create OIDC identities for themself
|
||||
payload := APICreateOIDCIdentityRequest{
|
||||
UserUUID: &admin.UUID,
|
||||
Issuer: fakeOIDCProvider1.Config.Issuer,
|
||||
Subject: provider1Subject1,
|
||||
}
|
||||
rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
var apiOIDCIdentity APIOIDCIdentity
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiOIDCIdentity))
|
||||
assert.Equal(t, provider1Subject1, apiOIDCIdentity.Subject)
|
||||
assert.Equal(t, fakeOIDCProvider1.Config.Issuer, apiOIDCIdentity.Issuer)
|
||||
|
||||
assert.Nil(t, ts.App.DB.First(&admin, "uuid = ?", admin.UUID).Error)
|
||||
assert.Equal(t, 1, len(admin.OIDCIdentities))
|
||||
assert.Equal(t, fakeOIDCProvider1.Config.Issuer, admin.OIDCIdentities[0].Issuer)
|
||||
assert.Equal(t, provider1Subject1, admin.OIDCIdentities[0].Subject)
|
||||
}
|
||||
{
|
||||
// If UserUUID is ommitted, default to the caller's UUID
|
||||
payload := APICreateOIDCIdentityRequest{
|
||||
Issuer: fakeOIDCProvider2.Config.Issuer,
|
||||
Subject: provider2Subject1,
|
||||
}
|
||||
rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
var apiOIDCIdentity APIOIDCIdentity
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiOIDCIdentity))
|
||||
assert.Equal(t, provider2Subject1, apiOIDCIdentity.Subject)
|
||||
assert.Equal(t, fakeOIDCProvider2.Config.Issuer, apiOIDCIdentity.Issuer)
|
||||
}
|
||||
{
|
||||
// admin should be able to create OIDC identities for other users
|
||||
payload := APICreateOIDCIdentityRequest{
|
||||
UserUUID: &user.UUID,
|
||||
Issuer: fakeOIDCProvider1.Config.Issuer,
|
||||
Subject: provider1Subject2,
|
||||
}
|
||||
rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
var apiOIDCIdentity APIOIDCIdentity
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiOIDCIdentity))
|
||||
assert.Equal(t, provider1Subject2, apiOIDCIdentity.Subject)
|
||||
assert.Equal(t, fakeOIDCProvider1.Config.Issuer, apiOIDCIdentity.Issuer)
|
||||
}
|
||||
{
|
||||
// Duplicate issuer and subject should fail
|
||||
payload := APICreateOIDCIdentityRequest{
|
||||
UserUUID: &admin.UUID,
|
||||
Issuer: fakeOIDCProvider1.Config.Issuer,
|
||||
Subject: provider1Subject1,
|
||||
}
|
||||
rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
var apiError APIError
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
|
||||
assert.Equal(t, "That Fake IDP 1 account is already linked to another user.", apiError.Message)
|
||||
}
|
||||
{
|
||||
// Duplicate issuer on the same user should fail
|
||||
payload := APICreateOIDCIdentityRequest{
|
||||
UserUUID: &admin.UUID,
|
||||
Issuer: fakeOIDCProvider1.Config.Issuer,
|
||||
Subject: provider1Subject3,
|
||||
}
|
||||
rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
var apiError APIError
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
|
||||
assert.Equal(t, "That user is already linked to a Fake IDP 1 account.", apiError.Message)
|
||||
}
|
||||
{
|
||||
// Non-admin should not be able to link an OIDC identity for another user
|
||||
payload := APICreateOIDCIdentityRequest{
|
||||
UserUUID: &admin.UUID,
|
||||
Issuer: fakeOIDCProvider2.Config.Issuer,
|
||||
Subject: provider2Subject3,
|
||||
}
|
||||
rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
var apiError APIError
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
|
||||
assert.Equal(t, "Can't link an OIDC account for another user unless you're an admin.", apiError.Message)
|
||||
}
|
||||
{
|
||||
// Non-admin should be able to link an OIDC identity for themself
|
||||
payload := APICreateOIDCIdentityRequest{
|
||||
UserUUID: &user.UUID,
|
||||
Issuer: fakeOIDCProvider2.Config.Issuer,
|
||||
Subject: provider2Subject2,
|
||||
}
|
||||
rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
var apiOIDCIdentity APIOIDCIdentity
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiOIDCIdentity))
|
||||
assert.Equal(t, provider2Subject2, apiOIDCIdentity.Subject)
|
||||
assert.Equal(t, fakeOIDCProvider2.Config.Issuer, apiOIDCIdentity.Issuer)
|
||||
}
|
||||
{
|
||||
// admin should be able to delete OIDC identity for other users
|
||||
payload := APIDeleteOIDCIdentityRequest{
|
||||
UserUUID: &user.UUID,
|
||||
Issuer: fakeOIDCProvider1.Config.Issuer,
|
||||
}
|
||||
rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
}
|
||||
{
|
||||
// Add the identity back for future tests...
|
||||
payload := APICreateOIDCIdentityRequest{
|
||||
UserUUID: &user.UUID,
|
||||
Issuer: fakeOIDCProvider1.Config.Issuer,
|
||||
Subject: provider1Subject2,
|
||||
}
|
||||
rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
{
|
||||
// Non-admin user should not be able to delete OIDC identity for other users
|
||||
payload := APIDeleteOIDCIdentityRequest{
|
||||
UserUUID: &admin.UUID,
|
||||
Issuer: fakeOIDCProvider1.Config.Issuer,
|
||||
}
|
||||
rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
var apiError APIError
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
|
||||
assert.Equal(t, "Can't unlink an OIDC account for another user unless you're an admin.", apiError.Message)
|
||||
}
|
||||
{
|
||||
// Non-admin user should be able to delete OIDC identity for themself
|
||||
payload := APIDeleteOIDCIdentityRequest{
|
||||
UserUUID: &user.UUID,
|
||||
Issuer: fakeOIDCProvider2.Config.Issuer,
|
||||
}
|
||||
rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
}
|
||||
{
|
||||
// Can't delete nonexistent OIDC identity
|
||||
payload := APIDeleteOIDCIdentityRequest{
|
||||
UserUUID: &user.UUID,
|
||||
Issuer: fakeOIDCProvider2.Config.Issuer,
|
||||
}
|
||||
rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
var apiError APIError
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
|
||||
assert.Equal(t, "No linked Fake IDP 2 account found.", apiError.Message)
|
||||
}
|
||||
{
|
||||
// Can't delete last OIDC identity
|
||||
payload := APIDeleteOIDCIdentityRequest{
|
||||
UserUUID: &user.UUID,
|
||||
Issuer: fakeOIDCProvider1.Config.Issuer,
|
||||
}
|
||||
rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
var apiError APIError
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
|
||||
assert.Equal(t, "Can't remove the last linked OIDC account.", apiError.Message)
|
||||
}
|
||||
assert.Nil(t, ts.App.DeleteUser(&GOD, admin))
|
||||
assert.Nil(t, ts.App.DeleteUser(&GOD, user))
|
||||
}
|
||||
|
||||
func (ts *TestSuite) testAPIGetInvites(t *testing.T) {
|
||||
username1 := "admin"
|
||||
admin, _ := ts.CreateTestUser(t, ts.App, ts.Server, username1)
|
||||
@ -628,13 +829,13 @@ func (ts *TestSuite) testAPIDeleteInvite(t *testing.T) {
|
||||
assert.Nil(t, err)
|
||||
|
||||
// user (not admin) should get a StatusForbidden
|
||||
rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/invites/"+invite.Code, nil, &user.APIToken)
|
||||
rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/invites/"+invite.Code, nil, nil, &user.APIToken)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
var apiError APIError
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
|
||||
|
||||
// admin should get a response
|
||||
rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/invites/"+invite.Code, nil, &admin.APIToken)
|
||||
rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/invites/"+invite.Code, nil, nil, &admin.APIToken)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
|
||||
// invite should no longer exist in the database
|
||||
@ -642,6 +843,11 @@ func (ts *TestSuite) testAPIDeleteInvite(t *testing.T) {
|
||||
assert.Nil(t, ts.App.DB.Model(&Invite{}).Where("code = ?", invite.Code).Count(&count).Error)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// should not be able to delete the same invite twice
|
||||
rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/invites/"+invite.Code, nil, nil, &admin.APIToken)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
|
||||
|
||||
assert.Nil(t, ts.App.DeleteUser(&GOD, admin))
|
||||
assert.Nil(t, ts.App.DeleteUser(&GOD, user))
|
||||
}
|
||||
|
134
auth.go
134
auth.go
@ -39,18 +39,23 @@ type UserResponse struct {
|
||||
Properties []UserProperty `json:"properties"`
|
||||
}
|
||||
|
||||
var invalidCredentialsBlob []byte = Unwrap(json.Marshal(ErrorResponse{
|
||||
Error: Ptr("ForbiddenOperationException"),
|
||||
ErrorMessage: Ptr("Invalid credentials. Invalid username or password."),
|
||||
}))
|
||||
var invalidAccessTokenBlob []byte = Unwrap(json.Marshal(ErrorResponse{
|
||||
Error: Ptr("ForbiddenOperationException"),
|
||||
ErrorMessage: Ptr("Invalid token."),
|
||||
}))
|
||||
var playerNotFoundBlob []byte = Unwrap(json.Marshal(ErrorResponse{
|
||||
Error: Ptr("IllegalArgumentException"),
|
||||
ErrorMessage: Ptr("Player not found."),
|
||||
}))
|
||||
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"`
|
||||
@ -94,6 +99,57 @@ type authenticateResponse struct {
|
||||
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(app.OIDCProvidersByName) > 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 {
|
||||
@ -103,41 +159,11 @@ func AuthAuthenticate(app *App) func(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
usernameOrPlayerName := req.Username
|
||||
|
||||
var user User
|
||||
player := mo.None[Player]()
|
||||
|
||||
if err := app.DB.First(&user, "username = ?", usernameOrPlayerName).Error; err == nil {
|
||||
if len(user.Players) == 1 {
|
||||
player = mo.Some(user.Players[0])
|
||||
}
|
||||
} else {
|
||||
var playerStruct Player
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if err := app.DB.Preload("User").First(&playerStruct, "name = ?", usernameOrPlayerName).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.JSONBlob(http.StatusUnauthorized, invalidCredentialsBlob)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
player = mo.Some(playerStruct)
|
||||
user = playerStruct.User
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
passwordHash, err := HashPassword(req.Password, user.PasswordSalt)
|
||||
user, player, err := app.AuthAuthenticateUser(c, req.Username, req.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !bytes.Equal(passwordHash, user.PasswordHash) {
|
||||
return c.JSONBlob(http.StatusUnauthorized, invalidCredentialsBlob)
|
||||
}
|
||||
|
||||
playerUUID := mo.None[string]()
|
||||
if p, ok := player.Get(); ok {
|
||||
playerUUID = mo.Some(p.UUID)
|
||||
@ -199,7 +225,7 @@ func AuthAuthenticate(app *App) func(c echo.Context) error {
|
||||
Name: p.Name,
|
||||
}
|
||||
}
|
||||
availableProfilesArray, err := getAvailableProfiles(&user)
|
||||
availableProfilesArray, err := getAvailableProfiles(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -267,7 +293,7 @@ func AuthRefresh(app *App) func(c echo.Context) error {
|
||||
|
||||
client := app.GetClient(req.AccessToken, StalePolicyAllow)
|
||||
if client == nil || client.ClientToken != req.ClientToken {
|
||||
return c.JSONBlob(http.StatusUnauthorized, invalidAccessTokenBlob)
|
||||
return invalidAccessTokenError
|
||||
}
|
||||
user := client.User
|
||||
player := client.Player
|
||||
@ -288,7 +314,7 @@ func AuthRefresh(app *App) func(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
if player == nil {
|
||||
return c.JSONBlob(http.StatusBadRequest, playerNotFoundBlob)
|
||||
return playerNotFoundError
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -379,22 +405,12 @@ func AuthSignout(app *App) func(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
var user User
|
||||
result := app.DB.First(&user, "username = ?", req.Username)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
passwordHash, err := HashPassword(req.Password, user.PasswordSalt)
|
||||
user, _, err := app.AuthAuthenticateUser(c, req.Username, req.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !bytes.Equal(passwordHash, user.PasswordHash) {
|
||||
return c.JSONBlob(http.StatusUnauthorized, invalidCredentialsBlob)
|
||||
}
|
||||
|
||||
err = app.InvalidateUser(app.DB, &user)
|
||||
err = app.InvalidateUser(app.DB, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -419,7 +435,7 @@ func AuthInvalidate(app *App) func(c echo.Context) error {
|
||||
|
||||
client := app.GetClient(req.AccessToken, StalePolicyAllow)
|
||||
if client == nil {
|
||||
return c.JSONBlob(http.StatusUnauthorized, invalidAccessTokenBlob)
|
||||
return invalidAccessTokenError
|
||||
}
|
||||
|
||||
if client.Player == nil {
|
||||
|
77
auth_test.go
77
auth_test.go
@ -76,6 +76,22 @@ func (ts *TestSuite) testAuthenticate(t *testing.T) {
|
||||
// We did not pass requestUser
|
||||
assert.Nil(t, response.User)
|
||||
}
|
||||
{
|
||||
// Authentication should succeed if we use the player's Minecraft token
|
||||
// as the password
|
||||
|
||||
var user User
|
||||
assert.Nil(t, ts.App.DB.First(&user, "username = ?", TEST_PLAYER_NAME).Error)
|
||||
|
||||
response := ts.authenticate(t, TEST_PLAYER_NAME, user.MinecraftToken)
|
||||
|
||||
// We did not pass an agent
|
||||
assert.Nil(t, response.SelectedProfile)
|
||||
assert.Nil(t, response.AvailableProfiles)
|
||||
|
||||
// We did not pass requestUser
|
||||
assert.Nil(t, response.User)
|
||||
}
|
||||
{
|
||||
// If we send our own clientToken, the server should use it
|
||||
clientToken := "12345678901234567890123456789012"
|
||||
@ -161,7 +177,7 @@ func (ts *TestSuite) testAuthenticate(t *testing.T) {
|
||||
rec := ts.PostJSON(t, ts.Server, "/authenticate", payload, nil, nil)
|
||||
|
||||
// Authentication should fail
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, "ForbiddenOperationException", *response.Error)
|
||||
assert.Equal(t, "Invalid credentials. Invalid username or password.", *response.ErrorMessage)
|
||||
@ -225,15 +241,28 @@ func (ts *TestSuite) testAuthenticate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func findProfile(profiles []Profile, playerName string) mo.Option[Profile] {
|
||||
for _, profile := range profiles {
|
||||
if profile.Name == playerName {
|
||||
return mo.Some(profile)
|
||||
}
|
||||
}
|
||||
return mo.None[Profile]()
|
||||
}
|
||||
|
||||
func (ts *TestSuite) testAuthenticateMultipleProfiles(t *testing.T) {
|
||||
{
|
||||
var user User
|
||||
assert.Nil(t, ts.App.DB.First(&user, "username = ?", TEST_USERNAME).Error)
|
||||
|
||||
// Set up two players on the test account, each distrinct from TEST_USERNAME
|
||||
firstPlayerName := "FirstPlayer"
|
||||
secondPlayerName := "SecondPlayer"
|
||||
|
||||
// player := user.Players[0]
|
||||
otherPlayer, err := ts.App.CreatePlayer(&GOD, user.UUID, secondPlayerName, nil, false, nil, nil, nil, nil, nil, nil, nil)
|
||||
_, err := ts.App.UpdatePlayer(&GOD, user.Players[0], &firstPlayerName, nil, nil, nil, nil, false, nil, nil, false)
|
||||
assert.Nil(t, err)
|
||||
|
||||
secondPlayer, err := ts.App.CreatePlayer(&GOD, user.UUID, secondPlayerName, nil, false, nil, nil, nil, nil, nil, nil, nil)
|
||||
assert.Nil(t, err)
|
||||
|
||||
authenticatePayload := authenticateRequest{
|
||||
@ -259,14 +288,7 @@ func (ts *TestSuite) testAuthenticateMultipleProfiles(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 2, len(*authenticateRes.AvailableProfiles))
|
||||
|
||||
p := mo.None[Profile]()
|
||||
for _, availableProfile := range *authenticateRes.AvailableProfiles {
|
||||
if availableProfile.Name == secondPlayerName {
|
||||
p = mo.Some(availableProfile)
|
||||
break
|
||||
}
|
||||
}
|
||||
profile, ok := p.Get()
|
||||
profile, ok := findProfile(*authenticateRes.AvailableProfiles, secondPlayerName).Get()
|
||||
assert.True(t, ok)
|
||||
|
||||
// Now, refresh to select a profile
|
||||
@ -287,7 +309,22 @@ func (ts *TestSuite) testAuthenticateMultipleProfiles(t *testing.T) {
|
||||
|
||||
assert.Equal(t, profile, *refreshRes.SelectedProfile)
|
||||
|
||||
assert.Nil(t, ts.App.DeletePlayer(&GOD, &otherPlayer))
|
||||
// When the username matches one of the available player names, that
|
||||
// player should automatically become the selectedProfile.
|
||||
_, err = ts.App.UpdatePlayer(&GOD, user.Players[0], Ptr(TEST_USERNAME), nil, nil, nil, nil, false, nil, nil, false)
|
||||
assert.Nil(t, err)
|
||||
|
||||
rec = ts.PostJSON(t, ts.Server, "/authenticate", authenticatePayload, nil, nil)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&authenticateRes))
|
||||
|
||||
usernameProfile, ok := findProfile(*authenticateRes.AvailableProfiles, TEST_USERNAME).Get()
|
||||
assert.True(t, ok)
|
||||
|
||||
assert.Equal(t, usernameProfile, *authenticateRes.SelectedProfile)
|
||||
|
||||
assert.Nil(t, ts.App.DeletePlayer(&GOD, &secondPlayer))
|
||||
}
|
||||
}
|
||||
|
||||
@ -341,11 +378,11 @@ func (ts *TestSuite) testInvalidate(t *testing.T) {
|
||||
rec := ts.PostJSON(t, ts.Server, "/invalidate", payload, nil, nil)
|
||||
|
||||
// Invalidate should fail
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
assert.Equal(t, "ForbiddenOperationException", *response.Error)
|
||||
assert.Equal(t, "Invalid token.", *response.ErrorMessage)
|
||||
assert.Equal(t, "Invalid token", *response.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@ -412,7 +449,7 @@ func (ts *TestSuite) testRefresh(t *testing.T) {
|
||||
|
||||
expectedUser := UserResponse{
|
||||
ID: Unwrap(UUIDToID(player.UUID)),
|
||||
Properties: []UserProperty{UserProperty{
|
||||
Properties: []UserProperty{{
|
||||
Name: "preferredLanguage",
|
||||
Value: player.User.PreferredLanguage,
|
||||
}},
|
||||
@ -431,7 +468,7 @@ func (ts *TestSuite) testRefresh(t *testing.T) {
|
||||
rec := ts.PostJSON(t, ts.Server, "/refresh", payload, nil, nil)
|
||||
|
||||
// Refresh should fail
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, "ForbiddenOperationException", *response.Error)
|
||||
}
|
||||
@ -445,10 +482,10 @@ func (ts *TestSuite) testRefresh(t *testing.T) {
|
||||
rec := ts.PostJSON(t, ts.Server, "/refresh", payload, nil, nil)
|
||||
|
||||
// Refresh should fail
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, "ForbiddenOperationException", *response.Error)
|
||||
assert.Equal(t, "Invalid token.", *response.ErrorMessage)
|
||||
assert.Equal(t, "Invalid token", *response.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@ -503,7 +540,7 @@ func (ts *TestSuite) testSignout(t *testing.T) {
|
||||
rec := ts.PostJSON(t, ts.Server, "/signout", payload, nil, nil)
|
||||
|
||||
// Signout should fail
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
assert.Equal(t, "ForbiddenOperationException", *response.Error)
|
||||
|
@ -5,9 +5,9 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/samber/mo"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@ -56,7 +56,7 @@ func AuthlibInjectorRoot(app *App) func(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
signaturePublicKey, err := authlibInjectorSerializeKey(&app.Key.PublicKey)
|
||||
signaturePublicKey, err := authlibInjectorSerializeKey(&app.PrivateKey.PublicKey)
|
||||
Check(err)
|
||||
|
||||
signaturePublicKeys := make([]string, 0, len(app.ProfilePropertyKeys))
|
||||
@ -92,12 +92,12 @@ func (app *App) AuthlibInjectorUploadTexture(textureType string) func(c echo.Con
|
||||
playerID := c.Param("id")
|
||||
playerUUID, err := IDToUUID(playerID)
|
||||
if err != nil {
|
||||
return MakeErrorResponse(&c, http.StatusBadRequest, nil, Ptr("Invalid UUID format"))
|
||||
return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("Invalid UUID format")}
|
||||
}
|
||||
|
||||
textureFile, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return MakeErrorResponse(&c, http.StatusBadRequest, nil, Ptr("Missing texture file"))
|
||||
return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("Missing texture file")}
|
||||
}
|
||||
textureHandle, err := textureFile.Open()
|
||||
if err != nil {
|
||||
@ -109,10 +109,9 @@ func (app *App) AuthlibInjectorUploadTexture(textureType string) func(c echo.Con
|
||||
var targetPlayer Player
|
||||
result := app.DB.Preload("User").First(&targetPlayer, "uuid = ?", playerUUID)
|
||||
if result.Error != nil {
|
||||
return MakeErrorResponse(&c, http.StatusNotFound, nil, Ptr("Player not found"))
|
||||
return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("Player not found")}
|
||||
}
|
||||
|
||||
var updatePlayerErr error
|
||||
switch textureType {
|
||||
case TextureTypeSkin:
|
||||
var model string
|
||||
@ -123,9 +122,9 @@ func (app *App) AuthlibInjectorUploadTexture(textureType string) func(c echo.Con
|
||||
model = SkinModelClassic
|
||||
default:
|
||||
message := fmt.Sprintf("Unknown model: %s", m)
|
||||
return MakeErrorResponse(&c, http.StatusBadRequest, nil, &message)
|
||||
return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some(message)}
|
||||
}
|
||||
_, updatePlayerErr = app.UpdatePlayer(
|
||||
_, err = app.UpdatePlayer(
|
||||
caller,
|
||||
targetPlayer,
|
||||
nil, // playerName
|
||||
@ -138,8 +137,11 @@ func (app *App) AuthlibInjectorUploadTexture(textureType string) func(c echo.Con
|
||||
nil, // capeURL
|
||||
false, // deleteCape
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case TextureTypeCape:
|
||||
_, updatePlayerErr = app.UpdatePlayer(
|
||||
_, err = app.UpdatePlayer(
|
||||
caller,
|
||||
targetPlayer,
|
||||
nil, // playerName
|
||||
@ -152,15 +154,10 @@ func (app *App) AuthlibInjectorUploadTexture(textureType string) func(c echo.Con
|
||||
nil, // capeURL
|
||||
false, // deleteCape
|
||||
)
|
||||
}
|
||||
if updatePlayerErr != nil {
|
||||
var userError *UserError
|
||||
if errors.As(updatePlayerErr, &userError) {
|
||||
return MakeErrorResponse(&c, userError.Code, nil, Ptr(userError.Err.Error()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
@ -170,13 +167,13 @@ func (app *App) AuthlibInjectorDeleteTexture(textureType string) func(c echo.Con
|
||||
playerID := c.Param("id")
|
||||
playerUUID, err := IDToUUID(playerID)
|
||||
if err != nil {
|
||||
return MakeErrorResponse(&c, http.StatusBadRequest, nil, Ptr("Invalid player UUID"))
|
||||
return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("Invalid player UUID")}
|
||||
}
|
||||
|
||||
var targetPlayer Player
|
||||
result := app.DB.Preload("User").First(&targetPlayer, "uuid = ?", playerUUID)
|
||||
if result.Error != nil {
|
||||
return MakeErrorResponse(&c, http.StatusNotFound, nil, Ptr("Player not found"))
|
||||
return &YggdrasilError{Code: http.StatusNotFound, ErrorMessage: mo.Some("Player not found")}
|
||||
}
|
||||
|
||||
_, err = app.UpdatePlayer(
|
||||
|
@ -137,19 +137,15 @@ func (ts *TestSuite) testAuthlibInjectorTextureUploadDelete(t *testing.T) {
|
||||
}
|
||||
{
|
||||
// Successful skin delete
|
||||
rec := ts.Delete(t, ts.Server, "/authlib-injector/api/user/profile/"+playerID+"/skin", nil, &accessToken)
|
||||
rec := ts.Delete(t, ts.Server, "/authlib-injector/api/user/profile/"+playerID+"/skin", nil, nil, &accessToken)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
|
||||
assert.Nil(t, ts.App.DB.First(&player, "name = ?", TEST_USERNAME).Error)
|
||||
assert.Nil(t, UnmakeNullString(&player.SkinHash))
|
||||
|
||||
// Delete should be idempotent
|
||||
rec = ts.Delete(t, ts.Server, "/authlib-injector/api/user/profile/"+playerID+"/skin", nil, &accessToken)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
}
|
||||
{
|
||||
// Successful cape delete
|
||||
rec := ts.Delete(t, ts.Server, "/authlib-injector/api/user/profile/"+playerID+"/cape", nil, &accessToken)
|
||||
rec := ts.Delete(t, ts.Server, "/authlib-injector/api/user/profile/"+playerID+"/cape", nil, nil, &accessToken)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
|
||||
assert.Nil(t, ts.App.DB.First(&player, "name = ?", TEST_USERNAME).Error)
|
||||
|
122
common.go
122
common.go
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
@ -10,11 +11,13 @@ import (
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/samber/mo"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"image/png"
|
||||
"io"
|
||||
"log"
|
||||
"lukechampine.com/blake3"
|
||||
"math/rand"
|
||||
mathRand "math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@ -25,34 +28,76 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func (app *App) AEADEncrypt(plaintext []byte) ([]byte, error) {
|
||||
nonceSize := app.AEAD.NonceSize()
|
||||
|
||||
nonce := make([]byte, nonceSize)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := app.AEAD.Seal(nil, nonce, plaintext, nil)
|
||||
return append(nonce, ciphertext...), nil
|
||||
}
|
||||
|
||||
func (app *App) AEADDecrypt(ciphertext []byte) ([]byte, error) {
|
||||
nonceSize := app.AEAD.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce := ciphertext[0:nonceSize]
|
||||
message := ciphertext[nonceSize:]
|
||||
return app.AEAD.Open(nil, nonce, message, nil)
|
||||
}
|
||||
|
||||
func (app *App) EncryptCookieValue(plaintext string) (string, error) {
|
||||
ciphertext, err := app.AEADEncrypt([]byte(plaintext))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
func (app *App) DecryptCookieValue(armored string) ([]byte, error) {
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(armored)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return app.AEADDecrypt(ciphertext)
|
||||
}
|
||||
|
||||
type OIDCProvider struct {
|
||||
Config RegistrationOIDCConfig
|
||||
RelyingParty rp.RelyingParty
|
||||
}
|
||||
|
||||
type UserError struct {
|
||||
Code int
|
||||
Code mo.Option[int]
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e UserError) Error() string {
|
||||
func (e *UserError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func NewUserError(code int, message string, args ...interface{}) error {
|
||||
return &UserError{
|
||||
Code: code,
|
||||
Code: mo.Some(code),
|
||||
Err: fmt.Errorf(message, args...),
|
||||
}
|
||||
}
|
||||
|
||||
func NewBadRequestUserError(message string, args ...interface{}) error {
|
||||
return &UserError{
|
||||
Code: http.StatusBadRequest,
|
||||
Code: mo.Some(http.StatusBadRequest),
|
||||
Err: fmt.Errorf(message, args...),
|
||||
}
|
||||
}
|
||||
|
||||
func NewForbiddenUserError(message string, args ...interface{}) error {
|
||||
return &UserError{
|
||||
Code: http.StatusForbidden,
|
||||
Err: fmt.Errorf(message, args...),
|
||||
}
|
||||
var InternalServerError error = &UserError{
|
||||
Code: mo.Some(http.StatusInternalServerError),
|
||||
Err: errors.New("Internal server error"),
|
||||
}
|
||||
|
||||
type ConstantsType struct {
|
||||
@ -187,24 +232,22 @@ type Agent struct {
|
||||
Version uint `json:"version"`
|
||||
}
|
||||
|
||||
var DEFAULT_ERROR_BLOB []byte = Unwrap(json.Marshal(ErrorResponse{
|
||||
ErrorMessage: Ptr("internal server error"),
|
||||
}))
|
||||
type YggdrasilError struct {
|
||||
Code int
|
||||
Error_ mo.Option[string]
|
||||
ErrorMessage mo.Option[string]
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
func (e *YggdrasilError) Error() string {
|
||||
return e.ErrorMessage.OrElse(e.Error_.OrElse("internal server error"))
|
||||
}
|
||||
|
||||
type YggdrasilErrorResponse struct {
|
||||
Path *string `json:"path,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
ErrorMessage *string `json:"errorMessage,omitempty"`
|
||||
}
|
||||
|
||||
func MakeErrorResponse(c *echo.Context, code int, error_ *string, errorMessage *string) error {
|
||||
return (*c).JSON(code, ErrorResponse{
|
||||
Path: Ptr((*c).Request().URL.Path),
|
||||
Error: error_,
|
||||
ErrorMessage: errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
type PathType int
|
||||
|
||||
const (
|
||||
@ -230,18 +273,27 @@ func GetPathType(path_ string) PathType {
|
||||
}
|
||||
|
||||
func (app *App) HandleYggdrasilError(err error, c *echo.Context) error {
|
||||
if httpError, ok := err.(*echo.HTTPError); ok {
|
||||
path_ := (*c).Request().URL.Path
|
||||
var yggdrasilError *YggdrasilError
|
||||
if errors.As(err, &yggdrasilError) {
|
||||
return (*c).JSON(yggdrasilError.Code, YggdrasilErrorResponse{
|
||||
Path: &path_,
|
||||
Error: yggdrasilError.Error_.ToPointer(),
|
||||
ErrorMessage: yggdrasilError.ErrorMessage.ToPointer(),
|
||||
})
|
||||
}
|
||||
var httpError *echo.HTTPError
|
||||
if errors.As(err, &httpError) {
|
||||
switch httpError.Code {
|
||||
case http.StatusNotFound,
|
||||
http.StatusRequestEntityTooLarge,
|
||||
http.StatusTooManyRequests,
|
||||
http.StatusMethodNotAllowed:
|
||||
path_ := (*c).Request().URL.Path
|
||||
return (*c).JSON(httpError.Code, ErrorResponse{Path: &path_})
|
||||
return (*c).JSON(httpError.Code, YggdrasilErrorResponse{Path: &path_})
|
||||
}
|
||||
}
|
||||
app.LogError(err, c)
|
||||
return (*c).JSON(http.StatusInternalServerError, ErrorResponse{ErrorMessage: Ptr("internal server error")})
|
||||
return (*c).JSON(http.StatusInternalServerError, YggdrasilErrorResponse{Path: &path_, ErrorMessage: Ptr("internal server error")})
|
||||
|
||||
}
|
||||
|
||||
@ -521,7 +573,7 @@ func (app *App) DeleteCapeIfUnused(hash *string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func StripQueryParam(urlString string, param string) (string, error) {
|
||||
func UnsetQueryParam(urlString string, param string) (string, error) {
|
||||
parsedURL, err := url.Parse(urlString)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@ -535,6 +587,20 @@ func StripQueryParam(urlString string, param string) (string, error) {
|
||||
return parsedURL.String(), nil
|
||||
}
|
||||
|
||||
func SetQueryParam(urlString string, param string, value string) (string, error) {
|
||||
parsedURL, err := url.Parse(urlString)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
query := parsedURL.Query()
|
||||
query.Set(param, value)
|
||||
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
|
||||
return parsedURL.String(), nil
|
||||
}
|
||||
|
||||
func (app *App) CreateInvite() (Invite, error) {
|
||||
code, err := RandomBase62(8)
|
||||
if err != nil {
|
||||
@ -705,7 +771,7 @@ func (app *App) ChooseFileForUser(player *Player, glob string) (*string, error)
|
||||
}
|
||||
|
||||
seed := int64(binary.BigEndian.Uint64(userUUID[8:]))
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
r := mathRand.New(mathRand.NewSource(seed))
|
||||
|
||||
fileIndex := r.Intn(len(filenames))
|
||||
|
||||
|
40
common_test.go
Normal file
40
common_test.go
Normal file
@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommon(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ts := &TestSuite{}
|
||||
|
||||
config := testConfig()
|
||||
ts.Setup(config)
|
||||
defer ts.Teardown()
|
||||
|
||||
t.Run("AEAD encrypt/decrypt", ts.testAEADEncryptDecrypt)
|
||||
t.Run("Encrypt/decrypt cookie value", ts.testEncryptDecryptCookieValue)
|
||||
|
||||
}
|
||||
|
||||
func (ts *TestSuite) testAEADEncryptDecrypt(t *testing.T) {
|
||||
plaintext := []byte("I am a cookie value")
|
||||
ciphertext, err := ts.App.AEADEncrypt(plaintext)
|
||||
assert.Nil(t, err)
|
||||
|
||||
decrypted, err := ts.App.AEADDecrypt(ciphertext)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
}
|
||||
|
||||
func (ts *TestSuite) testEncryptDecryptCookieValue(t *testing.T) {
|
||||
plaintext := "I am a cookie value"
|
||||
ciphertext, err := ts.App.EncryptCookieValue(plaintext)
|
||||
assert.Nil(t, err)
|
||||
|
||||
decrypted, err := ts.App.DecryptCookieValue(ciphertext)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, plaintext, string(decrypted))
|
||||
}
|
190
config.go
190
config.go
@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/BurntSushi/toml"
|
||||
mapset "github.com/deckarep/golang-set/v2"
|
||||
"github.com/dgraph-io/ristretto"
|
||||
"log"
|
||||
"net/url"
|
||||
@ -36,32 +37,65 @@ type FallbackAPIServer struct {
|
||||
DenyUnknownUsers bool
|
||||
}
|
||||
|
||||
type RegistrationOIDCConfig struct {
|
||||
Name string
|
||||
Issuer string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
PKCE bool
|
||||
RequireInvite bool
|
||||
AllowChoosingPlayerName bool
|
||||
}
|
||||
|
||||
type transientUsersConfig struct {
|
||||
Allow bool
|
||||
UsernameRegex string
|
||||
Password string
|
||||
}
|
||||
|
||||
type registrationNewPlayerConfig struct {
|
||||
Allow bool
|
||||
type v2RegistrationNewPlayerConfig struct {
|
||||
AllowChoosingUUID bool
|
||||
RequireInvite bool
|
||||
}
|
||||
|
||||
type registrationNewPlayerConfig struct {
|
||||
v2RegistrationNewPlayerConfig
|
||||
Allow bool
|
||||
RequireInvite bool
|
||||
}
|
||||
|
||||
type v2RegistrationExistingPlayerConfig struct {
|
||||
Nickname string
|
||||
SessionURL string
|
||||
AccountURL string
|
||||
SetSkinURL string
|
||||
RequireSkinVerification bool
|
||||
}
|
||||
|
||||
type registrationExistingPlayerConfig struct {
|
||||
v2RegistrationExistingPlayerConfig
|
||||
Allow bool
|
||||
RequireInvite bool
|
||||
}
|
||||
|
||||
type createNewPlayerConfig struct {
|
||||
Allow bool
|
||||
AllowChoosingUUID bool
|
||||
}
|
||||
|
||||
type importExistingPlayerConfig struct {
|
||||
Allow bool
|
||||
Nickname string
|
||||
SessionURL string
|
||||
AccountURL string
|
||||
SetSkinURL string
|
||||
RequireSkinVerification bool
|
||||
RequireInvite bool
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AllowCapes bool
|
||||
AllowChangingPlayerName bool
|
||||
AllowMultipleAccessTokens bool
|
||||
AllowPasswordLogin bool
|
||||
AllowSkins bool
|
||||
AllowTextureFromURL bool
|
||||
ApplicationOwner string
|
||||
@ -69,6 +103,7 @@ type Config struct {
|
||||
BaseURL string
|
||||
BodyLimit bodyLimitConfig
|
||||
CORSAllowOrigins []string
|
||||
CreateNewPlayer createNewPlayerConfig
|
||||
DataDirectory string
|
||||
DefaultAdmins []string
|
||||
DefaultPreferredLanguage string
|
||||
@ -80,9 +115,11 @@ type Config struct {
|
||||
FallbackAPIServers []FallbackAPIServer
|
||||
ForwardSkins bool
|
||||
InstanceName string
|
||||
ImportExistingPlayer importExistingPlayerConfig
|
||||
ListenAddress string
|
||||
LogRequests bool
|
||||
MinPasswordLength int
|
||||
RegistrationOIDC []RegistrationOIDCConfig
|
||||
PreMigrationBackups bool
|
||||
RateLimit rateLimitConfig
|
||||
RegistrationExistingPlayer registrationExistingPlayerConfig
|
||||
@ -110,15 +147,20 @@ var defaultBodyLimitConfig = bodyLimitConfig{
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
AllowCapes: true,
|
||||
AllowChangingPlayerName: true,
|
||||
AllowSkins: true,
|
||||
AllowTextureFromURL: false,
|
||||
ApplicationName: "Drasl",
|
||||
ApplicationOwner: "Anonymous",
|
||||
BaseURL: "",
|
||||
BodyLimit: defaultBodyLimitConfig,
|
||||
CORSAllowOrigins: []string{},
|
||||
AllowCapes: true,
|
||||
AllowChangingPlayerName: true,
|
||||
AllowPasswordLogin: true,
|
||||
AllowSkins: true,
|
||||
AllowTextureFromURL: false,
|
||||
ApplicationName: "Drasl",
|
||||
ApplicationOwner: "Anonymous",
|
||||
BaseURL: "",
|
||||
BodyLimit: defaultBodyLimitConfig,
|
||||
CORSAllowOrigins: []string{},
|
||||
CreateNewPlayer: createNewPlayerConfig{
|
||||
Allow: true,
|
||||
AllowChoosingUUID: false,
|
||||
},
|
||||
DataDirectory: GetDefaultDataDirectory(),
|
||||
DefaultAdmins: []string{},
|
||||
DefaultPreferredLanguage: "en",
|
||||
@ -128,20 +170,23 @@ func DefaultConfig() Config {
|
||||
EnableFooter: true,
|
||||
EnableWebFrontEnd: true,
|
||||
ForwardSkins: true,
|
||||
InstanceName: "Drasl",
|
||||
ListenAddress: "0.0.0.0:25585",
|
||||
LogRequests: true,
|
||||
MinPasswordLength: 8,
|
||||
OfflineSkins: true,
|
||||
PreMigrationBackups: true,
|
||||
RateLimit: defaultRateLimitConfig,
|
||||
ImportExistingPlayer: importExistingPlayerConfig{
|
||||
Allow: false,
|
||||
},
|
||||
InstanceName: "Drasl",
|
||||
ListenAddress: "0.0.0.0:25585",
|
||||
LogRequests: true,
|
||||
MinPasswordLength: 8,
|
||||
RegistrationOIDC: []RegistrationOIDCConfig{},
|
||||
OfflineSkins: true,
|
||||
PreMigrationBackups: true,
|
||||
RateLimit: defaultRateLimitConfig,
|
||||
RegistrationExistingPlayer: registrationExistingPlayerConfig{
|
||||
Allow: false,
|
||||
},
|
||||
RegistrationNewPlayer: registrationNewPlayerConfig{
|
||||
Allow: true,
|
||||
AllowChoosingUUID: false,
|
||||
RequireInvite: false,
|
||||
Allow: true,
|
||||
RequireInvite: false,
|
||||
},
|
||||
RequestCache: ristretto.Config{
|
||||
// Defaults from https://pkg.go.dev/github.com/dgraph-io/ristretto#readme-config
|
||||
@ -189,25 +234,44 @@ func CleanConfig(config *Config) error {
|
||||
if config.DefaultMaxPlayerCount < 0 && config.DefaultMaxPlayerCount != Constants.MaxPlayerCountUnlimited {
|
||||
return fmt.Errorf("DefaultMaxPlayerCount must be >= 0, or %d to indicate unlimited players", Constants.MaxPlayerCountUnlimited)
|
||||
}
|
||||
if config.RegistrationNewPlayer.Allow {
|
||||
if !config.CreateNewPlayer.Allow {
|
||||
return errors.New("If RegisterNewPlayer is allowed, CreateNewPlayer must be allowed.")
|
||||
}
|
||||
}
|
||||
if config.RegistrationExistingPlayer.Allow {
|
||||
if config.RegistrationExistingPlayer.Nickname == "" {
|
||||
return errors.New("RegistrationExistingPlayer.Nickname must be set")
|
||||
if !config.ImportExistingPlayer.Allow {
|
||||
return errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer must be allowed.")
|
||||
}
|
||||
if config.RegistrationExistingPlayer.SessionURL == "" {
|
||||
return errors.New("RegistrationExistingPlayer.SessionURL must be set. Example: https://sessionserver.mojang.com")
|
||||
if config.ImportExistingPlayer.Nickname == "" {
|
||||
return errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.Nickname must be set")
|
||||
}
|
||||
if _, err := url.Parse(config.RegistrationExistingPlayer.SessionURL); err != nil {
|
||||
return fmt.Errorf("Invalid RegistrationExistingPlayer.SessionURL: %s", err)
|
||||
if config.ImportExistingPlayer.SessionURL == "" {
|
||||
return errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.SessionURL must be set. Example: https://sessionserver.mojang.com")
|
||||
}
|
||||
config.RegistrationExistingPlayer.SessionURL = strings.TrimRight(config.RegistrationExistingPlayer.SessionURL, "/")
|
||||
if config.ImportExistingPlayer.AccountURL == "" {
|
||||
return errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.AccountURL must be set. Example: https://api.mojang.com")
|
||||
}
|
||||
}
|
||||
if config.ImportExistingPlayer.Allow {
|
||||
if config.ImportExistingPlayer.Nickname == "" {
|
||||
return errors.New("ImportExistingPlayer.Nickname must be set")
|
||||
}
|
||||
if config.ImportExistingPlayer.SessionURL == "" {
|
||||
return errors.New("ImportExistingPlayer.SessionURL must be set. Example: https://sessionserver.mojang.com")
|
||||
}
|
||||
if _, err := url.Parse(config.ImportExistingPlayer.SessionURL); err != nil {
|
||||
return fmt.Errorf("Invalid ImportExistingPlayer.SessionURL: %s", err)
|
||||
}
|
||||
config.ImportExistingPlayer.SessionURL = strings.TrimRight(config.ImportExistingPlayer.SessionURL, "/")
|
||||
|
||||
if config.RegistrationExistingPlayer.AccountURL == "" {
|
||||
return errors.New("RegistrationExistingPlayer.AccountURL must be set. Example: https://api.mojang.com")
|
||||
if config.ImportExistingPlayer.AccountURL == "" {
|
||||
return errors.New("ImportExistingPlayer.AccountURL must be set. Example: https://api.mojang.com")
|
||||
}
|
||||
if _, err := url.Parse(config.RegistrationExistingPlayer.AccountURL); err != nil {
|
||||
return fmt.Errorf("Invalid RegistrationExistingPlayer.AccountURL: %s", err)
|
||||
if _, err := url.Parse(config.ImportExistingPlayer.AccountURL); err != nil {
|
||||
return fmt.Errorf("Invalid ImportExistingPlayer.AccountURL: %s", err)
|
||||
}
|
||||
config.RegistrationExistingPlayer.AccountURL = strings.TrimRight(config.RegistrationExistingPlayer.AccountURL, "/")
|
||||
config.ImportExistingPlayer.AccountURL = strings.TrimRight(config.ImportExistingPlayer.AccountURL, "/")
|
||||
}
|
||||
for _, fallbackAPIServer := range PtrSlice(config.FallbackAPIServers) {
|
||||
if fallbackAPIServer.Nickname == "" {
|
||||
@ -243,6 +307,17 @@ func CleanConfig(config *Config) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oidcNames := mapset.NewSet[string]()
|
||||
for _, oidcConfig := range PtrSlice(config.RegistrationOIDC) {
|
||||
if oidcNames.Contains(oidcConfig.Name) {
|
||||
return fmt.Errorf("Duplicate RegistrationOIDC Name: %s", oidcConfig.Name)
|
||||
}
|
||||
if _, err := url.Parse(oidcConfig.Issuer); err != nil {
|
||||
return fmt.Errorf("Invalid RegistrationOIDC URL %s: %s", oidcConfig.Issuer, err)
|
||||
}
|
||||
oidcNames.Add(oidcConfig.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -259,10 +334,49 @@ DefaultAdmins = [""]
|
||||
|
||||
[RegistrationNewPlayer]
|
||||
Allow = true
|
||||
AllowChoosingUUID = true
|
||||
RequireInvite = true
|
||||
`
|
||||
|
||||
func HandleDeprecations(config Config, metadata *toml.MetaData) {
|
||||
warningTemplate := "Warning: config option %s is deprecated and will be removed in a future version. Use %s instead."
|
||||
if metadata.IsDefined("RegistrationNewPlayer", "AllowChoosingUUID") {
|
||||
log.Printf(warningTemplate, "RegistrationNewPlayer.AllowChoosingUUID", "CreateNewPlayer.AllowChoosingUUID")
|
||||
if !metadata.IsDefined("CreateNewPlayer", "AllowChoosingUUID") {
|
||||
config.CreateNewPlayer.AllowChoosingUUID = config.RegistrationNewPlayer.AllowChoosingUUID
|
||||
}
|
||||
}
|
||||
if metadata.IsDefined("RegistrationExistingPlayer", "Nickname") {
|
||||
log.Printf(warningTemplate, "RegistrationExistingPlayer.Nickname", "ImportExistingPlayer.Nickname")
|
||||
if !metadata.IsDefined("ImportExistingPlayer", "Nickname") {
|
||||
config.ImportExistingPlayer.Nickname = config.RegistrationExistingPlayer.Nickname
|
||||
}
|
||||
}
|
||||
if metadata.IsDefined("RegistrationExistingPlayer", "SessionURL") {
|
||||
log.Printf(warningTemplate, "RegistrationExistingPlayer.SessionURL", "ImportExistingPlayer.SessionURL")
|
||||
if !metadata.IsDefined("ImportExistingPlayer", "SessionURL") {
|
||||
config.ImportExistingPlayer.SessionURL = config.RegistrationExistingPlayer.SessionURL
|
||||
}
|
||||
}
|
||||
if metadata.IsDefined("RegistrationExistingPlayer", "AccountURL") {
|
||||
log.Printf(warningTemplate, "RegistrationExistingPlayer.AccountURL", "ImportExistingPlayer.AccountURL")
|
||||
if !metadata.IsDefined("ImportExistingPlayer", "AccountURL") {
|
||||
config.ImportExistingPlayer.AccountURL = config.RegistrationExistingPlayer.AccountURL
|
||||
}
|
||||
}
|
||||
if metadata.IsDefined("RegistrationExistingPlayer", "SetSkinURL") {
|
||||
log.Printf(warningTemplate, "RegistrationExistingPlayer.SetSkinURL", "ImportExistingPlayer.SetSkinURL")
|
||||
if !metadata.IsDefined("ImportExistingPlayer", "SetSkinURL") {
|
||||
config.ImportExistingPlayer.SetSkinURL = config.RegistrationExistingPlayer.SetSkinURL
|
||||
}
|
||||
}
|
||||
if metadata.IsDefined("RegistrationExistingPlayer", "RequireSkinVerification") {
|
||||
log.Printf(warningTemplate, "RegistrationExistingPlayer.RequireSkinVerification", "ImportExistingPlayer.RequireSkinVerification")
|
||||
if !metadata.IsDefined("ImportExistingPlayer", "RequireSkinVerification") {
|
||||
config.ImportExistingPlayer.RequireSkinVerification = config.RegistrationExistingPlayer.RequireSkinVerification
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ReadOrCreateConfig(path string) *Config {
|
||||
config := DefaultConfig()
|
||||
|
||||
@ -282,6 +396,7 @@ func ReadOrCreateConfig(path string) *Config {
|
||||
Check(err)
|
||||
}
|
||||
|
||||
log.Println("Loading config from", path)
|
||||
metadata, err := toml.DecodeFile(path, &config)
|
||||
Check(err)
|
||||
|
||||
@ -289,8 +404,7 @@ func ReadOrCreateConfig(path string) *Config {
|
||||
log.Println("Warning: unknown config option", strings.Join(key, "."))
|
||||
}
|
||||
|
||||
log.Println("Loading config from", path)
|
||||
|
||||
HandleDeprecations(config, &metadata)
|
||||
err = CleanConfig(&config)
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("Error in config: %s", err))
|
||||
|
@ -70,26 +70,27 @@ func TestConfig(t *testing.T) {
|
||||
|
||||
config = configTestConfig(sd)
|
||||
config.RegistrationExistingPlayer.Allow = true
|
||||
config.RegistrationExistingPlayer.Nickname = "Example"
|
||||
config.RegistrationExistingPlayer.SessionURL = "https://drasl.example.com/"
|
||||
config.RegistrationExistingPlayer.AccountURL = "https://drasl.example.com/"
|
||||
config.ImportExistingPlayer.Allow = true
|
||||
config.ImportExistingPlayer.Nickname = "Example"
|
||||
config.ImportExistingPlayer.SessionURL = "https://drasl.example.com/"
|
||||
config.ImportExistingPlayer.AccountURL = "https://drasl.example.com/"
|
||||
assert.Nil(t, CleanConfig(config))
|
||||
assert.Equal(t, "https://drasl.example.com", config.RegistrationExistingPlayer.SessionURL)
|
||||
assert.Equal(t, "https://drasl.example.com", config.RegistrationExistingPlayer.AccountURL)
|
||||
assert.Equal(t, "https://drasl.example.com", config.ImportExistingPlayer.SessionURL)
|
||||
assert.Equal(t, "https://drasl.example.com", config.ImportExistingPlayer.AccountURL)
|
||||
|
||||
config = configTestConfig(sd)
|
||||
config.RegistrationExistingPlayer.Allow = true
|
||||
config.RegistrationExistingPlayer.Nickname = ""
|
||||
config.ImportExistingPlayer.Nickname = ""
|
||||
assert.NotNil(t, CleanConfig(config))
|
||||
|
||||
config = configTestConfig(sd)
|
||||
config.RegistrationExistingPlayer.Allow = true
|
||||
config.RegistrationExistingPlayer.SessionURL = ""
|
||||
config.ImportExistingPlayer.SessionURL = ""
|
||||
assert.NotNil(t, CleanConfig(config))
|
||||
|
||||
config = configTestConfig(sd)
|
||||
config.RegistrationExistingPlayer.Allow = true
|
||||
config.RegistrationExistingPlayer.AccountURL = ""
|
||||
config.ImportExistingPlayer.AccountURL = ""
|
||||
assert.NotNil(t, CleanConfig(config))
|
||||
|
||||
config = configTestConfig(sd)
|
||||
|
47
db.go
47
db.go
@ -30,7 +30,7 @@ func IsErrorUniqueFailed(err error) bool {
|
||||
}
|
||||
// Work around https://stackoverflow.com/questions/75489773/why-do-i-get-second-argument-to-errors-as-should-not-be-error-build-error-in
|
||||
e := (errors.New("UNIQUE constraint failed")).(Error)
|
||||
return errors.As(err, &e) || IsErrorPlayerNameTakenByUsername(err) || IsErrorUsernameTakenByPlayerName(err)
|
||||
return errors.As(err, &e)
|
||||
}
|
||||
|
||||
func IsErrorUniqueFailedField(err error, field string) bool {
|
||||
@ -311,6 +311,10 @@ func Migrate(config *Config, dbPath mo.Option[string], db *gorm.DB, alreadyExist
|
||||
if playerName != v3User.Username && allUsernames.Contains(playerName) {
|
||||
playerName = v3User.Username
|
||||
}
|
||||
minecraftPassword, err := MakeMinecraftToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
player := V4Player{
|
||||
UUID: v3User.UUID,
|
||||
Name: playerName,
|
||||
@ -332,6 +336,7 @@ func Migrate(config *Config, dbPath mo.Option[string], db *gorm.DB, alreadyExist
|
||||
PasswordSalt: v3User.PasswordSalt,
|
||||
PasswordHash: v3User.PasswordHash,
|
||||
BrowserToken: v3User.BrowserToken,
|
||||
MinecraftToken: minecraftPassword,
|
||||
APIToken: v3User.APIToken,
|
||||
PreferredLanguage: v3User.PreferredLanguage,
|
||||
Players: []Player{player},
|
||||
@ -368,6 +373,11 @@ func Migrate(config *Config, dbPath mo.Option[string], db *gorm.DB, alreadyExist
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.AutoMigrate(&UserOIDCIdentity{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Exec(fmt.Sprintf(`
|
||||
DROP TRIGGER IF EXISTS v4_insert_unique_username;
|
||||
CREATE TRIGGER v4_insert_unique_username
|
||||
@ -437,6 +447,41 @@ func Migrate(config *Config, dbPath mo.Option[string], db *gorm.DB, alreadyExist
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Exec(`
|
||||
DROP TRIGGER IF EXISTS v4_insert_unique_user_oidc_identities;
|
||||
CREATE TRIGGER v4_insert_unique_user_oidc_identities
|
||||
BEFORE INSERT ON user_oidc_identities
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'UNIQUE constraint failed: user_oidc_identities.issuer, user_oidc_identities.subject')
|
||||
WHERE EXISTS(
|
||||
SELECT 1 from user_oidc_identities WHERE id != NEW.id AND issuer == NEW.issuer AND subject == NEW.subject
|
||||
);
|
||||
|
||||
SELECT RAISE(ABORT, 'UNIQUE constraint failed: user_oidc_identities.issuer')
|
||||
WHERE EXISTS(
|
||||
SELECT 1 from user_oidc_identities WHERE id != NEW.id AND user_uuid == NEW.user_uuid AND issuer == NEW.issuer
|
||||
);
|
||||
END;
|
||||
|
||||
DROP TRIGGER IF EXISTS v4_update_unique_user_oidc_identities;
|
||||
CREATE TRIGGER v4_update_unique_user_oidc_identities
|
||||
BEFORE UPDATE ON user_oidc_identities
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'UNIQUE constraint failed: user_oidc_identities.issuer, user_oidc_identities.subject')
|
||||
WHERE EXISTS(
|
||||
SELECT 1 from user_oidc_identities WHERE id != NEW.id AND issuer == NEW.issuer AND subject == NEW.subject
|
||||
);
|
||||
|
||||
SELECT RAISE(ABORT, 'UNIQUE constraint failed: user_oidc_identities.issuer')
|
||||
WHERE EXISTS(
|
||||
SELECT 1 from user_oidc_identities WHERE id != NEW.id AND user_uuid == NEW.user_uuid AND issuer == NEW.issuer
|
||||
);
|
||||
END;
|
||||
`).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := setUserVersion(tx, userVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -61,19 +61,22 @@ Other available options:
|
||||
<!-- - `UsernameRegex`: If a username matches this regular expression, it will be allowed to log in with the shared password. Use `".*"` to allow transient login for any username. String. Example value: `"[Bot] .*"`. -->
|
||||
<!-- - `Password`: The shared password for transient login. Not restricted by `MinPasswordLength`. String. Example value: `"hunter2"`. -->
|
||||
|
||||
- `[RegistrationNewPlayer]`: Registration policy for new players.
|
||||
- `Allow`: Boolean. Default value: `true`.
|
||||
- `AllowChoosingUUID`: Allow new users to choose the UUID for their account. Boolean. Default value: `false`.
|
||||
- `RequireInvite`: Whether registration requires an invite. If enabled, users will only be able to create a new account if they use an invite link generated by an admin (see `DefaultAdmins`).
|
||||
- `[RegistrationExistingPlayer]`: Registration policy for signing up using an existing account on another API server. The UUID of the existing account will be used for the new account.
|
||||
|
||||
- `[CreateNewPlayer]`: Policy for creating new players, i.e. players with a random or user-specified UUID.
|
||||
- `Allow`: Allow users to create players with new UUIDs, up to their individual `MaxPlayerCount` limit. Boolean. Default value: `true`.
|
||||
- `AllowChoosingUUID`: Allow users to choose a UUID for the new player. If disabled, the new player's UUID will always be randomly chosen. Boolean. Default value: `false`.
|
||||
- `[RegistrationNewPlayer]`
|
||||
- `Allow`: Allow users to register a new Drasl account by creating a player with a new UUID. Requires `CreateNewPlayer.Allow = true`. Boolean. Default value: `true`.
|
||||
- `RequireInvite`: Whether registration requires an invite. If enabled, users will only be able to create a new account if they use an invite link generated by an admin (see `DefaultAdmins`). Boolean. Default value: `false`.
|
||||
- `[ImportExistingPlayer]`: Policy for importing existing players from another API server. The UUID of the existing player will be used for the Drasl player.
|
||||
- `Allow`: Boolean. Default value: `false`.
|
||||
- `Nickname`: A name for the API server used for registration. String. Example value: `"Mojang"`.
|
||||
- `Nickname`: A name for the API server. String. Example value: `"Mojang"`.
|
||||
- `AccountURL`: The URL of the "account" server. String. Example value: `"https://api.mojang.com"`.
|
||||
- `SessionURL`: The URL of the "session" server. String. Example value: `"https://sessionserver.mojang.com"`.
|
||||
- `SetSkinURL`: A link to the web page where you set your skin on the API server. Example value: `"https://www.minecraft.net/msaprofile/mygames/editskin"`.
|
||||
- `RequireSkinVerification`: Require users to set a skin on the existing account to verify their ownership. Boolean. Default value: `false`.
|
||||
- `RequireInvite`: Whether registration requires an invite. If enabled, users will only be able to create a new account if they use an invite link generated by an admin (see `DefaultAdmins`).
|
||||
- `RequireSkinVerification`: Require users to set a skin on the existing player to verify their ownership. Boolean. Default value: `false`.
|
||||
- `[RegistrationExistingPlayer]`
|
||||
- `Allow`: Allow users to register a new Drasl account by importing an existing player from another API server. Requires `ImportExistingPlayer.Allow = true`. Boolean. Default value: `false`.
|
||||
- `RequireInvite`: Whether registration requires an invite. If enabled, users will only be able to create a new account if they use an invite link generated by an admin (see `DefaultAdmins`). Boolean. Default value: `false`.
|
||||
- Note: API servers set up for authlib-injector may only give you one URL---if their API URL is e.g. `https://example.com/yggdrasil`, then you would use the following settings:
|
||||
|
||||
```
|
||||
@ -81,6 +84,15 @@ Other available options:
|
||||
ServicesURL = https://example.com/yggdrasil/minecraftservices
|
||||
```
|
||||
|
||||
- `[[RegistrationOIDC]]`: Allow users to register via [OpenID Connect](https://openid.net/developers/how-connect-works) as well as link their existing Drasl account to OIDC providers. Compatible with both `[RegistrationNewPlayer]` and `[RegistrationExistingPlayer]`. If a user account is linked to one or more OIDC providers, **they will no longer be able to log in to the Drasl web UI or Minecraft using their Drasl password**. For the Drasl web UI, they will have to log in via OIDC. For Minecraft, they will have to use the "Minecraft Token" shown on their user page. Use `$BaseURL/web/oidc-callback/$Name` as the OIDC redirect URI when registering Drasl with your OIDC identity provider, where `$BaseURL` is your Drasl `BaseURL` and `$Name` is the `Name` of the `[[RegistrationOIDC]]` provider. For example, `https://drasl.example.com/web/oidc-callback/Kanidm`.
|
||||
- `Name`: The name of the OIDC provider. String. Example value: `"Kanidm"`.
|
||||
- `Issuer`: OIDC issuer URL. String. Example value: `"https://idm.example.com/oauth2/openid/drasl"`.
|
||||
- `ClientID`: OIDC client ID. String. Example value: `"drasl"`.
|
||||
- `ClientSecret`: OIDC client secret. String. Example value: `"yfUfeFuUI6YiTU23ngJtq8ioYq75FxQid8ls3RdNf0qWSiBO"`.
|
||||
- `PKCE`: Whether to use [PKCE](https://datatracker.ietf.org/doc/html/rfc7636). Recommended, but must be supported by the OIDC provider. Boolean. Default value: `false`.
|
||||
- `RequireInvite`: Whether registration via this OIDC provider requires an invite. If enabled, users will only be able to create a new account via this OIDC provider if they use an invite link generated by an admin (see `DefaultAdmins`). Boolean.
|
||||
- `AllowChoosingPlayerName`: Whether to allow choosing a player name other than the OIDC user's `preferredUsername` during registration. Boolean.
|
||||
|
||||
- `[RequestCache]`: Settings for the cache used for `FallbackAPIServers`. You probably don't need to change these settings. Modify `[[FallbackAPIServers]].CacheTTLSec` instead if you want to disable caching. See [https://pkg.go.dev/github.com/dgraph-io/ristretto#readme-config](https://pkg.go.dev/github.com/dgraph-io/ristretto#readme-config).
|
||||
|
||||
- `NumCounters`: The number of keys to track frequency of. Integer. Default value: `10000000` (`1e7`).
|
||||
@ -97,6 +109,7 @@ Other available options:
|
||||
- Note: Minecraft 1.19 and earlier can only validate player public keys against Mojang's public key, not ours, so you should use `enforce-secure-profile=false` on versions earlier than 1.20.
|
||||
- `TokenStaleSec`: number of seconds after which an access token will go "stale". A stale token needs to be refreshed before it can be used to log in to a Minecraft server. By default, `TokenStaleSec` is set to `0`, meaning tokens will never go stale, and you should never see an error in-game like "Failed to login: Invalid session (Try restarting your game)". To have tokens go stale after one day, for example, set this option to `86400`. Integer. Default value: `0`.
|
||||
- `TokenExpireSec`: number of seconds after which an access token will expire. An expired token can neither be refreshed nor be used to log in to a Minecraft server. By default, `TokenExpireSec` is set to `0`, meaning tokens will never expire, and you should never have to log in again to your launcher if you've been away for a while. The security risks of non-expiring JWTs are actually quite mild; an attacker would still need access to a client's system to steal a token. But if you're concerned about security, you might, for example, set this option to `604800` to have tokens expire after one week. Integer. Default value: `0`.
|
||||
- `AllowPasswordLogin`: Allow registration and login with passwords. Disable to force users to register via OIDC (see `[[OIDCProvider]]`). If disabled, users must use Minecraft Tokens to log in to Minecraft launchers. If this option is disabled after being previously enabled, password accounts will still have the option to link an OIDC provider to their account. Boolean. Default value: `true`.
|
||||
- `AllowChangingPlayerName`: Allow users to change their "player name" after their account has already been created. Could be useful in conjunction with `RegistrationExistingPlayer` if you want to make users register from an existing (e.g. Mojang) account but you want them to be able to choose a new player name. Admins can change the name of any player regardless of this setting. Boolean. Default value: `true`.
|
||||
- `AllowSkins`: Allow users to upload skins. You may want to disable this option if you want to rely exclusively on `ForwardSkins`, e.g. to fully support Vanilla clients. Admins can set skins regardless of this setting. Boolean. Default value: `true`.
|
||||
- `AllowCapes`: Allow users to upload capes. Admins can set capes regardless of this setting. Boolean. Default value: `true`.
|
||||
|
@ -16,10 +16,12 @@ Domain = "drasl.example.com" # CHANGE ME!
|
||||
BaseURL = "https://drasl.example.com" # CHANGE ME!
|
||||
DefaultAdmins = ["myusername"] # CHANGE ME!
|
||||
|
||||
[CreateNewPlayer]
|
||||
AllowChoosingUUID = true
|
||||
|
||||
[RegistrationNewPlayer]
|
||||
Allow = true
|
||||
AllowChoosingUUID = true
|
||||
RequireInvite = true
|
||||
Allow = true
|
||||
RequireInvite = true
|
||||
```
|
||||
|
||||
### Example 2: Mojang-dependent
|
||||
@ -41,9 +43,9 @@ ForwardSkins = true
|
||||
AllowChangingPlayerName = false
|
||||
|
||||
[RegistrationNewPlayer]
|
||||
Allow = false
|
||||
Allow = false
|
||||
|
||||
[RegistrationExistingPlayer]
|
||||
[ImportExistingPlayer]
|
||||
Allow = true
|
||||
Nickname = "Mojang"
|
||||
SessionURL = "https://sessionserver.mojang.com"
|
||||
@ -51,6 +53,9 @@ Allow = false
|
||||
SetSkinURL = "https://www.minecraft.net/msaprofile/mygames/editskin"
|
||||
RequireSkinVerification = true
|
||||
|
||||
[RegistrationExistingPlayer]
|
||||
Allow = true
|
||||
|
||||
[[FallbackAPIServers]]
|
||||
Nickname = "Mojang"
|
||||
SessionURL = "https://sessionserver.mojang.com"
|
||||
@ -78,7 +83,7 @@ BaseURL = "https://drasl.example.com" # CHANGE ME!
|
||||
DefaultAdmins = ["myusername"] # CHANGE ME!
|
||||
|
||||
[RegistrationNewPlayer]
|
||||
Allow = false
|
||||
Allow = false
|
||||
|
||||
[[FallbackAPIServers]]
|
||||
Nickname = "Ely.by"
|
||||
@ -112,10 +117,52 @@ Domain = "drasl.example.com" # CHANGE ME!
|
||||
BaseURL = "https://drasl.example.com/jaek7iNe # CHANGE ME!
|
||||
DefaultAdmins = ["myusername"] # CHANGE ME!
|
||||
|
||||
[CreateNewPlayer]
|
||||
AllowChoosingUUID = true
|
||||
|
||||
[RegistrationNewPlayer]
|
||||
Allow = true
|
||||
AllowChoosingUUID = true
|
||||
RequireInvite = true
|
||||
Allow = true
|
||||
RequireInvite = true
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Example 5: Single sign-on (SSO) via OpenID Connect (OIDC)
|
||||
|
||||
- Users can sign in to Drasl using the OIDC providers idm.example.com and/or lastlogin.net (`[[RegistrationOIDC]]`). Drasl users linked to one or more OIDC accounts will not be able to log in with a password. To log in to Minecraft launchers, they'll need to instead use their "Minecraft Token" shown on their user page.
|
||||
- Users will not be allowed to register an account with a password (`AllowPasswordLogin = false`). Existing Drasl users who already have an account with a password will not be able to sign in until they link their account with an OIDC provider.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Show config.toml</summary>
|
||||
|
||||
```
|
||||
Domain = "drasl.example.com" # CHANGE ME!
|
||||
BaseURL = "https://drasl.example.com # CHANGE ME!
|
||||
DefaultAdmins = ["myusername"] # CHANGE ME!
|
||||
|
||||
AllowPasswordLogin = false
|
||||
|
||||
[RegistrationNewPlayer]
|
||||
Allow = true
|
||||
|
||||
[[RegistrationOIDC]]
|
||||
Name = "Kanidm"
|
||||
Issuer = "https://idm.example.com/oauth2/openid/drasl" # CHANGE ME!
|
||||
ClientID = "drasl" # CHANGE ME!
|
||||
ClientSecret = "yfUfeFuUI6YiTU23ngJtq8ioYq75FxQid8ls3RdNf0qWSiBO" # CHANGE ME!
|
||||
RequireInvite = false
|
||||
PKCE = true
|
||||
AllowChoosingPlayerName = true
|
||||
|
||||
[[RegistrationOIDC]]
|
||||
Name = "LastLogin"
|
||||
Issuer = "https://lastlogin.net" # CHANGE ME!
|
||||
ClientID = "https://drasl.example.com" # CHANGE ME!
|
||||
ClientSecret = "" # CHANGE ME!
|
||||
RequireInvite = false
|
||||
PKCE = true
|
||||
AllowChoosingPlayerName = true
|
||||
```
|
||||
|
||||
</details>
|
||||
@ -144,11 +191,11 @@ Note for fallback servers implementing the authlib-injector API: authlib-injecto
|
||||
SkinDomains = ["textures.minecraft.net"]
|
||||
CacheTTLSeconds = 60
|
||||
|
||||
[RegistrationExistingPlayer]
|
||||
[ImportExistingPlayer]
|
||||
Allow = true
|
||||
Nickname = "Mojang"
|
||||
SessionURL = "https://sessionserver.mojang.com"
|
||||
AccountURL = "https://api.mojang.com"
|
||||
SessionURL = "https://sessionserver.mojang.com"
|
||||
SetSkinURL = "https://www.minecraft.net/msaprofile/mygames/editskin"
|
||||
```
|
||||
|
||||
@ -163,11 +210,11 @@ Note for fallback servers implementing the authlib-injector API: authlib-injecto
|
||||
SkinDomains = ["ely.by", ".ely.by"]
|
||||
CacheTTLSeconds = 60
|
||||
|
||||
[RegistrationExistingPlayer]
|
||||
[ImportExistingPlayer]
|
||||
Allow = true
|
||||
Nickname = "Ely.by"
|
||||
SessionURL = "https://authserver.ely.by/api/authlib-injector/sessionserver"
|
||||
AccountURL = "https://authserver.ely.by/api"
|
||||
SessionURL = "https://authserver.ely.by/api/authlib-injector/sessionserver"
|
||||
SetSkinURL = "https://ely.by/skins/add"
|
||||
```
|
||||
|
||||
@ -184,11 +231,10 @@ Note for fallback servers implementing the authlib-injector API: authlib-injecto
|
||||
SkinDomains = ["skin.example.com"]
|
||||
CacheTTLSeconds = 60
|
||||
|
||||
[RegistrationExistingPlayer]
|
||||
[ImportExistingPlayer]
|
||||
Allow = true
|
||||
Nickname = "Blessing Skin"
|
||||
SessionURL = "https://skin.example.com/api/yggdrasil/sessionserver"
|
||||
AccountURL = "https://skin.example.com/api/yggdrasil/api"
|
||||
SessionURL = "https://skin.example.com/api/yggdrasil/sessionserver"
|
||||
SetSkinURL = "https://skin.example.com/skinlib/upload"
|
||||
|
||||
```
|
||||
|
@ -48,12 +48,12 @@
|
||||
];
|
||||
|
||||
# Update whenever Go dependencies change
|
||||
vendorHash = "sha256-vs1aJ9n22pKk4EfvaH8rj1fEqEnsv2m2i/PnfEo/pGE=";
|
||||
vendorHash = "sha256-RN36xKoIj7wm0Mo6M+nH9GscZUzlbxOHDQmHfO/nC+E=";
|
||||
|
||||
outputs = ["out"];
|
||||
|
||||
preConfigure = ''
|
||||
substituteInPlace build_config.go --replace "\"/usr/share/drasl\"" "\"$out/share/drasl\""
|
||||
substituteInPlace build_config.go --replace-fail "\"/usr/share/drasl\"" "\"$out/share/drasl\""
|
||||
'';
|
||||
|
||||
preBuild = ''
|
||||
|
119
front_test.go
119
front_test.go
@ -33,12 +33,15 @@ func setupRegistrationExistingPlayerTS(t *testing.T, requireSkinVerification boo
|
||||
config := testConfig()
|
||||
config.RegistrationNewPlayer.Allow = false
|
||||
config.RegistrationExistingPlayer = registrationExistingPlayerConfig{
|
||||
Allow: true,
|
||||
RequireInvite: requireInvite,
|
||||
}
|
||||
config.ImportExistingPlayer = importExistingPlayerConfig{
|
||||
Allow: true,
|
||||
Nickname: "Aux",
|
||||
SessionURL: ts.AuxApp.SessionURL,
|
||||
AccountURL: ts.AuxApp.AccountURL,
|
||||
RequireSkinVerification: requireSkinVerification,
|
||||
RequireInvite: requireInvite,
|
||||
}
|
||||
config.FallbackAPIServers = []FallbackAPIServer{
|
||||
{
|
||||
@ -72,27 +75,32 @@ func (ts *TestSuite) testWebManifest(t *testing.T) {
|
||||
func (ts *TestSuite) testPublic(t *testing.T) {
|
||||
ts.testStatusOK(t, "/")
|
||||
ts.testStatusOK(t, "/web/registration")
|
||||
ts.testStatusOK(t, "/web/public/bundle.js")
|
||||
ts.testStatusOK(t, "/web/public/style.css")
|
||||
ts.testStatusOK(t, "/web/public/logo.svg")
|
||||
ts.testStatusOK(t, "/web/public/icon.png")
|
||||
ts.testStatusOK(t, "/web/manifest.webmanifest")
|
||||
ts.testStatusOK(t, ts.App.PublicURL+"/bundle.js")
|
||||
ts.testStatusOK(t, ts.App.PublicURL+"/style.css")
|
||||
ts.testStatusOK(t, ts.App.PublicURL+"/logo.svg")
|
||||
ts.testStatusOK(t, ts.App.PublicURL+"/icon.png")
|
||||
{
|
||||
rec := ts.Get(t, ts.Server, "/web/thisdoesnotexist", nil, nil)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func getErrorMessage(rec *httptest.ResponseRecorder) string {
|
||||
return Unwrap(url.QueryUnescape(getCookie(rec, "errorMessage").Value))
|
||||
return Unwrap(url.QueryUnescape(getCookie(rec, ERROR_MESSAGE_COOKIE_NAME).Value))
|
||||
}
|
||||
|
||||
func (ts *TestSuite) registrationShouldFail(t *testing.T, rec *httptest.ResponseRecorder, errorMessage string, returnURL string) {
|
||||
assert.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
assert.Equal(t, errorMessage, getErrorMessage(rec))
|
||||
assert.Equal(t, "", getCookie(rec, "browserToken").Value)
|
||||
assert.Equal(t, "", getCookie(rec, BROWSER_TOKEN_COOKIE_NAME).Value)
|
||||
assert.Equal(t, returnURL, rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
func (ts *TestSuite) registrationShouldSucceed(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
assert.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
assert.Equal(t, "", getErrorMessage(rec))
|
||||
assert.NotEqual(t, "", getCookie(rec, "browserToken").Value)
|
||||
assert.NotEqual(t, "", getCookie(rec, BROWSER_TOKEN_COOKIE_NAME).Value)
|
||||
assert.Equal(t, ts.App.FrontEndURL+"/web/user", rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
@ -142,19 +150,20 @@ func (ts *TestSuite) updatePlayerShouldSucceed(t *testing.T, rec *httptest.Respo
|
||||
func (ts *TestSuite) loginShouldSucceed(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
assert.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
assert.Equal(t, "", getErrorMessage(rec))
|
||||
assert.NotEqual(t, "", getCookie(rec, "browserToken").Value)
|
||||
assert.NotEqual(t, "", getCookie(rec, BROWSER_TOKEN_COOKIE_NAME).Value)
|
||||
assert.Equal(t, ts.App.FrontEndURL+"/web/user", rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
func (ts *TestSuite) loginShouldFail(t *testing.T, rec *httptest.ResponseRecorder, errorMessage string) {
|
||||
assert.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
assert.Equal(t, errorMessage, getErrorMessage(rec))
|
||||
assert.Equal(t, "", getCookie(rec, "browserToken").Value)
|
||||
assert.Equal(t, "", getCookie(rec, BROWSER_TOKEN_COOKIE_NAME).Value)
|
||||
assert.Equal(t, ts.App.FrontEndURL, rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
func TestFront(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
{
|
||||
// Registration as existing player not allowed
|
||||
ts := &TestSuite{}
|
||||
@ -330,7 +339,7 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
|
||||
{
|
||||
// Tripping the honeypot should fail
|
||||
form := url.Values{}
|
||||
form.Set("username", usernameA)
|
||||
form.Set("playerName", usernameA)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("email", "mail@example.com")
|
||||
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
|
||||
@ -340,11 +349,11 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
|
||||
{
|
||||
// Register
|
||||
form := url.Values{}
|
||||
form.Set("username", usernameA)
|
||||
form.Set("playerName", usernameA)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
|
||||
ts.registrationShouldSucceed(t, rec)
|
||||
browserTokenCookie := getCookie(rec, "browserToken")
|
||||
browserTokenCookie := getCookie(rec, BROWSER_TOKEN_COOKIE_NAME)
|
||||
|
||||
// Check that the user has been created with a correct password hash/salt
|
||||
var user User
|
||||
@ -375,12 +384,12 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
|
||||
{
|
||||
// Register
|
||||
form := url.Values{}
|
||||
form.Set("username", usernameB)
|
||||
form.Set("playerName", usernameB)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
|
||||
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
|
||||
ts.registrationShouldSucceed(t, rec)
|
||||
browserTokenCookie := getCookie(rec, "browserToken")
|
||||
browserTokenCookie := getCookie(rec, BROWSER_TOKEN_COOKIE_NAME)
|
||||
|
||||
// Users not in the DefaultAdmins list should not be admins
|
||||
var user User
|
||||
@ -398,7 +407,7 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
|
||||
{
|
||||
// Try registering again with the same username
|
||||
form := url.Values{}
|
||||
form.Set("username", usernameA)
|
||||
form.Set("playerName", usernameA)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
|
||||
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
|
||||
@ -410,7 +419,7 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
|
||||
// username, but uppercase. Usernames are case-sensitive, but player
|
||||
// names are.
|
||||
form := url.Values{}
|
||||
form.Set("username", usernameAUppercase)
|
||||
form.Set("playerName", usernameAUppercase)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
|
||||
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
|
||||
@ -420,17 +429,17 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
|
||||
{
|
||||
// Registration with a too-long username should fail
|
||||
form := url.Values{}
|
||||
form.Set("username", "AReallyReallyReallyLongUsername")
|
||||
form.Set("playerName", "AReallyReallyReallyLongUsername")
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("returnUrl", returnURL)
|
||||
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
|
||||
|
||||
ts.registrationShouldFail(t, rec, "Invalid username: can't be longer than 16 characters", returnURL)
|
||||
ts.registrationShouldFail(t, rec, "Invalid username: neither a valid player name (can't be longer than 16 characters) nor an email address", returnURL)
|
||||
}
|
||||
{
|
||||
// Registration with a too-short password should fail
|
||||
form := url.Values{}
|
||||
form.Set("username", usernameC)
|
||||
form.Set("playerName", usernameC)
|
||||
form.Set("password", "")
|
||||
form.Set("returnUrl", returnURL)
|
||||
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
|
||||
@ -440,7 +449,7 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
|
||||
{
|
||||
// Registration from an existing player should fail
|
||||
form := url.Values{}
|
||||
form.Set("username", usernameC)
|
||||
form.Set("playerName", usernameC)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("existingPlayer", "on")
|
||||
form.Set("challengeToken", "This is not a valid challenge token.")
|
||||
@ -461,7 +470,7 @@ func (ts *TestSuite) testRegistrationNewPlayerChosenUUIDNotAllowed(t *testing.T)
|
||||
|
||||
returnURL := ts.App.FrontEndURL + "/web/registration"
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("playerName", username)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("uuid", uuid)
|
||||
form.Set("returnUrl", returnURL)
|
||||
@ -478,14 +487,14 @@ func (ts *TestSuite) testRegistrationNewPlayerChosenUUID(t *testing.T) {
|
||||
{
|
||||
// Register
|
||||
form := url.Values{}
|
||||
form.Set("username", usernameA)
|
||||
form.Set("playerName", usernameA)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("uuid", uuid)
|
||||
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
|
||||
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
|
||||
|
||||
// Registration should succeed, grant a browserToken, and redirect to user page
|
||||
assert.NotEqual(t, "", getCookie(rec, "browserToken"))
|
||||
assert.NotEqual(t, "", getCookie(rec, BROWSER_TOKEN_COOKIE_NAME))
|
||||
ts.registrationShouldSucceed(t, rec)
|
||||
|
||||
// Check that the user has been created and has a player with the chosen UUID
|
||||
@ -498,7 +507,7 @@ func (ts *TestSuite) testRegistrationNewPlayerChosenUUID(t *testing.T) {
|
||||
{
|
||||
// Try registering again with the same UUID
|
||||
form := url.Values{}
|
||||
form.Set("username", usernameB)
|
||||
form.Set("playerName", usernameB)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("uuid", uuid)
|
||||
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
|
||||
@ -509,7 +518,7 @@ func (ts *TestSuite) testRegistrationNewPlayerChosenUUID(t *testing.T) {
|
||||
{
|
||||
// Try registering with a garbage UUID
|
||||
form := url.Values{}
|
||||
form.Set("username", usernameB)
|
||||
form.Set("playerName", usernameB)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("uuid", "This is not a UUID.")
|
||||
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
|
||||
@ -525,7 +534,7 @@ func (ts *TestSuite) testRegistrationNewPlayerInvite(t *testing.T) {
|
||||
// Registration without an invite should fail
|
||||
returnURL := ts.App.FrontEndURL + "/web/registration"
|
||||
form := url.Values{}
|
||||
form.Set("username", usernameA)
|
||||
form.Set("playerName", usernameA)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
|
||||
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
|
||||
@ -536,7 +545,7 @@ func (ts *TestSuite) testRegistrationNewPlayerInvite(t *testing.T) {
|
||||
// registration page without ?invite
|
||||
returnURL := ts.App.FrontEndURL + "/web/registration"
|
||||
form := url.Values{}
|
||||
form.Set("username", usernameA)
|
||||
form.Set("playerName", usernameA)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("inviteCode", "invalid")
|
||||
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration?invite=invalid")
|
||||
@ -559,15 +568,15 @@ func (ts *TestSuite) testRegistrationNewPlayerInvite(t *testing.T) {
|
||||
// registration page with the same unused invite code
|
||||
returnURL := ts.App.FrontEndURL + "/web/registration?invite=" + invite.Code
|
||||
form := url.Values{}
|
||||
form.Set("username", "")
|
||||
form.Set("playerName", "")
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("inviteCode", invite.Code)
|
||||
form.Set("returnUrl", returnURL)
|
||||
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
|
||||
ts.registrationShouldFail(t, rec, "Invalid username: can't be blank", returnURL)
|
||||
ts.registrationShouldFail(t, rec, "Invalid username: neither a valid player name (can't be blank) nor an email address", returnURL)
|
||||
|
||||
// Then, set a valid username and continnue
|
||||
form.Set("username", usernameA)
|
||||
form.Set("playerName", usernameA)
|
||||
rec = ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
|
||||
ts.registrationShouldSucceed(t, rec)
|
||||
|
||||
@ -584,7 +593,7 @@ func (ts *TestSuite) solveRegisterChallenge(t *testing.T, username string) *http
|
||||
rec := httptest.NewRecorder()
|
||||
ts.Server.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
challengeToken := getCookie(rec, "challengeToken")
|
||||
challengeToken := getCookie(rec, CHALLENGE_TOKEN_COOKIE_NAME)
|
||||
assert.NotEqual(t, "", challengeToken.Value)
|
||||
|
||||
base64Exp, err := regexp.Compile("src=\"data:image\\/png;base64,([A-Za-z0-9+/&#;]*={0,2})\"")
|
||||
@ -614,7 +623,7 @@ func (ts *TestSuite) solveCreatePlayerChallenge(t *testing.T, playerName string)
|
||||
rec := httptest.NewRecorder()
|
||||
ts.Server.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
challengeToken := getCookie(rec, "challengeToken")
|
||||
challengeToken := getCookie(rec, CHALLENGE_TOKEN_COOKIE_NAME)
|
||||
assert.NotEqual(t, "", challengeToken.Value)
|
||||
|
||||
base64Exp, err := regexp.Compile("src=\"data:image\\/png;base64,([A-Za-z0-9+/&#;]*={0,2})\"")
|
||||
@ -644,7 +653,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerInvite(t *testing.T) {
|
||||
// Registration without an invite should fail
|
||||
returnURL := ts.App.FrontEndURL + "/web/registration"
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("playerName", username)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("existingPlayer", "on")
|
||||
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
|
||||
@ -656,7 +665,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerInvite(t *testing.T) {
|
||||
// registration page without ?invite
|
||||
returnURL := ts.App.FrontEndURL + "/web/registration"
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("playerName", username)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("existingPlayer", "on")
|
||||
form.Set("inviteCode", "invalid")
|
||||
@ -682,18 +691,18 @@ func (ts *TestSuite) testRegistrationExistingPlayerInvite(t *testing.T) {
|
||||
// Registration with an invalid username should redirect to the
|
||||
// registration page with the same unused invite code
|
||||
form := url.Values{}
|
||||
form.Set("username", "")
|
||||
form.Set("playerName", "")
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("existingPlayer", "on")
|
||||
form.Set("inviteCode", invite.Code)
|
||||
form.Set("returnUrl", returnURL)
|
||||
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
|
||||
ts.registrationShouldFail(t, rec, "Invalid username: can't be blank", returnURL)
|
||||
ts.registrationShouldFail(t, rec, "Invalid username: neither a valid player name (can't be blank) nor an email address", returnURL)
|
||||
}
|
||||
{
|
||||
// Registration should fail if we give the wrong challenge token, and the invite should not be used
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("playerName", username)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("existingPlayer", "on")
|
||||
form.Set("inviteCode", invite.Code)
|
||||
@ -706,7 +715,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerInvite(t *testing.T) {
|
||||
{
|
||||
// Registration should succeed if everything is correct
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("playerName", username)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("existingPlayer", "on")
|
||||
form.Set("inviteCode", invite.Code)
|
||||
@ -737,7 +746,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerInvite(t *testing.T) {
|
||||
|
||||
func (ts *TestSuite) testLoginLogout(t *testing.T) {
|
||||
username := "loginLogout"
|
||||
ts.CreateTestUser(t, ts.App, ts.Server, username)
|
||||
user, _ := ts.CreateTestUser(t, ts.App, ts.Server, username)
|
||||
|
||||
{
|
||||
// Login
|
||||
@ -747,7 +756,7 @@ func (ts *TestSuite) testLoginLogout(t *testing.T) {
|
||||
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
|
||||
rec := ts.PostForm(t, ts.Server, "/web/login", form, nil, nil)
|
||||
ts.loginShouldSucceed(t, rec)
|
||||
browserTokenCookie := getCookie(rec, "browserToken")
|
||||
browserTokenCookie := getCookie(rec, BROWSER_TOKEN_COOKIE_NAME)
|
||||
|
||||
// The BrowserToken we get should match the one in the database
|
||||
var user User
|
||||
@ -779,6 +788,14 @@ func (ts *TestSuite) testLoginLogout(t *testing.T) {
|
||||
rec := ts.PostForm(t, ts.Server, "/web/login", form, nil, nil)
|
||||
ts.loginShouldFail(t, rec, "Incorrect password.")
|
||||
}
|
||||
{
|
||||
// Web login with the user's Minecraft token should fail
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("password", user.MinecraftToken)
|
||||
rec := ts.PostForm(t, ts.Server, "/web/login", form, nil, nil)
|
||||
ts.loginShouldFail(t, rec, "Incorrect password.")
|
||||
}
|
||||
{
|
||||
// GET /web/user without valid BrowserToken should fail
|
||||
req := httptest.NewRequest(http.MethodGet, "/web/user", nil)
|
||||
@ -802,7 +819,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerNoVerification(t *testing.T)
|
||||
|
||||
// Register from the existing account
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("playerName", username)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("existingPlayer", "on")
|
||||
form.Set("returnUrl", returnURL)
|
||||
@ -825,7 +842,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerNoVerification(t *testing.T)
|
||||
{
|
||||
// Registration as a new user should fail
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("playerName", username)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("returnUrl", returnURL)
|
||||
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
|
||||
@ -835,7 +852,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerNoVerification(t *testing.T)
|
||||
// Registration with a missing existing account should fail
|
||||
returnURL := ts.App.FrontEndURL + "/web/registration"
|
||||
form := url.Values{}
|
||||
form.Set("username", "nonexistent")
|
||||
form.Set("playerName", "nonexistent")
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("existingPlayer", "on")
|
||||
form.Set("returnUrl", returnURL)
|
||||
@ -947,7 +964,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerVerification(t *testing.T) {
|
||||
{
|
||||
// Registration without setting a skin should fail
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("playerName", username)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("existingPlayer", "on")
|
||||
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
|
||||
@ -960,7 +977,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerVerification(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ts.Server.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
assert.Equal(t, "Invalid username: can't be longer than 16 characters", getErrorMessage(rec))
|
||||
assert.Equal(t, "Invalid username: neither a valid player name (can't be longer than 16 characters) nor an email address", getErrorMessage(rec))
|
||||
assert.Equal(t, returnURL, rec.Header().Get("Location"))
|
||||
}
|
||||
{
|
||||
@ -968,7 +985,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerVerification(t *testing.T) {
|
||||
{
|
||||
// Registration should fail if we give the wrong challenge token
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("playerName", username)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("existingPlayer", "on")
|
||||
form.Set("challengeToken", "invalid-challenge-token")
|
||||
@ -980,7 +997,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerVerification(t *testing.T) {
|
||||
{
|
||||
// Registration should succeed if everything is correct
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("playerName", username)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
form.Set("existingPlayer", "on")
|
||||
form.Set("challengeToken", challengeToken.Value)
|
||||
@ -1078,7 +1095,7 @@ func (ts *TestSuite) testUserUpdate(t *testing.T) {
|
||||
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
|
||||
rec = ts.PostForm(t, ts.Server, "/web/login", form, nil, nil)
|
||||
ts.loginShouldSucceed(t, rec)
|
||||
browserTokenCookie = getCookie(rec, "browserToken")
|
||||
browserTokenCookie = getCookie(rec, BROWSER_TOKEN_COOKIE_NAME)
|
||||
}
|
||||
{
|
||||
// As an admin, test updating another user's account
|
||||
@ -1447,11 +1464,11 @@ func (ts *TestSuite) testDeleteAccount(t *testing.T) {
|
||||
{
|
||||
// Register usernameB again
|
||||
form := url.Values{}
|
||||
form.Set("username", usernameB)
|
||||
form.Set("playerName", usernameB)
|
||||
form.Set("password", TEST_PASSWORD)
|
||||
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
|
||||
ts.registrationShouldSucceed(t, rec)
|
||||
browserTokenCookie := getCookie(rec, "browserToken")
|
||||
browserTokenCookie := getCookie(rec, BROWSER_TOKEN_COOKIE_NAME)
|
||||
|
||||
// Check that usernameB has been created
|
||||
var otherUser User
|
||||
|
22
go.mod
22
go.mod
@ -4,12 +4,15 @@ go 1.21
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.3.2
|
||||
github.com/deckarep/golang-set/v2 v2.6.0
|
||||
github.com/dgraph-io/ristretto v0.1.1
|
||||
github.com/golang-jwt/jwt/v5 v5.1.0
|
||||
github.com/google/uuid v1.4.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jxskiss/base62 v1.1.0
|
||||
github.com/labstack/echo/v4 v4.11.4
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/samber/mo v1.13.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/zitadel/oidc/v3 v3.33.1
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/time v0.5.0
|
||||
gorm.io/driver/sqlite v1.3.6
|
||||
@ -20,10 +23,13 @@ require (
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/golang/glog v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||
@ -32,13 +38,21 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.18 // indirect
|
||||
github.com/muhlemmer/gu v0.3.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||
github.com/samber/mo v1.13.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/zitadel/logging v0.6.1 // indirect
|
||||
github.com/zitadel/oidc v1.13.5 // indirect
|
||||
github.com/zitadel/schema v1.3.0 // indirect
|
||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/oauth2 v0.24.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
|
45
go.sum
45
go.sum
@ -11,11 +11,17 @@ github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80N
|
||||
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
|
||||
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
|
||||
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v5 v5.1.0 h1:UGKbA/IPjtS6zLcdB7i5TyACMgSbOTiR8qzXgw8HWQU=
|
||||
@ -23,8 +29,10 @@ github.com/golang-jwt/jwt/v5 v5.1.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo=
|
||||
github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ=
|
||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
@ -35,11 +43,9 @@ github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
|
||||
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
|
||||
@ -53,28 +59,49 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
|
||||
github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/samber/mo v1.13.0 h1:LB1OwfJMju3a6FjghH+AIvzMG0ZPOzgTWj1qaHs1IQ4=
|
||||
github.com/samber/mo v1.13.0/go.mod h1:BfkrCPuYzVG3ZljnZB783WIJIGk1mcZr9c9CPf8tAxs=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/zitadel/logging v0.6.1 h1:Vyzk1rl9Kq9RCevcpX6ujUaTYFX43aa4LkvV1TvUk+Y=
|
||||
github.com/zitadel/logging v0.6.1/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow=
|
||||
github.com/zitadel/oidc v1.13.5 h1:7jhh68NGZitLqwLiVU9Dtwa4IraJPFF1vS+4UupO93U=
|
||||
github.com/zitadel/oidc v1.13.5/go.mod h1:rHs1DhU3Sv3tnI6bQRVlFa3u0lCwtR7S21WHY+yXgPA=
|
||||
github.com/zitadel/oidc/v3 v3.33.1 h1:e3w9PDV0Mh50/ZiJWtzyT0E4uxJ6RXll+hqVDnqGbTU=
|
||||
github.com/zitadel/oidc/v3 v3.33.1/go.mod h1:zkoZ1Oq6CweX3BaLrftLEGCs6YK6zDpjjVGZrP10AWU=
|
||||
github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0=
|
||||
github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@ -86,9 +113,9 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
|
||||
|
77
main.go
77
main.go
@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
@ -10,6 +13,8 @@ import (
|
||||
"github.com/dgraph-io/ristretto"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
httphelper "github.com/zitadel/oidc/v3/pkg/http"
|
||||
"golang.org/x/time/rate"
|
||||
"gorm.io/gorm"
|
||||
"image"
|
||||
@ -22,6 +27,7 @@ import (
|
||||
"path"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var DEBUG = os.Getenv("DRASL_DEBUG") != ""
|
||||
@ -33,6 +39,7 @@ var bodyDump = middleware.BodyDump(func(c echo.Context, reqBody, resBody []byte)
|
||||
|
||||
type App struct {
|
||||
FrontEndURL string
|
||||
PublicURL string
|
||||
AuthURL string
|
||||
AccountURL string
|
||||
ServicesURL string
|
||||
@ -47,10 +54,14 @@ type App struct {
|
||||
Constants *ConstantsType
|
||||
PlayerCertificateKeys []rsa.PublicKey
|
||||
ProfilePropertyKeys []rsa.PublicKey
|
||||
Key *rsa.PrivateKey
|
||||
KeyB3Sum512 []byte
|
||||
PrivateKey *rsa.PrivateKey
|
||||
PrivateKeyB3Sum256 [256 / 8]byte
|
||||
PrivateKeyB3Sum512 [512 / 8]byte
|
||||
AEAD cipher.AEAD
|
||||
SkinMutex *sync.Mutex
|
||||
VerificationSkinTemplate *image.NRGBA
|
||||
OIDCProvidersByName map[string]*OIDCProvider
|
||||
OIDCProvidersByIssuer map[string]*OIDCProvider
|
||||
}
|
||||
|
||||
func (app *App) LogError(err error, c *echo.Context) {
|
||||
@ -157,14 +168,16 @@ func (app *App) MakeServer() *echo.Echo {
|
||||
if app.Config.EnableWebFrontEnd {
|
||||
t := NewTemplate(app)
|
||||
e.Renderer = t
|
||||
frontUser := FrontUser(app)
|
||||
e.GET("/", FrontRoot(app))
|
||||
e.GET("/web/admin", FrontAdmin(app))
|
||||
e.GET("/web/complete-registration", FrontCompleteRegistration(app))
|
||||
e.GET("/web/create-player-challenge", FrontCreatePlayerChallenge(app))
|
||||
e.GET("/web/manifest.webmanifest", FrontWebManifest(app))
|
||||
e.GET("/web/oidc-callback/:providerName", FrontOIDCCallback(app))
|
||||
e.GET("/web/player/:uuid", FrontPlayer(app))
|
||||
e.GET("/web/register-challenge", FrontRegisterChallenge(app))
|
||||
e.GET("/web/registration", FrontRegistration(app))
|
||||
frontUser := FrontUser(app)
|
||||
e.GET("/web/user", frontUser)
|
||||
e.GET("/web/user/:uuid", frontUser)
|
||||
e.POST("/web/admin/delete-invite", FrontDeleteInvite(app))
|
||||
@ -175,6 +188,8 @@ func (app *App) MakeServer() *echo.Echo {
|
||||
e.POST("/web/delete-user", FrontDeleteUser(app))
|
||||
e.POST("/web/login", FrontLogin(app))
|
||||
e.POST("/web/logout", FrontLogout(app))
|
||||
e.POST("/web/oidc-migrate", app.FrontOIDCMigrate())
|
||||
e.POST("/web/oidc-unlink", app.FrontOIDCUnlink())
|
||||
e.POST("/web/register", FrontRegister(app))
|
||||
e.POST("/web/update-player", FrontUpdatePlayer(app))
|
||||
e.POST("/web/update-user", FrontUpdateUser(app))
|
||||
@ -187,6 +202,7 @@ func (app *App) MakeServer() *echo.Echo {
|
||||
|
||||
// Drasl API
|
||||
e.DELETE(DRASL_API_PREFIX+"/invites/:code", app.APIDeleteInvite())
|
||||
e.DELETE(DRASL_API_PREFIX+"/oidc-identities", app.APIDeleteOIDCIdentity())
|
||||
e.DELETE(DRASL_API_PREFIX+"/players/:uuid", app.APIDeletePlayer())
|
||||
e.DELETE(DRASL_API_PREFIX+"/user", app.APIDeleteSelf())
|
||||
e.DELETE(DRASL_API_PREFIX+"/users/:uuid", app.APIDeleteUser())
|
||||
@ -203,8 +219,9 @@ func (app *App) MakeServer() *echo.Echo {
|
||||
e.PATCH(DRASL_API_PREFIX+"/user", app.APIUpdateSelf())
|
||||
e.PATCH(DRASL_API_PREFIX+"/users/:uuid", app.APIUpdateUser())
|
||||
|
||||
e.POST(DRASL_API_PREFIX+"/login", app.APILogin())
|
||||
e.POST(DRASL_API_PREFIX+"/invites", app.APICreateInvite())
|
||||
e.POST(DRASL_API_PREFIX+"/login", app.APILogin())
|
||||
e.POST(DRASL_API_PREFIX+"/oidc-identities", app.APICreateOIDCIdentity())
|
||||
e.POST(DRASL_API_PREFIX+"/players", app.APICreatePlayer())
|
||||
e.POST(DRASL_API_PREFIX+"/users", app.APICreateUser())
|
||||
|
||||
@ -358,11 +375,17 @@ func setup(config *Config) *App {
|
||||
}
|
||||
}
|
||||
|
||||
// Crypto
|
||||
key := ReadOrCreateKey(config)
|
||||
keyBytes := Unwrap(x509.MarshalPKCS8PrivateKey(key))
|
||||
sum := blake3.Sum512(keyBytes)
|
||||
keyB3Sum512 := sum[:]
|
||||
keyB3Sum256 := blake3.Sum256(keyBytes)
|
||||
keyB3Sum512 := blake3.Sum512(keyBytes)
|
||||
block, err := aes.NewCipher(keyB3Sum256[:])
|
||||
Check(err)
|
||||
aead, err := cipher.NewGCM(block)
|
||||
Check(err)
|
||||
|
||||
// Database
|
||||
db, err := OpenDB(config)
|
||||
Check(err)
|
||||
|
||||
@ -434,6 +457,39 @@ func setup(config *Config) *App {
|
||||
}
|
||||
}
|
||||
|
||||
// OIDC providers
|
||||
oidcProvidersByName := map[string]*OIDCProvider{}
|
||||
oidcProvidersByIssuer := map[string]*OIDCProvider{}
|
||||
scopes := []string{"openid", "email"}
|
||||
for _, oidcConfig := range config.RegistrationOIDC {
|
||||
options := []rp.Option{
|
||||
rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)),
|
||||
rp.WithHTTPClient(MakeHTTPClient()),
|
||||
rp.WithSigningAlgsFromDiscovery(),
|
||||
}
|
||||
escapedProviderName := url.PathEscape(oidcConfig.Name)
|
||||
redirectURI, err := url.JoinPath(config.BaseURL, "web", "oidc-callback", escapedProviderName)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating OIDC redirect URI: %s", err)
|
||||
}
|
||||
if oidcConfig.PKCE {
|
||||
cookieHandler := httphelper.NewCookieHandler(keyB3Sum256[:], keyB3Sum256[:], httphelper.WithSameSite(http.SameSiteLaxMode))
|
||||
options = append(options, rp.WithPKCE(cookieHandler))
|
||||
}
|
||||
relyingParty, err := rp.NewRelyingPartyOIDC(context.Background(), oidcConfig.Issuer, oidcConfig.ClientID, oidcConfig.ClientSecret, redirectURI, scopes, options...)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating OIDC relying party: %s", err)
|
||||
}
|
||||
|
||||
oidcProvider := OIDCProvider{
|
||||
RelyingParty: relyingParty,
|
||||
Config: oidcConfig,
|
||||
}
|
||||
|
||||
oidcProvidersByName[oidcConfig.Name] = &oidcProvider
|
||||
oidcProvidersByIssuer[oidcConfig.Issuer] = &oidcProvider
|
||||
}
|
||||
|
||||
app := &App{
|
||||
RequestCache: cache,
|
||||
Config: config,
|
||||
@ -442,9 +498,12 @@ func setup(config *Config) *App {
|
||||
Constants: Constants,
|
||||
DB: db,
|
||||
FSMutex: KeyedMutex{},
|
||||
Key: key,
|
||||
KeyB3Sum512: keyB3Sum512,
|
||||
PrivateKey: key,
|
||||
PrivateKeyB3Sum256: keyB3Sum256,
|
||||
PrivateKeyB3Sum512: keyB3Sum512,
|
||||
AEAD: aead,
|
||||
FrontEndURL: config.BaseURL,
|
||||
PublicURL: Unwrap(url.JoinPath(config.BaseURL, "web/public")),
|
||||
PlayerCertificateKeys: playerCertificateKeys,
|
||||
ProfilePropertyKeys: profilePropertyKeys,
|
||||
AccountURL: Unwrap(url.JoinPath(config.BaseURL, "account")),
|
||||
@ -453,6 +512,8 @@ func setup(config *Config) *App {
|
||||
SessionURL: Unwrap(url.JoinPath(config.BaseURL, "session")),
|
||||
AuthlibInjectorURL: Unwrap(url.JoinPath(config.BaseURL, "authlib-injector")),
|
||||
VerificationSkinTemplate: verificationSkinTemplate,
|
||||
OIDCProvidersByName: oidcProvidersByName,
|
||||
OIDCProvidersByIssuer: oidcProvidersByIssuer,
|
||||
}
|
||||
|
||||
// Post-setup
|
||||
|
90
model.go
90
model.go
@ -10,6 +10,8 @@ import (
|
||||
"github.com/samber/mo"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@ -103,7 +105,16 @@ func (app *App) ValidatePlayerName(playerName string) error {
|
||||
}
|
||||
|
||||
func (app *App) ValidateUsername(username string) error {
|
||||
return app.ValidatePlayerName(username)
|
||||
// Valid username are either valid player names or valid email addresses
|
||||
playerNameErr := app.ValidatePlayerName(username)
|
||||
if playerNameErr == nil {
|
||||
return nil
|
||||
}
|
||||
_, emailErr := mail.ParseAddress(username)
|
||||
if emailErr == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("neither a valid player name (%s) nor an email address", playerNameErr)
|
||||
}
|
||||
|
||||
func (app *App) ValidatePlayerNameOrUUID(player string) error {
|
||||
@ -299,6 +310,14 @@ func MakeAPIToken() (string, error) {
|
||||
return RandomBase62(16)
|
||||
}
|
||||
|
||||
func MakeMinecraftToken() (string, error) {
|
||||
random, err := RandomBase62(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "MC_" + random, nil
|
||||
}
|
||||
|
||||
type TokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Version int `json:"version"`
|
||||
@ -332,7 +351,7 @@ func (app *App) MakeAccessToken(client Client) (string, error) {
|
||||
Version: client.Version,
|
||||
StaleAt: jwt.NewNumericDate(staleAt),
|
||||
})
|
||||
return token.SignedString(app.Key)
|
||||
return token.SignedString(app.PrivateKey)
|
||||
}
|
||||
|
||||
type StaleTokenPolicy int
|
||||
@ -344,7 +363,7 @@ const (
|
||||
|
||||
func (app *App) GetClient(accessToken string, stalePolicy StaleTokenPolicy) *Client {
|
||||
token, err := jwt.ParseWithClaims(accessToken, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return app.Key.Public(), nil
|
||||
return app.PrivateKey.Public(), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
@ -384,51 +403,43 @@ func (app *App) GetMaxPlayerCount(user *User) int {
|
||||
type User struct {
|
||||
IsAdmin bool
|
||||
IsLocked bool
|
||||
UUID string `gorm:"primaryKey"`
|
||||
Username string `gorm:"unique;not null"`
|
||||
PasswordSalt []byte `gorm:"not null"`
|
||||
PasswordHash []byte `gorm:"not null"`
|
||||
UUID string `gorm:"primaryKey"`
|
||||
Username string `gorm:"unique;not null"`
|
||||
PasswordSalt []byte
|
||||
PasswordHash []byte
|
||||
BrowserToken sql.NullString `gorm:"index"`
|
||||
MinecraftToken string
|
||||
APIToken string
|
||||
PreferredLanguage string
|
||||
Players []Player
|
||||
MaxPlayerCount int
|
||||
Clients []Client
|
||||
OIDCIdentities []UserOIDCIdentity
|
||||
}
|
||||
|
||||
func (user *User) BeforeDelete(tx *gorm.DB) error {
|
||||
var players []Player
|
||||
if err := tx.Where("user_uuid = ?", user.UUID).Find(&players).Error; err != nil {
|
||||
if err := tx.Clauses(clause.Returning{}).Where("user_uuid = ?", user.UUID).Delete(&Player{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(players) > 0 {
|
||||
return tx.Delete(&players).Error
|
||||
}
|
||||
|
||||
var clients []Client
|
||||
if err := tx.Where("user_uuid = ?", user.UUID).Find(&clients).Error; err != nil {
|
||||
if err := tx.Clauses(clause.Returning{}).Where("user_uuid = ?", user.UUID).Delete(&Client{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(clients) > 0 {
|
||||
if err := tx.Delete(&clients).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Clauses(clause.Returning{}).Where("user_uuid = ?", user.UUID).Delete(&UserOIDCIdentity{}).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
|
||||
type UserOIDCIdentity struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
User User
|
||||
UserUUID string `gorm:"index;not null"`
|
||||
Subject string `gorm:"uniqueIndex:subject_issuer_unique_index;not null"`
|
||||
Issuer string `gorm:"uniqueIndex:subject_issuer_unique_index;not null"`
|
||||
}
|
||||
|
||||
func (UserOIDCIdentity) TableName() string {
|
||||
return "user_oidc_identities"
|
||||
}
|
||||
|
||||
func (player *Player) AfterFind(tx *gorm.DB) error {
|
||||
@ -439,12 +450,13 @@ func (player *Player) AfterFind(tx *gorm.DB) error {
|
||||
}
|
||||
|
||||
func (user *User) AfterFind(tx *gorm.DB) error {
|
||||
err := tx.Find(&user.Players, "user_uuid = ?", user.UUID).Error
|
||||
if err != nil {
|
||||
if err := tx.Find(&user.OIDCIdentities, "user_uuid = ?", user.UUID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
err = tx.Find(&user.Clients, "user_uuid = ?", user.UUID).Error
|
||||
if err != nil {
|
||||
if err := tx.Find(&user.Players, "user_uuid = ?", user.UUID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Find(&user.Clients, "user_uuid = ?", user.UUID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@ -462,8 +474,12 @@ type Player struct {
|
||||
ServerID sql.NullString
|
||||
FallbackPlayer string
|
||||
User User
|
||||
UserUUID string `gorm:"not null"`
|
||||
Clients []Client
|
||||
UserUUID string `gorm:"not null"`
|
||||
Clients []Client `gorm:"constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (player *Player) BeforeDelete(tx *gorm.DB) error {
|
||||
return tx.Clauses(clause.Returning{}).Where("player_uuid = ?", player.UUID).Delete(&Client{}).Error
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
|
16
player.go
16
player.go
@ -125,7 +125,7 @@ func (app *App) CreatePlayer(
|
||||
var err error
|
||||
details, err := app.ValidateChallenge(playerName, challengeToken)
|
||||
if err != nil {
|
||||
if app.Config.RegistrationExistingPlayer.RequireSkinVerification {
|
||||
if app.Config.ImportExistingPlayer.RequireSkinVerification {
|
||||
return Player{}, NewBadRequestUserError("Couldn't verify your skin, maybe try again: %s", err)
|
||||
} else {
|
||||
return Player{}, NewBadRequestUserError("Couldn't find your account, maybe try again: %s", err)
|
||||
@ -373,7 +373,7 @@ type ProxiedAccountDetails struct {
|
||||
}
|
||||
|
||||
func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*ProxiedAccountDetails, error) {
|
||||
base, err := url.Parse(app.Config.RegistrationExistingPlayer.AccountURL)
|
||||
base, err := url.Parse(app.Config.ImportExistingPlayer.AccountURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -400,9 +400,9 @@ func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*P
|
||||
return nil, err
|
||||
}
|
||||
|
||||
base, err = url.Parse(app.Config.RegistrationExistingPlayer.SessionURL)
|
||||
base, err = url.Parse(app.Config.ImportExistingPlayer.SessionURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid SessionURL %s: %s", app.Config.RegistrationExistingPlayer.SessionURL, err)
|
||||
return nil, fmt.Errorf("Invalid SessionURL %s: %s", app.Config.ImportExistingPlayer.SessionURL, err)
|
||||
}
|
||||
base.Path, err = url.JoinPath(base.Path, "session/minecraft/profile/"+idRes.ID)
|
||||
if err != nil {
|
||||
@ -435,7 +435,7 @@ func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*P
|
||||
Username: profileRes.Name,
|
||||
UUID: accountUUID,
|
||||
}
|
||||
if !app.Config.RegistrationExistingPlayer.RequireSkinVerification {
|
||||
if !app.Config.ImportExistingPlayer.RequireSkinVerification {
|
||||
return &details, nil
|
||||
}
|
||||
|
||||
@ -520,9 +520,9 @@ func (app *App) GetChallenge(playerName string, token string) []byte {
|
||||
// the verifying browser
|
||||
challengeBytes := bytes.Join([][]byte{
|
||||
[]byte(playerName),
|
||||
app.KeyB3Sum512,
|
||||
app.PrivateKeyB3Sum512[:],
|
||||
[]byte(token),
|
||||
}, []byte{})
|
||||
}, []byte{byte(0)})
|
||||
|
||||
sum := blake3.Sum512(challengeBytes)
|
||||
return sum[:]
|
||||
@ -583,7 +583,7 @@ func (app *App) InvalidateUser(db *gorm.DB, user *User) error {
|
||||
|
||||
func (app *App) DeletePlayer(caller *User, player *Player) error {
|
||||
if caller.UUID != player.UserUUID && !caller.IsAdmin {
|
||||
return NewForbiddenUserError("You don't own that player.")
|
||||
return NewUserError(http.StatusForbidden, "You don't own that player.")
|
||||
}
|
||||
|
||||
if err := app.DB.Delete(player).Error; err != nil {
|
||||
|
74
public/openid-logo.svg
Normal file
74
public/openid-logo.svg
Normal file
@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.0"
|
||||
width="320"
|
||||
height="120"
|
||||
viewBox="0 0 6440 8334"
|
||||
id="svg2114"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="openid-logo.svg"
|
||||
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="1.828125"
|
||||
inkscape:cx="159.45299"
|
||||
inkscape:cy="92.444444"
|
||||
inkscape:window-width="1291"
|
||||
inkscape:window-height="1056"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="g2189" /><defs
|
||||
id="defs2127">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</defs>
|
||||
|
||||
<g
|
||||
transform="matrix(69.45,0,0,69.45,-8759.476,-989.3969)"
|
||||
id="g2189"><g
|
||||
transform="matrix(1.031807e-2,0,0,1.031807e-2,143.3621,-90.91537)"
|
||||
id="g2202"><path
|
||||
d="M -3436.8,14543.2 C -4284.8,14015.2 -5468.8,13687.2 -6764.8,13687.2 C -9372.8,13687.2 -11484.8,14991.2 -11484.8,16599.2 C -11484.8,18071.2 -9724.8,19279.2 -7444.8,19487.2 L -7444.8,18639.2 C -8980.8,18447.2 -10132.8,17607.2 -10132.8,16599.2 C -10132.8,15455.2 -8628.8,14519.2 -6764.8,14519.2 C -5836.8,14519.2 -4996.8,14751.2 -4388.8,15127.2 L -5252.8,15663.2 L -2556.8,15663.2 L -2556.8,13999.2 L -3436.8,14543.2 z "
|
||||
style="fill:#ccc;fill-opacity:1"
|
||||
id="path2204" /><path
|
||||
d="M -7444.8,12247.2 L -7444.8,18639.2 L -7444.8,19487.2 L -6092.8,18639.2 L -6092.8,11375.2 L -7444.8,12247.2 z "
|
||||
style="fill:#ff6200;fill-opacity:1"
|
||||
id="path2206" /></g><g
|
||||
transform="matrix(1.377521e-2,0,0,1.377521e-2,142.3208,-135.7131)"
|
||||
id="g2208"><path
|
||||
d="M -1124.8,15343.2 C -1060.8,15119.2 -956.8,14927.2 -820.8,14759.2 C -676.8,14591.2 -508.8,14455.2 -300.8,14359.2 C -92.8,14255.2 147.2,14207.2 419.2,14207.2 C 699.2,14207.2 939.2,14255.2 1147.2,14359.2 C 1347.2,14455.2 1523.2,14591.2 1659.2,14759.2 C 1795.2,14927.2 1899.2,15119.2 1963.2,15343.2 C 2035.2,15559.2 2067.2,15791.2 2067.2,16031.2 C 2067.2,16271.2 2035.2,16503.2 1963.2,16727.2 C 1899.2,16943.2 1795.2,17135.2 1659.2,17303.2 C 1523.2,17471.2 1347.2,17607.2 1147.2,17703.2 C 939.2,17807.2 699.2,17855.2 419.2,17855.2 C 147.2,17855.2 -92.8,17807.2 -300.8,17703.2 C -508.8,17607.2 -676.8,17471.2 -820.8,17303.2 C -956.8,17135.2 -1060.8,16943.2 -1124.8,16727.2 C -1196.8,16503.2 -1228.8,16271.2 -1228.8,16031.2 C -1228.8,15791.2 -1196.8,15559.2 -1124.8,15343.2 M -820.8,16599.2 C -772.8,16783.2 -692.8,16943.2 -580.8,17095.2 C -476.8,17239.2 -340.8,17351.2 -172.8,17447.2 C -4.8,17535.2 187.2,17575.2 419.2,17575.2 C 651.2,17575.2 851.2,17535.2 1019.2,17447.2 C 1187.2,17351.2 1315.2,17239.2 1427.2,17095.2 C 1531.2,16943.2 1611.2,16783.2 1659.2,16599.2 C 1707.2,16415.2 1739.2,16223.2 1739.2,16031.2 C 1739.2,15839.2 1707.2,15655.2 1659.2,15471.2 C 1611.2,15287.2 1531.2,15119.2 1427.2,14975.2 C 1315.2,14831.2 1187.2,14711.2 1019.2,14623.2 C 851.2,14535.2 651.2,14495.2 419.2,14495.2 C 187.2,14495.2 -4.8,14535.2 -172.8,14623.2 C -340.8,14711.2 -476.8,14831.2 -580.8,14975.2 C -692.8,15119.2 -772.8,15287.2 -820.8,15471.2 C -868.8,15655.2 -892.8,15839.2 -892.8,16031.2 C -892.8,16223.2 -868.8,16415.2 -820.8,16599.2 z "
|
||||
style="fill:#ff6200;fill-opacity:1"
|
||||
id="path2210" /><path
|
||||
d="M 2563.2,15255.2 L 2563.2,15735.2 L 2571.2,15735.2 C 2643.2,15559.2 2763.2,15423.2 2923.2,15327.2 C 3083.2,15231.2 3267.2,15183.2 3475.2,15183.2 C 3667.2,15183.2 3835.2,15215.2 3979.2,15287.2 C 4123.2,15359.2 4243.2,15455.2 4339.2,15583.2 C 4427.2,15703.2 4499.2,15847.2 4547.2,16007.2 C 4595.2,16167.2 4619.2,16343.2 4619.2,16519.2 C 4619.2,16703.2 4595.2,16871.2 4547.2,17031.2 C 4499.2,17191.2 4427.2,17335.2 4339.2,17455.2 C 4243.2,17583.2 4123.2,17679.2 3979.2,17751.2 C 3835.2,17815.2 3667.2,17855.2 3475.2,17855.2 C 3379.2,17855.2 3291.2,17839.2 3195.2,17823.2 C 3107.2,17799.2 3019.2,17759.2 2947.2,17719.2 C 2867.2,17671.2 2795.2,17615.2 2731.2,17543.2 C 2675.2,17479.2 2627.2,17399.2 2595.2,17303.2 L 2587.2,17303.2 L 2587.2,18711.2 L 2275.2,18711.2 L 2275.2,15255.2 L 2563.2,15255.2 M 4259.2,16135.2 C 4227.2,15999.2 4179.2,15887.2 4115.2,15783.2 C 4043.2,15687.2 3955.2,15599.2 3851.2,15535.2 C 3747.2,15471.2 3627.2,15447.2 3475.2,15447.2 C 3307.2,15447.2 3163.2,15471.2 3051.2,15535.2 C 2931.2,15591.2 2843.2,15671.2 2771.2,15767.2 C 2707.2,15863.2 2659.2,15983.2 2627.2,16111.2 C 2603.2,16239.2 2587.2,16375.2 2587.2,16519.2 C 2587.2,16655.2 2603.2,16783.2 2635.2,16911.2 C 2667.2,17039.2 2715.2,17159.2 2787.2,17255.2 C 2859.2,17359.2 2947.2,17439.2 3059.2,17503.2 C 3171.2,17567.2 3315.2,17599.2 3475.2,17599.2 C 3627.2,17599.2 3747.2,17567.2 3851.2,17503.2 C 3955.2,17439.2 4043.2,17359.2 4115.2,17255.2 C 4179.2,17159.2 4227.2,17039.2 4259.2,16911.2 C 4291.2,16783.2 4307.2,16655.2 4307.2,16519.2 C 4307.2,16391.2 4291.2,16263.2 4259.2,16135.2 z "
|
||||
style="fill:#ff6200;fill-opacity:1"
|
||||
id="path2212" /><path
|
||||
d="M 5139.2,16951.2 C 5171.2,17071.2 5219.2,17175.2 5291.2,17271.2 C 5355.2,17367.2 5443.2,17447.2 5547.2,17503.2 C 5651.2,17567.2 5779.2,17599.2 5923.2,17599.2 C 6147.2,17599.2 6323.2,17543.2 6451.2,17423.2 C 6579.2,17303.2 6667.2,17151.2 6715.2,16951.2 L 7027.2,16951.2 C 6963.2,17239.2 6843.2,17463.2 6667.2,17615.2 C 6491.2,17775.2 6243.2,17855.2 5923.2,17855.2 C 5723.2,17855.2 5555.2,17815.2 5411.2,17751.2 C 5259.2,17679.2 5147.2,17583.2 5051.2,17455.2 C 4963.2,17335.2 4891.2,17191.2 4851.2,17031.2 C 4803.2,16871.2 4787.2,16703.2 4787.2,16519.2 C 4787.2,16351.2 4803.2,16191.2 4851.2,16031.2 C 4891.2,15871.2 4963.2,15727.2 5051.2,15599.2 C 5147.2,15471.2 5259.2,15375.2 5411.2,15295.2 C 5555.2,15223.2 5723.2,15183.2 5923.2,15183.2 C 6123.2,15183.2 6299.2,15223.2 6443.2,15303.2 C 6587.2,15383.2 6707.2,15495.2 6795.2,15623.2 C 6883.2,15759.2 6947.2,15911.2 6987.2,16079.2 C 7027.2,16247.2 7043.2,16423.2 7035.2,16599.2 L 5091.2,16599.2 C 5091.2,16711.2 5107.2,16831.2 5139.2,16951.2 M 6667.2,16007.2 C 6627.2,15895.2 6571.2,15799.2 6507.2,15719.2 C 6435.2,15639.2 6355.2,15567.2 6259.2,15519.2 C 6155.2,15471.2 6051.2,15447.2 5923.2,15447.2 C 5795.2,15447.2 5683.2,15471.2 5587.2,15519.2 C 5491.2,15567.2 5403.2,15639.2 5339.2,15719.2 C 5267.2,15799.2 5211.2,15895.2 5171.2,16007.2 C 5131.2,16111.2 5107.2,16223.2 5091.2,16343.2 L 6723.2,16343.2 C 6723.2,16223.2 6699.2,16111.2 6667.2,16007.2 z "
|
||||
style="fill:#ff6200;fill-opacity:1"
|
||||
id="path2214" /><path
|
||||
d="M 7499.2,15255.2 L 7499.2,15687.2 L 7507.2,15687.2 C 7571.2,15535.2 7675.2,15415.2 7827.2,15319.2 C 7971.2,15231.2 8139.2,15183.2 8323.2,15183.2 C 8499.2,15183.2 8643.2,15207.2 8763.2,15247.2 C 8883.2,15295.2 8979.2,15359.2 9051.2,15447.2 C 9123.2,15527.2 9171.2,15631.2 9203.2,15751.2 C 9235.2,15871.2 9243.2,16007.2 9243.2,16159.2 L 9243.2,17783.2 L 8939.2,17783.2 L 8939.2,16207.2 C 8939.2,16095.2 8931.2,15999.2 8907.2,15903.2 C 8891.2,15815.2 8851.2,15735.2 8803.2,15663.2 C 8755.2,15591.2 8691.2,15543.2 8603.2,15503.2 C 8523.2,15463.2 8419.2,15447.2 8299.2,15447.2 C 8171.2,15447.2 8059.2,15463.2 7963.2,15511.2 C 7867.2,15551.2 7787.2,15615.2 7723.2,15687.2 C 7651.2,15767.2 7603.2,15855.2 7563.2,15967.2 C 7523.2,16071.2 7507.2,16183.2 7499.2,16311.2 L 7499.2,17783.2 L 7195.2,17783.2 L 7195.2,15255.2 L 7499.2,15255.2 z "
|
||||
style="fill:#ff6200;fill-opacity:1"
|
||||
id="path2216" /><path
|
||||
d="M 9835.2,14287.2 L 9835.2,17783.2 L 9507.2,17783.2 L 9507.2,14287.2 L 9835.2,14287.2 z "
|
||||
style="fill:#ff6200;fill-opacity:1"
|
||||
id="path2218" /><path
|
||||
d="M 11299.2,14287.2 C 11835.2,14295.2 12235.2,14447.2 12507.2,14735.2 C 12771.2,15023.2 12907.2,15455.2 12907.2,16031.2 C 12907.2,16615.2 12771.2,17047.2 12507.2,17335.2 C 12235.2,17623.2 11835.2,17767.2 11299.2,17783.2 L 10091.2,17783.2 L 10091.2,14287.2 L 11299.2,14287.2 M 11139.2,17495.2 C 11387.2,17495.2 11603.2,17471.2 11787.2,17415.2 C 11963.2,17359.2 12115.2,17279.2 12235.2,17159.2 C 12347.2,17039.2 12435.2,16887.2 12491.2,16703.2 C 12547.2,16519.2 12571.2,16295.2 12571.2,16031.2 C 12571.2,15775.2 12547.2,15551.2 12491.2,15367.2 C 12435.2,15175.2 12347.2,15023.2 12235.2,14911.2 C 12115.2,14791.2 11963.2,14703.2 11787.2,14655.2 C 11603.2,14599.2 11387.2,14567.2 11139.2,14567.2 L 10427.2,14567.2 L 10427.2,17495.2 L 11139.2,17495.2 z "
|
||||
style="fill:#ff6200;fill-opacity:1"
|
||||
id="path2220" /></g></g></svg>
|
After Width: | Height: | Size: 8.5 KiB |
@ -39,6 +39,10 @@ td:not(:first-child) {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
thead {
|
||||
font-weight: bold;
|
||||
}
|
||||
@ -59,6 +63,22 @@ a:visited {
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.divider::before, .divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
border-top: 1px solid white;
|
||||
}
|
||||
.openid-logo {
|
||||
height: 3rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
color: white;
|
||||
@ -149,9 +169,9 @@ input[type="file"]::file-selector-button {
|
||||
0 0 0 var(--button-highlight-size) black;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
input[type="submit"]:hover,
|
||||
input[type="file"]::file-selector-button:hover {
|
||||
button:hover:not(:disabled),
|
||||
input[type="submit"]:hover:not(:disabled),
|
||||
input[type="file"]::file-selector-button:hover:not(:disabled) {
|
||||
box-shadow:
|
||||
0 var(--button-shadow-size) 0 0 var(--button-shadow-light) inset,
|
||||
var(--button-shadow-size) 0 0 0 var(--button-shadow-light) inset,
|
||||
@ -160,9 +180,9 @@ input[type="file"]::file-selector-button:hover {
|
||||
0 0 0 var(--button-highlight-size) var(--accent-light);
|
||||
}
|
||||
|
||||
button:active,
|
||||
input[type="submit"]:active,
|
||||
input[type="file"]::file-selector-button:active {
|
||||
button:active:not(:disabled),
|
||||
input[type="submit"]:active:not(:disabled),
|
||||
input[type="file"]::file-selector-button:active:not(:disabled) {
|
||||
box-shadow:
|
||||
0 var(--button-shadow-size) 0 0 var(--button-shadow-dark) inset,
|
||||
var(--button-shadow-size) 0 0 0 var(--button-shadow-dark) inset,
|
||||
|
21
services.go
21
services.go
@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/samber/mo"
|
||||
"gorm.io/gorm"
|
||||
"math/big"
|
||||
"net/http"
|
||||
@ -27,22 +28,22 @@ func withBearerAuthentication(app *App, f func(c echo.Context, user *User, playe
|
||||
return func(c echo.Context) error {
|
||||
authorizationHeader := c.Request().Header.Get("Authorization")
|
||||
if authorizationHeader == "" {
|
||||
return c.JSON(http.StatusUnauthorized, ErrorResponse{Path: Ptr(c.Request().URL.Path)})
|
||||
return &YggdrasilError{Code: http.StatusUnauthorized}
|
||||
}
|
||||
|
||||
accessTokenMatch := bearerExp.FindStringSubmatch(authorizationHeader)
|
||||
if accessTokenMatch == nil || len(accessTokenMatch) < 2 {
|
||||
return c.JSON(http.StatusUnauthorized, ErrorResponse{Path: Ptr(c.Request().URL.Path)})
|
||||
return &YggdrasilError{Code: http.StatusUnauthorized}
|
||||
}
|
||||
accessToken := accessTokenMatch[1]
|
||||
|
||||
client := app.GetClient(accessToken, StalePolicyAllow)
|
||||
if client == nil {
|
||||
return c.JSON(http.StatusUnauthorized, ErrorResponse{Path: Ptr(c.Request().URL.Path)})
|
||||
return &YggdrasilError{Code: http.StatusUnauthorized}
|
||||
}
|
||||
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 &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("Access token does not have a selected profile.")}
|
||||
}
|
||||
|
||||
return f(c, &client.User, player)
|
||||
@ -327,30 +328,30 @@ func ServicesPlayerCertificates(app *App) func(c echo.Context) error {
|
||||
func ServicesUploadSkin(app *App) func(c echo.Context) error {
|
||||
return withBearerAuthentication(app, func(c echo.Context, _ *User, player *Player) error {
|
||||
if !app.Config.AllowSkins {
|
||||
return MakeErrorResponse(&c, http.StatusBadRequest, nil, Ptr("Changing your skin is not allowed."))
|
||||
return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("Changing your skin is not allowed.")}
|
||||
}
|
||||
|
||||
model := strings.ToLower(c.FormValue("variant"))
|
||||
|
||||
if !IsValidSkinModel(model) {
|
||||
return MakeErrorResponse(&c, http.StatusBadRequest, nil, Ptr("Invalid request body for skin upload"))
|
||||
return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("Invalid request body for skin upload")}
|
||||
}
|
||||
player.SkinModel = model
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return MakeErrorResponse(&c, http.StatusBadRequest, nil, Ptr("content is marked non-null but is null"))
|
||||
return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("content is marked non-null but is null")}
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return MakeErrorResponse(&c, http.StatusBadRequest, nil, Ptr("content is marked non-null but is null"))
|
||||
return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("content is marked non-null but is null")}
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
err = app.SetSkinAndSave(player, src)
|
||||
if err != nil {
|
||||
return MakeErrorResponse(&c, http.StatusBadRequest, nil, Ptr("Could not read image data."))
|
||||
return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("Could not read image data.")}
|
||||
}
|
||||
|
||||
servicesProfile, err := getServicesProfile(app, player)
|
||||
@ -452,7 +453,7 @@ func ServicesNameAvailability(app *App) func(c echo.Context) error {
|
||||
}
|
||||
if err := app.ValidatePlayerName(playerName); err != nil {
|
||||
errorMessage := fmt.Sprintf("checkNameAvailability.profileName: %s, checkNameAvailability.profileName: Invalid profile name", err.Error())
|
||||
return MakeErrorResponse(&c, http.StatusBadRequest, Ptr("CONSTRAINT_VIOLATION"), Ptr(errorMessage))
|
||||
return &YggdrasilError{Code: http.StatusBadRequest, Error_: mo.Some("CONSTRAINT_VIOLATION"), ErrorMessage: mo.Some(errorMessage)}
|
||||
}
|
||||
var otherPlayer Player
|
||||
result := app.DB.First(&otherPlayer, "name = ?", playerName)
|
||||
|
@ -136,7 +136,7 @@ func (ts *TestSuite) testServicesPlayerAttributes(t *testing.T) {
|
||||
rec := ts.Get(t, ts.Server, "/player/attributes", nil, Ptr("invalid"))
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
}
|
||||
}
|
||||
@ -161,7 +161,7 @@ func (ts *TestSuite) testServicesPlayerCertificates(t *testing.T) {
|
||||
rec := ts.PostForm(t, ts.Server, "/player/certificates", url.Values{}, nil, Ptr("invalid"))
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
}
|
||||
}
|
||||
@ -195,7 +195,7 @@ func (ts *TestSuite) testServicesUploadSkin(t *testing.T) {
|
||||
rec := ts.PostMultipart(t, ts.Server, "/minecraft/profile/skins", body, writer, nil, &accessToken)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, "Could not read image data.", *response.ErrorMessage)
|
||||
}
|
||||
@ -209,7 +209,7 @@ func (ts *TestSuite) testServicesUploadSkin(t *testing.T) {
|
||||
rec := ts.PostMultipart(t, ts.Server, "/minecraft/profile/skins", body, writer, nil, &accessToken)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, "content is marked non-null but is null", *response.ErrorMessage)
|
||||
}
|
||||
@ -227,7 +227,7 @@ func (ts *TestSuite) testServicesUploadSkin(t *testing.T) {
|
||||
rec := ts.PostMultipart(t, ts.Server, "/minecraft/profile/skins", body, writer, nil, &accessToken)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, "Could not read image data.", *response.ErrorMessage)
|
||||
}
|
||||
@ -249,7 +249,7 @@ func (ts *TestSuite) testServicesUploadSkinSkinsNotAllowed(t *testing.T) {
|
||||
rec := ts.PostMultipart(t, ts.Server, "/minecraft/profile/skins", body, writer, nil, &accessToken)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, "Changing your skin is not allowed.", *response.ErrorMessage)
|
||||
}
|
||||
@ -430,7 +430,7 @@ func (ts *TestSuite) testServicesNameAvailability(t *testing.T) {
|
||||
rec := ts.Get(t, ts.Server, "/minecraft/profile/name/"+playerName+"/available", nil, &accessToken)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
|
||||
assert.Equal(t, "CONSTRAINT_VIOLATION", *response.Error)
|
||||
|
11
session.go
11
session.go
@ -2,8 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/samber/mo"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"net/http"
|
||||
@ -28,7 +30,7 @@ func SessionJoin(app *App) func(c echo.Context) error {
|
||||
|
||||
client := app.GetClient(req.AccessToken, StalePolicyDeny)
|
||||
if client == nil {
|
||||
return c.JSONBlob(http.StatusForbidden, invalidAccessTokenBlob)
|
||||
return &YggdrasilError{Code: http.StatusForbidden, Error_: mo.Some("ForbiddenOperationException")}
|
||||
}
|
||||
|
||||
player := client.Player
|
||||
@ -224,9 +226,10 @@ func SessionProfile(app *App, fromAuthlibInjector bool) func(c echo.Context) err
|
||||
if err != nil {
|
||||
_, err = uuid.Parse(id)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||
ErrorMessage: Ptr("Not a valid UUID: " + c.Param("id")),
|
||||
})
|
||||
return &YggdrasilError{
|
||||
Code: http.StatusBadRequest,
|
||||
ErrorMessage: mo.Some(fmt.Sprintf("Not a valid UUID: %s", c.Param("id"))),
|
||||
}
|
||||
}
|
||||
uuid_ = id
|
||||
}
|
||||
|
@ -76,10 +76,9 @@ func (ts *TestSuite) testSessionJoin(t *testing.T) {
|
||||
rec := ts.PostJSON(t, ts.Server, "/session/minecraft/join", payload, nil, nil)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, "ForbiddenOperationException", *response.Error)
|
||||
assert.Equal(t, "Invalid token.", *response.ErrorMessage)
|
||||
|
||||
// Player ServerID should be invalid
|
||||
assert.Nil(t, ts.App.DB.First(&player, "name = ?", TEST_PLAYER_NAME).Error)
|
||||
@ -202,7 +201,7 @@ func (ts *TestSuite) testSessionProfile(t *testing.T) {
|
||||
rec := ts.Get(t, ts.Server, url, nil, nil)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var response ErrorResponse
|
||||
var response YggdrasilErrorResponse
|
||||
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
|
||||
assert.Equal(t, "Not a valid UUID: "+"invalid", *response.ErrorMessage)
|
||||
}
|
||||
|
144
swagger.json
144
swagger.json
@ -216,6 +216,90 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/drasl/api/v2/oidc-identities": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Link an OIDC identity to a user",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.APIError"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.APIError"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.APIError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Unlink an OIDC identity from a user",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.APIError"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.APIError"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.APIError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/drasl/api/v2/players": {
|
||||
"get": {
|
||||
"description": "Get details of all players. Requires admin privileges.",
|
||||
@ -402,6 +486,12 @@
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/main.APIError"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
@ -1032,8 +1122,14 @@
|
||||
"type": "integer",
|
||||
"example": 3
|
||||
},
|
||||
"oidcIdentities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/main.APIOIDCIdentitySpec"
|
||||
}
|
||||
},
|
||||
"password": {
|
||||
"description": "Plaintext password",
|
||||
"description": "Plaintext password. Not needed if OIDCIdentitySpecs are supplied.",
|
||||
"type": "string",
|
||||
"example": "hunter2"
|
||||
},
|
||||
@ -1139,6 +1235,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"main.APIOIDCIdentity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"issuer": {
|
||||
"type": "string",
|
||||
"example": "https://idm.example.com/oauth2/openid/drasl"
|
||||
},
|
||||
"oidcProviderName": {
|
||||
"type": "string",
|
||||
"example": "Kanidm"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"example": "f85f8c18-9bdf-49ad-a76e-719f9ba3ed25"
|
||||
},
|
||||
"userUuid": {
|
||||
"type": "string",
|
||||
"example": "918bd04e-1bc4-4ccd-860f-60c15c5f1cec"
|
||||
}
|
||||
}
|
||||
},
|
||||
"main.APIOIDCIdentitySpec": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"issuer": {
|
||||
"type": "string",
|
||||
"example": "https://idm.example.com/oauth2/openid/drasl"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"example": "f85f8c18-9bdf-49ad-a76e-719f9ba3ed25"
|
||||
}
|
||||
}
|
||||
},
|
||||
"main.APIPlayer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -1273,6 +1403,11 @@
|
||||
"description": "Pass `true` to reset the user's API token",
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"resetMinecraftToken": {
|
||||
"description": "Pass `true` to reset the user's Minecraft token",
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -1294,6 +1429,13 @@
|
||||
"type": "integer",
|
||||
"example": 3
|
||||
},
|
||||
"oidcIdentities": {
|
||||
"description": "OIDC identities linked to the user",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/main.APIOIDCIdentity"
|
||||
}
|
||||
},
|
||||
"players": {
|
||||
"description": "A user can have multiple players.",
|
||||
"type": "array",
|
||||
|
@ -163,7 +163,8 @@ func (ts *TestSuite) CreateTestUser(t *testing.T, app *App, server *echo.Echo, u
|
||||
user, err := app.CreateUser(
|
||||
&GOD, // caller
|
||||
username,
|
||||
TEST_PASSWORD, // password
|
||||
Ptr(TEST_PASSWORD), // password
|
||||
PotentiallyInsecure[[]OIDCIdentitySpec]{Value: []OIDCIdentitySpec{}},
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
@ -181,6 +182,9 @@ func (ts *TestSuite) CreateTestUser(t *testing.T, app *App, server *echo.Echo, u
|
||||
nil,
|
||||
)
|
||||
assert.Nil(t, err)
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
@ -191,7 +195,7 @@ func (ts *TestSuite) CreateTestUser(t *testing.T, app *App, server *echo.Echo, u
|
||||
rec := httptest.NewRecorder()
|
||||
server.ServeHTTP(rec, req)
|
||||
|
||||
browserToken := getCookie(rec, "browserToken")
|
||||
browserToken := getCookie(rec, BROWSER_TOKEN_COOKIE_NAME)
|
||||
assert.NotNil(t, browserToken)
|
||||
|
||||
assert.Nil(t, app.DB.First(&user, "username = ?", user.Username).Error)
|
||||
@ -213,14 +217,17 @@ func (ts *TestSuite) Get(t *testing.T, server *echo.Echo, path string, cookies [
|
||||
return rec
|
||||
}
|
||||
|
||||
func (ts *TestSuite) Delete(t *testing.T, server *echo.Echo, path string, cookies []http.Cookie, accessToken *string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodDelete, path, nil)
|
||||
func (ts *TestSuite) Delete(t *testing.T, server *echo.Echo, path string, payload interface{}, cookies []http.Cookie, accessToken *string) *httptest.ResponseRecorder {
|
||||
body, err := json.Marshal(payload)
|
||||
assert.Nil(t, err)
|
||||
req := httptest.NewRequest(http.MethodDelete, path, bytes.NewBuffer(body))
|
||||
for _, cookie := range cookies {
|
||||
req.AddCookie(&cookie)
|
||||
}
|
||||
if accessToken != nil {
|
||||
req.Header.Add("Authorization", "Bearer "+*accessToken)
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
server.ServeHTTP(rec, req)
|
||||
ts.CheckAuthlibInjectorHeader(t, ts.App, rec)
|
||||
|
259
user.go
259
user.go
@ -2,9 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
"gorm.io/gorm"
|
||||
"io"
|
||||
"net/http"
|
||||
@ -20,10 +24,38 @@ 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,
|
||||
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,
|
||||
@ -42,21 +74,46 @@ func (app *App) CreateUser(
|
||||
) (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 err := app.ValidatePassword(password); err != nil {
|
||||
return User{}, NewBadRequestUserError("Invalid password: %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
|
||||
} else {
|
||||
if *playerName != username && !app.Config.AllowChangingPlayerName && !callerIsAdmin {
|
||||
return User{}, NewBadRequestUserError("Choosing a player name different from your username is not allowed.")
|
||||
}
|
||||
if err := app.ValidatePlayerName(*playerName); err != nil {
|
||||
return User{}, NewBadRequestUserError("Invalid player name: %s", err)
|
||||
}
|
||||
}
|
||||
if err := app.ValidatePlayerName(*playerName); err != nil {
|
||||
return User{}, NewBadRequestUserError("Invalid player name: %s", err)
|
||||
}
|
||||
|
||||
if preferredLanguage == nil {
|
||||
@ -105,7 +162,7 @@ func (app *App) CreateUser(
|
||||
|
||||
details, err := app.ValidateChallenge(*playerName, challengeToken)
|
||||
if err != nil {
|
||||
if app.Config.RegistrationExistingPlayer.RequireSkinVerification {
|
||||
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)
|
||||
@ -143,15 +200,18 @@ func (app *App) CreateUser(
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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 {
|
||||
@ -179,16 +239,23 @@ func (app *App) CreateUser(
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
minecraftToken, err := MakeMinecraftToken()
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
user := User{
|
||||
IsAdmin: Contains(app.Config.DefaultAdmins, username) || isAdmin,
|
||||
IsLocked: isLocked,
|
||||
UUID: uuid.New().String(),
|
||||
UUID: userUUID,
|
||||
Username: username,
|
||||
PasswordSalt: passwordSalt,
|
||||
PasswordHash: passwordHash,
|
||||
PreferredLanguage: app.Config.DefaultPreferredLanguage,
|
||||
MaxPlayerCount: maxPlayerCountInt,
|
||||
APIToken: apiToken,
|
||||
MinecraftToken: minecraftToken,
|
||||
OIDCIdentities: oidcIdentities,
|
||||
}
|
||||
|
||||
// Player
|
||||
@ -287,7 +354,9 @@ func (app *App) CreateUser(
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (app *App) Login(username string, password string) (User, error) {
|
||||
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 {
|
||||
@ -297,6 +366,36 @@ func (app *App) Login(username string, password string) (User, error) {
|
||||
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
|
||||
@ -307,7 +406,7 @@ func (app *App) Login(username string, password string) (User, error) {
|
||||
}
|
||||
|
||||
if user.IsLocked {
|
||||
return User{}, NewForbiddenUserError("User is locked.")
|
||||
return User{}, NewUserError(http.StatusForbidden, "User is locked.")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
@ -321,6 +420,7 @@ func (app *App) UpdateUser(
|
||||
isAdmin *bool,
|
||||
isLocked *bool,
|
||||
resetAPIToken bool,
|
||||
resetMinecraftToken bool,
|
||||
preferredLanguage *string,
|
||||
maxPlayerCount *int,
|
||||
) (User, error) {
|
||||
@ -377,6 +477,14 @@ func (app *App) UpdateUser(
|
||||
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.")
|
||||
@ -428,7 +536,7 @@ func (app *App) SetIsLocked(db *gorm.DB, user *User, isLocked bool) error {
|
||||
|
||||
func (app *App) DeleteUser(caller *User, user *User) error {
|
||||
if !caller.IsAdmin && caller.UUID != user.UUID {
|
||||
return NewForbiddenUserError("You are not an admin.")
|
||||
return NewUserError(http.StatusForbidden, "You are not an admin.")
|
||||
}
|
||||
|
||||
oldSkinHashes := make([]*string, 0, len(user.Players))
|
||||
@ -463,3 +571,106 @@ func (app *App) DeleteUser(caller *User, user *User) error {
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
10
util.go
10
util.go
@ -15,6 +15,12 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Wrap arguments that may introduce security issues so the caller is aware to
|
||||
// take additional precautions
|
||||
type PotentiallyInsecure[T any] struct {
|
||||
Value T
|
||||
}
|
||||
|
||||
func Check(e error) {
|
||||
if e != nil {
|
||||
log.Fatal(e)
|
||||
@ -121,7 +127,7 @@ func SignSHA256(app *App, plaintext []byte) ([]byte, error) {
|
||||
hash.Write(plaintext)
|
||||
sum := hash.Sum(nil)
|
||||
|
||||
return rsa.SignPKCS1v15(rand.Reader, app.Key, crypto.SHA256, sum)
|
||||
return rsa.SignPKCS1v15(rand.Reader, app.PrivateKey, crypto.SHA256, sum)
|
||||
}
|
||||
|
||||
func SignSHA1(app *App, plaintext []byte) ([]byte, error) {
|
||||
@ -129,7 +135,7 @@ func SignSHA1(app *App, plaintext []byte) ([]byte, error) {
|
||||
hash.Write(plaintext)
|
||||
sum := hash.Sum(nil)
|
||||
|
||||
return rsa.SignPKCS1v15(rand.Reader, app.Key, crypto.SHA1, sum)
|
||||
return rsa.SignPKCS1v15(rand.Reader, app.PrivateKey, crypto.SHA1, sum)
|
||||
}
|
||||
|
||||
type KeyedMutex struct {
|
||||
|
@ -71,7 +71,15 @@
|
||||
method="post"
|
||||
onsubmit="return confirm('Are you sure you want to delete the account “{{ $user.Username }}”? This action is irreversible.');"
|
||||
>
|
||||
<input hidden name="returnUrl" value="{{ $.URL }}" />
|
||||
<input
|
||||
hidden
|
||||
name="returnUrl"
|
||||
value="{{ if eq $.User.UUID $user.UUID }}
|
||||
{{ $.App.FrontEndURL }}
|
||||
{{ else }}
|
||||
{{ $.URL }}
|
||||
{{ end }}"
|
||||
/>
|
||||
<input hidden type="text" name="uuid" value="{{ $user.UUID }}" />
|
||||
</form>
|
||||
{{ end }}
|
||||
@ -117,7 +125,10 @@
|
||||
<input
|
||||
name="max-player-count-{{ $user.UUID }}"
|
||||
type="number"
|
||||
{{ if $user.IsAdmin }}disabled{{ end }}
|
||||
{{ if $user.IsAdmin }}
|
||||
title="Admins can always create unlimited players"
|
||||
disabled
|
||||
{{ end }}
|
||||
value="{{ if or $user.IsAdmin (eq $user.MaxPlayerCount $.App.Constants.MaxPlayerCountUnlimited) }}-1{{ else if eq $user.MaxPlayerCount $.App.Constants.MaxPlayerCountUseDefault}}{{ else }}{{ $user.MaxPlayerCount }}{{ end }}"
|
||||
placeholder="{{ $.App.Config.DefaultMaxPlayerCount }}"
|
||||
min="-1">
|
||||
|
@ -4,14 +4,14 @@
|
||||
|
||||
<p>
|
||||
We need to verify that you own the
|
||||
{{ .App.Config.RegistrationExistingPlayer.Nickname }} account
|
||||
{{ .App.Config.ImportExistingPlayer.Nickname }} account
|
||||
"{{ .PlayerName }}" before you register its UUID.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Download this image and set it as your skin on your
|
||||
{{ .App.Config.RegistrationExistingPlayer.Nickname }}
|
||||
account{{ if .App.Config.RegistrationExistingPlayer.SetSkinURL }}, <a target="_blank" href="{{ .App.Config.RegistrationExistingPlayer.SetSkinURL }}">here</a>{{ end }}.
|
||||
{{ .App.Config.ImportExistingPlayer.Nickname }}
|
||||
account{{ if .App.Config.ImportExistingPlayer.SetSkinURL }}, <a target="_blank" href="{{ .App.Config.ImportExistingPlayer.SetSkinURL }}">here</a>{{ end }}.
|
||||
</p>
|
||||
|
||||
<div style="text-align: center">
|
||||
@ -33,20 +33,24 @@
|
||||
|
||||
|
||||
{{ if eq .Action "register" }}
|
||||
<p>
|
||||
When you are done, enter a password for your {{ .App.Config.ApplicationName }} account and hit
|
||||
"Register".
|
||||
</p>
|
||||
{{ if .UseIDToken }}
|
||||
<p>
|
||||
When you are done, hit "Register".
|
||||
</p>
|
||||
{{ else }}
|
||||
<p>
|
||||
When you are done, enter a password for your {{ .App.Config.ApplicationName }} account and hit
|
||||
"Register".
|
||||
</p>
|
||||
{{ end }}
|
||||
<form action="{{ .App.FrontEndURL }}/web/register" method="post">
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value="{{ .PlayerName }}"
|
||||
required
|
||||
hidden
|
||||
<input hidden type="text" name="playerName" value="{{ .PlayerName }}"
|
||||
/>
|
||||
<input type="password" name="password" placeholder="Password" required />
|
||||
<input type="checkbox" name="existingPlayer" checked hidden />
|
||||
{{ if not .UseIDToken }}
|
||||
<input type="password" name="password" placeholder="Password" required />
|
||||
{{ end }}
|
||||
<input hidden type="checkbox" name="existingPlayer" checked />
|
||||
<input hidden type="checkbox" name="useIdToken" {{ if .UseIDToken }}checked{{ end }} />
|
||||
<input hidden name="challengeToken" value="{{ .ChallengeToken }}" />
|
||||
<input hidden name="inviteCode" value="{{ .InviteCode }}" />
|
||||
<input hidden name="returnUrl" value="{{ .URL }}" />
|
||||
@ -59,7 +63,7 @@
|
||||
<form action="{{ .App.FrontEndURL }}/web/create-player" method="post">
|
||||
<input hidden name="userUuid" value="{{ .UserUUID }}"/>
|
||||
<input hidden name="playerName" value="{{ .PlayerName }}"/>
|
||||
<input type="checkbox" name="existingPlayer" checked hidden />
|
||||
<input hidden type="checkbox" name="existingPlayer" checked />
|
||||
<input hidden name="challengeToken" value="{{ .ChallengeToken }}" />
|
||||
<input hidden name="returnUrl" value="{{ .URL }}" />
|
||||
<input type="submit" value="Create player" />
|
||||
|
182
view/complete-registration.tmpl
Normal file
182
view/complete-registration.tmpl
Normal file
@ -0,0 +1,182 @@
|
||||
{{ template "layout" . }}
|
||||
|
||||
{{ define "title" }}Complete Registration - {{ .App.Config.ApplicationName }}{{ end }}
|
||||
|
||||
{{ define
|
||||
"content"
|
||||
}}
|
||||
{{ template "header" . }}
|
||||
|
||||
{{ $dividerNeeded := false }}
|
||||
|
||||
{{ if .AnyUnmigratedUsers }}
|
||||
{{ if $dividerNeeded }}
|
||||
<div class="divider">or</div>
|
||||
{{ $dividerNeeded = false }}
|
||||
{{ end }}
|
||||
<h3>Migrate an existing user</h3>
|
||||
|
||||
<p>You can link this identity provider to an existing {{ .App.Config.ApplicationName }} account. <span class="warning-message">If you do so, you will no longer be able to log in using your {{ .App.Config.ApplicationName }} password. You'll need to use your Minecraft Token to log in to Minecraft launchers.</span></p>
|
||||
|
||||
<form action="{{ .App.FrontEndURL }}/web/oidc-migrate" method="post">
|
||||
<input type="text" name="username" placeholder="Username" required />
|
||||
<input hidden name="returnUrl" value="{{ .URL }}" />
|
||||
<input
|
||||
class="long"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
<input type="submit" value="Link account" />
|
||||
</form>
|
||||
{{ $dividerNeeded := true }}
|
||||
{{ end }}
|
||||
|
||||
<!-- CreateNewPlayer -->
|
||||
{{ if .App.Config.CreateNewPlayer.Allow }}
|
||||
{{ if $dividerNeeded }}
|
||||
<div class="divider">or</div>
|
||||
{{ $dividerNeeded = false }}
|
||||
{{ end }}
|
||||
<h3>Create a player</h3>
|
||||
{{ if .App.Config.CreateNewPlayer.AllowChoosingUUID }}
|
||||
<p>Complete registration by creating a new player:</p>
|
||||
{{ else }}
|
||||
<p>Complete registration by creating a new player with a random UUID:</p>
|
||||
{{ end }}
|
||||
<form action="{{ .App.FrontEndURL }}/web/register" method="post">
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
name="playerName"
|
||||
placeholder="Player name"
|
||||
maxlength="{{ .App.Constants.MaxUsernameLength }}"
|
||||
value="{{ .PreferredPlayerName }}"
|
||||
{{ if not .AllowChoosingPlayerName }}
|
||||
title="Choosing a player name is not allowed."
|
||||
disabled
|
||||
{{ end }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="email"
|
||||
placeholder="Leave this blank"
|
||||
class="honeypot"
|
||||
/>
|
||||
<input
|
||||
hidden
|
||||
type="checkbox"
|
||||
name="useIdToken"
|
||||
checked
|
||||
/>
|
||||
{{ if .App.Config.CreateNewPlayer.AllowChoosingUUID }}
|
||||
<p>
|
||||
<input
|
||||
class="long"
|
||||
type="text"
|
||||
name="uuid"
|
||||
placeholder="Player UUID (leave blank for random)"
|
||||
pattern="^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$"
|
||||
/>
|
||||
</p>
|
||||
{{ end }}
|
||||
<input type="text" name="inviteCode" value="{{ .InviteCode }}" hidden />
|
||||
<input hidden name="returnUrl" value="{{ .URL }}" />
|
||||
{{ if .InviteCode }}
|
||||
<p><em>Using invite code {{ .InviteCode }}</em></p>
|
||||
{{ end }}
|
||||
<p>
|
||||
<input type="submit" value="Register" />
|
||||
</p>
|
||||
</form>
|
||||
{{ $dividerNeeded = true }}
|
||||
{{ end }}
|
||||
|
||||
<!-- ImportExistingPlayer -->
|
||||
{{ if .App.Config.ImportExistingPlayer.Allow }}
|
||||
{{ if $dividerNeeded }}
|
||||
<div class="divider">or</div>
|
||||
{{ $dividerNeeded = false }}
|
||||
{{ end }}
|
||||
<h3>Register from an existing Minecraft player</h3>
|
||||
{{ if and .App.Config.RegistrationExistingPlayer.RequireInvite (not
|
||||
.InviteCode)
|
||||
}}
|
||||
<p>Registration as an existing player is invite-only.</p>
|
||||
{{ else }}
|
||||
{{ if .App.Config.ImportExistingPlayer.RequireSkinVerification }}
|
||||
<p>
|
||||
Register a new account with the UUID of an existing
|
||||
{{ .App.Config.ImportExistingPlayer.Nickname }} account.
|
||||
Requires verification that you own the account.
|
||||
</p>
|
||||
{{ if .InviteCode }}
|
||||
<p><em>Using invite code {{ .InviteCode }}</em></p>
|
||||
{{ end }}
|
||||
<form action="{{ .App.FrontEndURL }}/web/register-challenge" method="get">
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
name="playerName"
|
||||
placeholder="{{ .App.Config.ImportExistingPlayer.Nickname }} Player name"
|
||||
maxlength="{{ .App.Constants.MaxUsernameLength }}"
|
||||
{{ if not .AllowChoosingPlayerName }}
|
||||
value="{{ .PreferredPlayerName }}"
|
||||
title="Choosing a player name is not allowed."
|
||||
disabled
|
||||
{{ end }}
|
||||
/>
|
||||
<input
|
||||
hidden
|
||||
type="checkbox"
|
||||
name="useIdToken"
|
||||
checked
|
||||
/>
|
||||
<input
|
||||
hidden
|
||||
type="text"
|
||||
name="inviteCode"
|
||||
value="{{ .InviteCode }}"
|
||||
/>
|
||||
<input hidden name="returnUrl" value="{{ .URL }}" />
|
||||
<input type="submit" value="Continue" />
|
||||
</form>
|
||||
{{ else }}
|
||||
<p>
|
||||
Register a new account with the UUID of an existing
|
||||
{{ .App.Config.ImportExistingPlayer.Nickname }} account.
|
||||
</p>
|
||||
<form action="{{ .App.FrontEndURL }}/web/register" method="post">
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
name="playerName"
|
||||
placeholder="{{ .App.Config.ImportExistingPlayer.Nickname }} Player name"
|
||||
maxlength="{{ .App.Constants.MaxUsernameLength }}"
|
||||
/>
|
||||
<input
|
||||
hidden
|
||||
type="checkbox"
|
||||
name="useIdToken"
|
||||
checked
|
||||
/>
|
||||
<input type="checkbox" name="existingPlayer" checked hidden />
|
||||
<input
|
||||
hidden
|
||||
type="text"
|
||||
name="inviteCode"
|
||||
value="{{ .InviteCode }}"
|
||||
/>
|
||||
{{ if .InviteCode }}
|
||||
<p><em>Using invite code {{ .InviteCode }}</em></p>
|
||||
{{ end }}
|
||||
<input type="submit" value="Register" />
|
||||
</form>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ $dividerNeeded = true }}
|
||||
{{ end }}
|
||||
|
||||
{{ template "footer" . }}
|
||||
{{ end }}
|
11
view/error.tmpl
Normal file
11
view/error.tmpl
Normal file
@ -0,0 +1,11 @@
|
||||
{{ template "layout" . }}
|
||||
|
||||
{{ define "title" }}{{ .Message }} - {{ .App.Config.ApplicationName }}{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
{{ template "header" . }}
|
||||
|
||||
<h1>{{ .StatusCode }} {{ .Message }}</h1>
|
||||
|
||||
{{ template "footer" . }}
|
||||
{{ end }}
|
@ -3,7 +3,7 @@
|
||||
<div>
|
||||
<a class="logo" href="{{ .App.FrontEndURL }}">
|
||||
<img
|
||||
src="{{ .App.FrontEndURL }}/web/public/logo.svg"
|
||||
src="{{ .App.PublicURL }}/logo.svg"
|
||||
alt="{{ .App.Config.ApplicationName }} logo"
|
||||
/>{{ .App.Config.ApplicationName }}
|
||||
</a>
|
||||
|
@ -9,14 +9,14 @@
|
||||
name="description"
|
||||
content="A self-hosted API server for Minecraft"
|
||||
/>
|
||||
<link rel="icon" href="{{ .App.FrontEndURL }}/web/public/icon.png" />
|
||||
<link rel="icon" href="{{ .App.PublicURL }}/icon.png" />
|
||||
<link
|
||||
rel="manifest"
|
||||
href="{{ .App.FrontEndURL }}/web/manifest.webmanifest"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ .App.FrontEndURL }}/web/public/style.css"
|
||||
href="{{ .App.PublicURL }}/style.css"
|
||||
/>
|
||||
<title>{{ block "title" . }}{{ end }}</title>
|
||||
</head>
|
||||
@ -25,7 +25,7 @@
|
||||
<main id="content">{{ block "content" . }}{{ end }}</main>
|
||||
{{ if .App.Config.EnableBackgroundEffect }}
|
||||
<script type="module">
|
||||
import { background } from "{{.App.FrontEndURL}}/web/public/bundle.js";
|
||||
import { background } from "{{.App.PublicURL}}/bundle.js";
|
||||
background(document.querySelector("#background"));
|
||||
|
||||
for (const el of document.querySelectorAll(".noscript-hidden")) {
|
||||
|
@ -8,7 +8,7 @@
|
||||
|
||||
<p>
|
||||
{{ if .AdminView }}
|
||||
<a href="{{ .App.FrontEndURL }}/web/user/{{ .Player.User.UUID }}">Back to {{ .Player.User.Username }}'s account</a>
|
||||
<a href="{{ .App.FrontEndURL }}/web/user/{{ .PlayerUser.UUID }}">Back to {{ .PlayerUser.Username }}'s account</a>
|
||||
{{ else }}
|
||||
<a href="{{ .App.FrontEndURL }}/web/user">Back to your account</a>
|
||||
{{ end }}
|
||||
@ -39,7 +39,7 @@
|
||||
{{ if or .App.Config.AllowChangingPlayerName .User.IsAdmin }}
|
||||
<p>
|
||||
<label for="player-name"
|
||||
>Player Name (can be different from {{ if .AdminView }}{{ .Player.User.Username }}'s{{ else }}your{{ end }} {{ .App.Config.ApplicationName }} username)</label
|
||||
>Player Name (can be different from {{ if .AdminView }}{{ .PlayerUser.Username }}'s{{ else }}your{{ end }} {{ .App.Config.ApplicationName }} username)</label
|
||||
><br />
|
||||
<input
|
||||
type="text"
|
||||
@ -164,7 +164,7 @@
|
||||
hidden
|
||||
name="returnUrl"
|
||||
value="{{ if .AdminView }}
|
||||
{{ .App.FrontEndURL }}/web/user/{{ .Player.User.UUID }}
|
||||
{{ .App.FrontEndURL }}/web/user/{{ .PlayerUser.UUID }}
|
||||
{{ else }}
|
||||
{{ .App.FrontEndURL }}/web/user
|
||||
{{ end }}"
|
||||
@ -176,7 +176,7 @@
|
||||
|
||||
{{ if .SkinURL }}
|
||||
<script type="module">
|
||||
import { skinview3d } from "{{.App.FrontEndURL}}/web/public/bundle.js"
|
||||
import { skinview3d } from "{{.App.PublicURL}}/bundle.js"
|
||||
const skinCanvas = document.getElementById("skin-canvas");
|
||||
const skinViewer = new skinview3d.SkinViewer({
|
||||
canvas: skinCanvas,
|
||||
|
@ -6,9 +6,34 @@
|
||||
"content"
|
||||
}}
|
||||
{{ template "header" . }}
|
||||
{{ if
|
||||
.App.Config.RegistrationNewPlayer.Allow
|
||||
}}
|
||||
|
||||
{{ $dividerNeeded := false }}
|
||||
|
||||
<!-- Sign in with OpenID -->
|
||||
{{ if gt (len .WebOIDCProviders) 0 }}
|
||||
{{ if $dividerNeeded }}
|
||||
<div class="divider">or</div>
|
||||
{{ $dividerNeeded = false }}
|
||||
{{ end }}
|
||||
<h3><img class="openid-logo" src="{{ .App.PublicURL }}/openid-logo.svg" alt="OpenID logo"></h3>
|
||||
{{ range $provider := $.WebOIDCProviders }}
|
||||
{{ if and $provider.RequireInvite (not $.InviteCode) }}
|
||||
Signing in with {{ $provider.Name }} is invite-only.
|
||||
{{ else }}
|
||||
<p>
|
||||
<a href="{{ $provider.AuthURL }}">Sign in with {{ $provider.Name }}</a>
|
||||
</p>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ $dividerNeeded = true }}
|
||||
{{ end }}
|
||||
|
||||
<!-- RegistrationNewPlayer -->
|
||||
{{ if and .App.Config.RegistrationNewPlayer.Allow .App.Config.AllowPasswordLogin }}
|
||||
{{ if $dividerNeeded }}
|
||||
<div class="divider">or</div>
|
||||
{{ $dividerNeeded = false }}
|
||||
{{ end }}
|
||||
<h3>Register</h3>
|
||||
{{ if and .App.Config.RegistrationNewPlayer.RequireInvite (not .InviteCode) }}
|
||||
<p>Registration as a new player is invite-only.</p>
|
||||
@ -21,7 +46,7 @@
|
||||
<form action="{{ .App.FrontEndURL }}/web/register" method="post">
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
name="playerName"
|
||||
placeholder="Username"
|
||||
maxlength="{{ .App.Constants.MaxUsernameLength }}"
|
||||
required
|
||||
@ -46,7 +71,7 @@
|
||||
class="long"
|
||||
type="text"
|
||||
name="uuid"
|
||||
placeholder="UUID (leave blank for random)"
|
||||
placeholder="Player UUID (leave blank for random)"
|
||||
pattern="^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$"
|
||||
/>
|
||||
</p>
|
||||
@ -61,18 +86,25 @@
|
||||
</p>
|
||||
</form>
|
||||
{{ end }}
|
||||
{{ $dividerNeeded = true }}
|
||||
{{ end }}
|
||||
{{ if .App.Config.RegistrationExistingPlayer.Allow }}
|
||||
<h3>Register from an existing account</h3>
|
||||
|
||||
<!-- RegistrationExistingPlayer -->
|
||||
{{ if and .App.Config.RegistrationExistingPlayer.Allow .App.Config.AllowPasswordLogin }}
|
||||
{{ if $dividerNeeded }}
|
||||
<div class="divider">or</div>
|
||||
{{ $dividerNeeded = false }}
|
||||
{{ end }}
|
||||
<h3>Register from an existing Minecraft player</h3>
|
||||
{{ if and .App.Config.RegistrationExistingPlayer.RequireInvite (not
|
||||
.InviteCode)
|
||||
}}
|
||||
<p>Registration as an existing player is invite-only.</p>
|
||||
{{ else }}
|
||||
{{ if .App.Config.RegistrationExistingPlayer.RequireSkinVerification }}
|
||||
{{ if .App.Config.ImportExistingPlayer.RequireSkinVerification }}
|
||||
<p>
|
||||
Register a new account with the UUID of an existing
|
||||
{{ .App.Config.RegistrationExistingPlayer.Nickname }} account.
|
||||
{{ .App.Config.ImportExistingPlayer.Nickname }} account.
|
||||
Requires verification that you own the account.
|
||||
</p>
|
||||
{{ if .InviteCode }}
|
||||
@ -81,8 +113,8 @@
|
||||
<form action="{{ .App.FrontEndURL }}/web/register-challenge" method="get">
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} Player name"
|
||||
name="playerName"
|
||||
placeholder="{{ .App.Config.ImportExistingPlayer.Nickname }} Player name"
|
||||
maxlength="{{ .App.Constants.MaxUsernameLength }}"
|
||||
required
|
||||
/>
|
||||
@ -98,13 +130,13 @@
|
||||
{{ else }}
|
||||
<p>
|
||||
Register a new account with the UUID of an existing
|
||||
{{ .App.Config.RegistrationExistingPlayer.Nickname }} account.
|
||||
{{ .App.Config.ImportExistingPlayer.Nickname }} account.
|
||||
</p>
|
||||
<form action="{{ .App.FrontEndURL }}/web/register" method="post">
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} Player name"
|
||||
name="playerName"
|
||||
placeholder="{{ .App.Config.ImportExistingPlayer.Nickname }} Player name"
|
||||
maxlength="{{ .App.Constants.MaxUsernameLength }}"
|
||||
required
|
||||
/>
|
||||
@ -130,6 +162,8 @@
|
||||
</form>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ $dividerNeeded = true }}
|
||||
{{ end }}
|
||||
|
||||
{{ template "footer" . }}
|
||||
{{ end }}
|
||||
|
@ -5,19 +5,43 @@
|
||||
{{ define "content" }}
|
||||
{{ template "header" . }}
|
||||
<h3>Log in</h3>
|
||||
<form action="{{ .App.FrontEndURL }}/web/login" method="post">
|
||||
<input type="text" name="username" placeholder="Username" required />
|
||||
<input hidden name="returnUrl" value="{{ .URL }}" />
|
||||
<input hidden name="destination" value="{{ .Destination }}" />
|
||||
<input
|
||||
class="long"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
<input type="submit" value="Log in" />
|
||||
</form>
|
||||
|
||||
{{ $dividerNeeded := false }}
|
||||
|
||||
{{ if gt (len .WebOIDCProviders) 0 }}
|
||||
{{ if $dividerNeeded }}
|
||||
<div class="divider">or</div>
|
||||
{{ $dividerNeeded = false }}
|
||||
{{ end }}
|
||||
<h3><img class="openid-logo" src="{{ .App.PublicURL }}/openid-logo.svg" alt="OpenID logo"></h3>
|
||||
{{ range $provider := $.WebOIDCProviders }}
|
||||
<p>
|
||||
<a href="{{ $provider.AuthURL }}">Sign in with {{ $provider.Name }}</a>
|
||||
</p>
|
||||
{{ end }}
|
||||
{{ $dividerNeeded = true }}
|
||||
{{ end }}
|
||||
|
||||
{{ if $dividerNeeded }}
|
||||
<div class="divider">or</div>
|
||||
{{ $dividerNeeded = false }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .App.Config.AllowPasswordLogin }}
|
||||
<form action="{{ .App.FrontEndURL }}/web/login" method="post">
|
||||
<input type="text" name="username" placeholder="Username" required />
|
||||
<input hidden name="returnUrl" value="{{ .URL }}" />
|
||||
<input hidden name="destination" value="{{ .Destination }}" />
|
||||
<input
|
||||
class="long"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
<input type="submit" value="Log in" />
|
||||
</form>
|
||||
{{ end }}
|
||||
|
||||
<h3>Configuring your client</h3>
|
||||
<p>
|
||||
|
117
view/user.tmpl
117
view/user.tmpl
@ -14,10 +14,25 @@
|
||||
id="delete-{{ $player.UUID }}"
|
||||
action="{{ $.App.FrontEndURL }}/web/delete-player"
|
||||
method="post"
|
||||
onsubmit="return confirm('Are you sure? This action is irreversible.');"
|
||||
onsubmit="return confirm('Are you sure you want to delete {{ $player.Name }}? This action is irreversible.');"
|
||||
>
|
||||
<input hidden name="returnUrl" value="{{ $.URL }}" />
|
||||
<input type="text" name="uuid" value="{{ $player.UUID }}" />
|
||||
<input name="returnUrl" value="{{ $.URL }}" />
|
||||
<input name="uuid" value="{{ $player.UUID }}" />
|
||||
</form>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div style="display: none">
|
||||
{{ range $providerName := .LinkedOIDCProviderNames }}
|
||||
<form
|
||||
id="unlink-{{ $providerName }}"
|
||||
action="{{ $.App.FrontEndURL }}/web/oidc-unlink"
|
||||
method="post"
|
||||
onsubmit="return confirm('Are you sure you want to unlink your {{ $providerName }} account? You will no longer be able to log in to your {{ $.App.Config.ApplicationName }} account using {{ $providerName }}.');"
|
||||
>
|
||||
<input name="returnUrl" value="{{ $.URL }}" />
|
||||
<input name="userUuid" value="{{ $.TargetUser.UUID }}" />
|
||||
<input name="providerName" value="{{ $providerName }}" />
|
||||
</form>
|
||||
{{ end }}
|
||||
</div>
|
||||
@ -53,11 +68,7 @@
|
||||
</td>
|
||||
<td>{{ $player.UUID }}</td>
|
||||
<td>
|
||||
<input
|
||||
type="submit"
|
||||
form="delete-{{ $player.UUID }}"
|
||||
value="Delete"
|
||||
/>
|
||||
<input type="submit" form="delete-{{ $player.UUID }}" value="Delete" />
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
@ -79,7 +90,7 @@
|
||||
</p>
|
||||
{{ if or (lt (len .TargetUser.Players) .MaxPlayerCount) (lt .MaxPlayerCount 0) .AdminView }}
|
||||
{{ if .App.Config.RegistrationNewPlayer.Allow }}
|
||||
{{ if .App.Config.RegistrationNewPlayer.AllowChoosingUUID }}
|
||||
{{ if or .User.IsAdmin .App.Config.RegistrationNewPlayer.AllowChoosingUUID }}
|
||||
<h4>Create a new player</h4>
|
||||
{{ else }}
|
||||
<p>Create a new player with a random UUID:</p>
|
||||
@ -93,12 +104,12 @@
|
||||
maxlength="{{ .App.Constants.MaxPlayerNameLength }}"
|
||||
required
|
||||
/>
|
||||
{{ if .App.Config.RegistrationNewPlayer.AllowChoosingUUID }}
|
||||
{{ if or .User.IsAdmin .App.Config.RegistrationNewPlayer.AllowChoosingUUID }}
|
||||
<input
|
||||
class="long"
|
||||
type="text"
|
||||
name="playerUuid"
|
||||
placeholder="UUID (leave blank for random)"
|
||||
placeholder="Player UUID (leave blank for random)"
|
||||
pattern="^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$"
|
||||
/>
|
||||
{{ end }}
|
||||
@ -107,18 +118,18 @@
|
||||
</form>
|
||||
{{ end }}
|
||||
{{ if .App.Config.RegistrationExistingPlayer.Allow }}
|
||||
<h4>Import a(n) {{ .App.Config.RegistrationExistingPlayer.Nickname }} player</h4>
|
||||
{{ if .App.Config.RegistrationExistingPlayer.RequireSkinVerification }}
|
||||
<h4>Import a(n) {{ .App.Config.ImportExistingPlayer.Nickname }} player</h4>
|
||||
{{ if .App.Config.ImportExistingPlayer.RequireSkinVerification }}
|
||||
<p>
|
||||
Create a new player with the UUID of an existing
|
||||
{{ .App.Config.RegistrationExistingPlayer.Nickname }} player.
|
||||
{{ .App.Config.ImportExistingPlayer.Nickname }} player.
|
||||
Requires verification that you own the account.
|
||||
</p>
|
||||
<form action="{{ .App.FrontEndURL }}/web/create-player-challenge" method="get">
|
||||
<input
|
||||
type="text"
|
||||
name="playerName"
|
||||
placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} player name"
|
||||
placeholder="{{ .App.Config.ImportExistingPlayer.Nickname }} player name"
|
||||
maxlength="{{ .App.Constants.MaxUsernameLength }}"
|
||||
required
|
||||
/>
|
||||
@ -129,13 +140,13 @@
|
||||
{{ else }}
|
||||
<p>
|
||||
Create a new player with the UUID of an existing
|
||||
{{ .App.Config.RegistrationExistingPlayer.Nickname }} player.
|
||||
{{ .App.Config.ImportExistingPlayer.Nickname }} player.
|
||||
</p>
|
||||
<form action="{{ .App.FrontEndURL }}/web/create-player" method="post">
|
||||
<input
|
||||
type="text"
|
||||
name="playerName"
|
||||
placeholder="{{ .App.Config.RegistrationExistingPlayer.Nickname }} Player name"
|
||||
placeholder="{{ .App.Config.ImportExistingPlayer.Nickname }} Player name"
|
||||
maxlength="{{ .App.Constants.MaxPlayerNameLength }}"
|
||||
required
|
||||
/>
|
||||
@ -147,15 +158,54 @@
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ if gt (len .App.OIDCProvidersByName) 0 }}
|
||||
<h3>Linked accounts</h3>
|
||||
{{ if gt (len $.LinkedOIDCProviderNames) 0 }}
|
||||
<p>
|
||||
These external accounts are linked to {{ if .AdminView }}{{ .TargetUser.Username }}'s{{ else }}your{{ end }} {{ .App.Config.ApplicationName }} account:
|
||||
</p>
|
||||
<table>
|
||||
<tbody>
|
||||
{{ range $providerName := $.LinkedOIDCProviderNames }}
|
||||
<tr>
|
||||
<td>{{ $providerName }}</td>
|
||||
<td>
|
||||
<input
|
||||
type="submit"
|
||||
form="unlink-{{ $providerName }}"
|
||||
value="Remove"
|
||||
{{ if le (len $.LinkedOIDCProviderNames) 1 }}disabled title="Can't remove the last linked OIDC account."{{ end }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ else }}
|
||||
<p>
|
||||
No external accounts are linked to {{ if .AdminView }}{{ .TargetUser.Username }}'s{{ else }}your{{ end }} {{ .App.Config.ApplicationName }} account. <span class="warning-message">If you link an external account, you will no longer be able to log in using your {{ .App.Config.ApplicationName }} password. You'll need to use your Minecraft Token to log in to Minecraft launchers.</span>
|
||||
</p>
|
||||
{{ end }}
|
||||
{{ if and (eq .User.UUID .TargetUser.UUID) (gt (len $.UnlinkedOIDCProviders) 0) }}
|
||||
{{ range $provider := $.UnlinkedOIDCProviders }}
|
||||
<p>
|
||||
<a href="{{ $provider.AuthURL }}">Link with {{ $provider.Name }}</a></td>
|
||||
</p>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
<h3>Account settings</h3>
|
||||
<form
|
||||
action="{{ .App.FrontEndURL }}/web/update-user"
|
||||
method="post"
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
{{ if .User.IsAdmin }}
|
||||
{{ if and .User.IsAdmin (not .TargetUser.IsAdmin) }}
|
||||
<p>
|
||||
<label for="max-player-count">Max number of players. Specify -1 to allow unlimited players or leave blank to reset to the configured default value.</label><br />
|
||||
<label for="max-player-count">Max number of players</label><br>
|
||||
<small>Specify -1 to allow unlimited players or leave blank to reset to the configured default value.</small><br>
|
||||
<input
|
||||
name="maxPlayerCount"
|
||||
type="number"
|
||||
@ -166,15 +216,34 @@
|
||||
</input>
|
||||
</p>
|
||||
{{ end }}
|
||||
{{ if and .App.Config.AllowPasswordLogin (eq (len $.LinkedOIDCProviderNames) 0) }}
|
||||
<p>
|
||||
<label for="password">Password</label><br />
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
class="long"
|
||||
placeholder="Leave blank to keep"
|
||||
/>
|
||||
</p>
|
||||
{{ end }}
|
||||
<p>
|
||||
<label for="password">Password</label><br />
|
||||
<label for="minecraftToken">Minecraft Token</label><br>
|
||||
<small>Can be used instead of a password to sign in to Minecraft launchers.</small><br>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
type="text"
|
||||
name="minecraftToken"
|
||||
id="minecraft-token"
|
||||
class="long"
|
||||
placeholder="Leave blank to keep"
|
||||
readonly
|
||||
value="{{ .TargetUser.MinecraftToken }}"
|
||||
/>
|
||||
<br>
|
||||
<label for="reset-minecraft-token"
|
||||
>check the box to reset your Minecraft token
|
||||
</label>
|
||||
<input type="checkbox" name="resetMinecraftToken" id="reset-minecraft-token" />
|
||||
</p>
|
||||
<p>
|
||||
<label for="apiToken">API Token</label><br />
|
||||
|
Loading…
x
Reference in New Issue
Block a user