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:
Evan Goode 2025-03-22 16:40:26 -04:00 committed by GitHub
parent 09c9192cca
commit 5c1f6c1cfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2973 additions and 725 deletions

View File

@ -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

View File

@ -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)

View File

@ -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
View File

@ -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
}

View File

@ -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
View File

@ -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 {

View File

@ -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)

View File

@ -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(

View File

@ -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
View File

@ -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
View 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
View File

@ -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))

View File

@ -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
View File

@ -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
}

View File

@ -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`.

View File

@ -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"
```

View File

@ -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 = ''

1018
front.go

File diff suppressed because it is too large Load Diff

View File

@ -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
View File

@ -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
View File

@ -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
View File

@ -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

View File

@ -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 {

View File

@ -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
View 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

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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
}

View File

@ -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)
}

View File

@ -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",

View File

@ -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
View File

@ -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
View File

@ -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 {

View File

@ -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">

View File

@ -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" />

View 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
View 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 }}

View File

@ -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>

View File

@ -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")) {

View File

@ -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,

View File

@ -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 }}

View File

@ -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>

View File

@ -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 />