diff --git a/README.md b/README.md
index a00e5b8..05d11c2 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/account.go b/account.go
index 6d3487a..0448bb1 100644
--- a/account.go
+++ b/account.go
@@ -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)
diff --git a/account_test.go b/account_test.go
index 55a051e..1eee3ad 100644
--- a/account_test.go
+++ b/account_test.go
@@ -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)
}
diff --git a/api.go b/api.go
index 7c3ae5a..e27803c 100644
--- a/api.go
+++ b/api.go
@@ -64,7 +64,7 @@ func (app *App) HandleAPIError(err error, c *echo.Context) error {
var userError *UserError
if errors.As(err, &userError) {
- code = userError.Code
+ code = userError.Code.OrElse(http.StatusInternalServerError)
message = userError.Error()
log = false
}
@@ -164,18 +164,18 @@ func (app *App) withAPITokenAdmin(f func(c echo.Context, user *User) error) func
}
type APIUser struct {
- IsAdmin bool `json:"isAdmin" example:"true"` // Whether the user is an admin
- IsLocked bool `json:"isLocked" example:"false"` // Whether the user is locked (disabled)
- UUID string `json:"uuid" example:"557e0c92-2420-4704-8840-a790ea11551c"`
- Username string `json:"username" example:"MyUsername"` // Username. Can be different from the user's player name.
- PreferredLanguage string `json:"preferredLanguage" example:"en"` // One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft.
- Players []APIPlayer `json:"players"` // A user can have multiple players.
- MaxPlayerCount int `json:"maxPlayerCount" example:"3"` // Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value.
+ IsAdmin bool `json:"isAdmin" example:"true"` // Whether the user is an admin
+ IsLocked bool `json:"isLocked" example:"false"` // Whether the user is locked (disabled)
+ UUID string `json:"uuid" example:"557e0c92-2420-4704-8840-a790ea11551c"`
+ Username string `json:"username" example:"MyUsername"` // Username. Can be different from the user's player name.
+ PreferredLanguage string `json:"preferredLanguage" example:"en"` // One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft.
+ MaxPlayerCount int `json:"maxPlayerCount" example:"3"` // Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value.
+ Players []APIPlayer `json:"players"` // A user can have multiple players.
+ OIDCIdentities []APIOIDCIdentity `json:"oidcIdentities"` // OIDC identities linked to the user
}
func (app *App) userToAPIUser(user *User) (APIUser, error) {
apiPlayers := make([]APIPlayer, 0, len(user.Players))
-
for _, player := range user.Players {
apiPlayer, err := app.playerToAPIPlayer(&player)
if err != nil {
@@ -184,6 +184,15 @@ func (app *App) userToAPIUser(user *User) (APIUser, error) {
apiPlayers = append(apiPlayers, apiPlayer)
}
+ apiOIDCIdentities := make([]APIOIDCIdentity, 0, len(user.OIDCIdentities))
+ for _, oidcIdentity := range user.OIDCIdentities {
+ apiOIDCIdentity, err := app.oidcIdentityToAPIOIDCIdentity(&oidcIdentity)
+ if err != nil {
+ return APIUser{}, err
+ }
+ apiOIDCIdentities = append(apiOIDCIdentities, apiOIDCIdentity)
+ }
+
return APIUser{
IsAdmin: user.IsAdmin,
IsLocked: user.IsLocked,
@@ -191,6 +200,7 @@ func (app *App) userToAPIUser(user *User) (APIUser, error) {
Username: user.Username,
PreferredLanguage: user.PreferredLanguage,
Players: apiPlayers,
+ OIDCIdentities: apiOIDCIdentities,
MaxPlayerCount: user.MaxPlayerCount,
}, nil
}
@@ -231,6 +241,26 @@ func (app *App) playerToAPIPlayer(player *Player) (APIPlayer, error) {
}, nil
}
+type APIOIDCIdentity struct {
+ UserUUID string `json:"userUuid" example:"918bd04e-1bc4-4ccd-860f-60c15c5f1cec"`
+ OIDCProviderName string `json:"oidcProviderName" example:"Kanidm"`
+ Issuer string `json:"issuer" example:"https://idm.example.com/oauth2/openid/drasl"`
+ Subject string `json:"subject" example:"f85f8c18-9bdf-49ad-a76e-719f9ba3ed25"`
+}
+
+func (app *App) oidcIdentityToAPIOIDCIdentity(oidcIdentity *UserOIDCIdentity) (APIOIDCIdentity, error) {
+ oidcProvider, ok := app.OIDCProvidersByIssuer[oidcIdentity.Issuer]
+ if !ok {
+ return APIOIDCIdentity{}, InternalServerError
+ }
+ return APIOIDCIdentity{
+ UserUUID: oidcIdentity.UserUUID,
+ OIDCProviderName: oidcProvider.Config.Name,
+ Issuer: oidcIdentity.Issuer,
+ Subject: oidcIdentity.Subject,
+ }, nil
+}
+
type APIInvite struct {
Code string `json:"code" example:"rqjJwh0yMjO"` // The base62 invite code
URL string `json:"url" example:"https://drasl.example.com/drasl/registration?invite=rqjJwh0yMjO"` // Link to register using the invite
@@ -343,24 +373,30 @@ func (app *App) APIGetUser() func(c echo.Context) error {
})
}
+type APIOIDCIdentitySpec struct {
+ Issuer string `json:"issuer" example:"https://idm.example.com/oauth2/openid/drasl"`
+ Subject string `json:"subject" example:"f85f8c18-9bdf-49ad-a76e-719f9ba3ed25"`
+}
+
type APICreateUserRequest struct {
- Username string `json:"username" example:"MyUsername"` // Username of the new user. Can be different from the user's player name.
- Password string `json:"password" example:"hunter2"` // Plaintext password
- IsAdmin bool `json:"isAdmin" example:"true"` // Whether the user is an admin
- IsLocked bool `json:"isLocked" example:"false"` // Whether the user is locked (disabled)
- RequestAPIToken bool `json:"requestApiToken" example:"true"` // Whether to include an API token for the user in the response
- ChosenUUID *string `json:"chosenUuid" example:"557e0c92-2420-4704-8840-a790ea11551c"` // Optional. Specify a UUID for the player of the new user. If omitted, a random UUID will be generated.
- ExistingPlayer bool `json:"existingPlayer" example:"false"` // If true, the new user's player will get the UUID of the existing player with the specified PlayerName. See `RegistrationExistingPlayer` in configuration.md.
- InviteCode *string `json:"inviteCode" example:"rqjJwh0yMjO"` // Invite code to use. Optional even if the `RequireInvite` configuration option is set; admin API users can bypass `RequireInvite`.
- PlayerName *string `json:"playerName" example:"MyPlayerName"` // Optional. Player name. Can be different from the user's username. If omitted, the user's username will be used.
- FallbackPlayer *string `json:"fallbackPlayer" example:"Notch"` // Can be a UUID or a player name. If you don't set a skin or cape, this player's skin on one of the fallback API servers will be used instead.
- PreferredLanguage *string `json:"preferredLanguage" example:"en"` // Optional. One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft. If omitted, the value of the `DefaultPreferredLanguage` configuration option will be used.
- SkinModel *string `json:"skinModel" example:"classic"` // Skin model. Either "classic" or "slim". If omitted, `"classic"` will be assumed.
- SkinBase64 *string `json:"skinBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI"` // Optional. Base64-encoded skin PNG. Example value truncated for brevity. Do not specify both `skinBase64` and `skinUrl`.
- SkinURL *string `json:"skinUrl" example:"https://example.com/skin.png"` // Optional. URL to skin file. Do not specify both `skinBase64` and `skinUrl`.
- CapeBase64 *string `json:"capeBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf"` // Optional. Base64-encoded cape PNG. Example value truncated for brevity. Do not specify both `capeBase64` and `capeUrl`.
- CapeURL *string `json:"capeUrl" example:"https://example.com/cape.png"` // Optional. URL to cape file. Do not specify both `capeBase64` and `capeUrl`.
- MaxPlayerCount *int `json:"maxPlayerCount" example:"3"` // Optional. Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value.
+ Username string `json:"username" example:"MyUsername"` // Username of the new user. Can be different from the user's player name.
+ Password *string `json:"password" example:"hunter2"` // Plaintext password. Not needed if OIDCIdentitySpecs are supplied.
+ OIDCIdentitySpecs []APIOIDCIdentitySpec `json:"oidcIdentities"`
+ IsAdmin bool `json:"isAdmin" example:"true"` // Whether the user is an admin
+ IsLocked bool `json:"isLocked" example:"false"` // Whether the user is locked (disabled)
+ RequestAPIToken bool `json:"requestApiToken" example:"true"` // Whether to include an API token for the user in the response
+ ChosenUUID *string `json:"chosenUuid" example:"557e0c92-2420-4704-8840-a790ea11551c"` // Optional. Specify a UUID for the player of the new user. If omitted, a random UUID will be generated.
+ ExistingPlayer bool `json:"existingPlayer" example:"false"` // If true, the new user's player will get the UUID of the existing player with the specified PlayerName. See `RegistrationExistingPlayer` in configuration.md.
+ InviteCode *string `json:"inviteCode" example:"rqjJwh0yMjO"` // Invite code to use. Optional even if the `RequireInvite` configuration option is set; admin API users can bypass `RequireInvite`.
+ PlayerName *string `json:"playerName" example:"MyPlayerName"` // Optional. Player name. Can be different from the user's username. If omitted, the user's username will be used.
+ FallbackPlayer *string `json:"fallbackPlayer" example:"Notch"` // Can be a UUID or a player name. If you don't set a skin or cape, this player's skin on one of the fallback API servers will be used instead.
+ PreferredLanguage *string `json:"preferredLanguage" example:"en"` // Optional. One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft. If omitted, the value of the `DefaultPreferredLanguage` configuration option will be used.
+ SkinModel *string `json:"skinModel" example:"classic"` // Skin model. Either "classic" or "slim". If omitted, `"classic"` will be assumed.
+ SkinBase64 *string `json:"skinBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARzQklUCAgI"` // Optional. Base64-encoded skin PNG. Example value truncated for brevity. Do not specify both `skinBase64` and `skinUrl`.
+ SkinURL *string `json:"skinUrl" example:"https://example.com/skin.png"` // Optional. URL to skin file. Do not specify both `skinBase64` and `skinUrl`.
+ CapeBase64 *string `json:"capeBase64" example:"iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAABcGlDQ1BpY2MAACiRdZG9S8NAGMaf"` // Optional. Base64-encoded cape PNG. Example value truncated for brevity. Do not specify both `capeBase64` and `capeUrl`.
+ CapeURL *string `json:"capeUrl" example:"https://example.com/cape.png"` // Optional. URL to cape file. Do not specify both `capeBase64` and `capeUrl`.
+ MaxPlayerCount *int `json:"maxPlayerCount" example:"3"` // Optional. Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value.
}
type APICreateUserResponse struct {
@@ -385,6 +421,8 @@ type APICreateUserResponse struct {
// @Router /drasl/api/v2/users [post]
func (app *App) APICreateUser() func(c echo.Context) error {
return app.withAPIToken(false, func(c echo.Context, caller *User) error {
+ callerIsAdmin := caller != nil && caller.IsAdmin
+
req := new(APICreateUserRequest)
if err := c.Bind(req); err != nil {
return err
@@ -402,10 +440,19 @@ func (app *App) APICreateUser() func(c echo.Context) error {
capeReader = &decoder
}
+ if !callerIsAdmin && len(req.OIDCIdentitySpecs) > 0 {
+ return NewBadRequestUserError("Can't create a user with OIDC identities without admin privileges.")
+ }
+ oidcIdentitySpecs := make([]OIDCIdentitySpec, 0, len(req.OIDCIdentitySpecs))
+ for _, ois := range req.OIDCIdentitySpecs {
+ oidcIdentitySpecs = append(oidcIdentitySpecs, OIDCIdentitySpec(ois))
+ }
+
user, err := app.CreateUser(
caller,
req.Username,
req.Password,
+ PotentiallyInsecure[[]OIDCIdentitySpec]{Value: oidcIdentitySpecs},
req.IsAdmin,
req.IsLocked,
req.InviteCode,
@@ -440,12 +487,13 @@ func (app *App) APICreateUser() func(c echo.Context) error {
}
type APIUpdateUserRequest struct {
- Password *string `json:"password" example:"hunter2"` // Optional. New plaintext password
- IsAdmin *bool `json:"isAdmin" example:"true"` // Optional. Pass`true` to grant, `false` to revoke admin privileges.
- IsLocked *bool `json:"isLocked" example:"false"` // Optional. Pass `true` to lock (disable), `false` to unlock user.
- ResetAPIToken bool `json:"resetApiToken" example:"true"` // Pass `true` to reset the user's API token
- PreferredLanguage *string `json:"preferredLanguage" example:"en"` // Optional. One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft.
- MaxPlayerCount *int `json:"maxPlayerCount" example:"3"` // Optional. Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value.
+ Password *string `json:"password" example:"hunter2"` // Optional. New plaintext password
+ IsAdmin *bool `json:"isAdmin" example:"true"` // Optional. Pass`true` to grant, `false` to revoke admin privileges.
+ IsLocked *bool `json:"isLocked" example:"false"` // Optional. Pass `true` to lock (disable), `false` to unlock user.
+ ResetAPIToken bool `json:"resetApiToken" example:"true"` // Pass `true` to reset the user's API token
+ ResetMinecraftToken bool `json:"resetMinecraftToken" example:"true"` // Pass `true` to reset the user's Minecraft token
+ PreferredLanguage *string `json:"preferredLanguage" example:"en"` // Optional. One of the two-letter codes in https://www.oracle.com/java/technologies/javase/jdk8-jre8-suported-locales.html. Used by Minecraft.
+ MaxPlayerCount *int `json:"maxPlayerCount" example:"3"` // Optional. Maximum number of players a user is allowed to own. -1 means unlimited players. -2 means use the default configured value.
}
// APIUpdateUser godoc
@@ -492,6 +540,7 @@ func (app *App) APIUpdateUser() func(c echo.Context) error {
req.IsAdmin,
req.IsLocked,
req.ResetAPIToken,
+ req.ResetMinecraftToken,
req.PreferredLanguage,
req.MaxPlayerCount,
)
@@ -537,6 +586,7 @@ func (app *App) APIUpdateSelf() func(c echo.Context) error {
req.IsAdmin,
req.IsLocked,
req.ResetAPIToken,
+ req.ResetMinecraftToken,
req.PreferredLanguage,
req.MaxPlayerCount,
)
@@ -861,6 +911,7 @@ func (app *App) APIUpdatePlayer() func(c echo.Context) error {
// @Produce json
// @Param uuid path string true "Player UUID"
// @Success 204
+// @Failure 401 {object} APIError
// @Failure 403 {object} APIError
// @Failure 404 {object} APIError
// @Failure 500 {object} APIError
@@ -890,6 +941,92 @@ func (app *App) APIDeletePlayer() func(c echo.Context) error {
})
}
+type APICreateOIDCIdentityRequest struct {
+ UserUUID *string `json:"userUUID" example:"f9b9af62-da83-4ec7-aeea-de48c621822c"`
+ Issuer string `json:"issuer" example:"https://idm.example.com/oauth2/openid/drasl"`
+ Subject string `json:"subject" example:"f85f8c18-9bdf-49ad-a76e-719f9ba3ed25"`
+}
+
+// APICreateOIDCIdentity godoc
+//
+// @Summary Link an OIDC identity to a user
+// @Tags users
+// @Accept json
+// @Produce json
+// @Success 200
+// @Failure 400 {object} APIError
+// @Failure 401 {object} APIError
+// @Failure 403 {object} APIError
+// @Failure 500 {object} APIError
+// @Router /drasl/api/v2/oidc-identities [post]
+func (app *App) APICreateOIDCIdentity() func(c echo.Context) error {
+ return app.withAPIToken(true, func(c echo.Context, caller *User) error {
+ req := new(APICreateOIDCIdentityRequest)
+ if err := c.Bind(req); err != nil {
+ return err
+ }
+
+ userUUID := caller.UUID
+ if req.UserUUID != nil {
+ userUUID = *req.UserUUID
+ }
+
+ oidcIdentity, err := app.CreateOIDCIdentity(caller, userUUID, req.Issuer, req.Subject)
+ if err != nil {
+ return err
+ }
+
+ apiOIDCIdentity, err := app.oidcIdentityToAPIOIDCIdentity(&oidcIdentity)
+ if err != nil {
+ return err
+ }
+ return c.JSON(http.StatusOK, apiOIDCIdentity)
+ })
+}
+
+type APIDeleteOIDCIdentityRequest struct {
+ UserUUID *string `json:"userUUID" example:"f9b9af62-da83-4ec7-aeea-de48c621822c"`
+ Issuer string `json:"issuer" example:"https://idm.example.com/oauth2/openid/drasl"`
+}
+
+// APIDeleteOIDCIdentity godoc
+//
+// @Summary Unlink an OIDC identity from a user
+// @Tags users
+// @Accept json
+// @Produce json
+// @Success 204
+// @Failure 401 {object} APIError
+// @Failure 403 {object} APIError
+// @Failure 404 {object} APIError
+// @Failure 500 {object} APIError
+// @Router /drasl/api/v2/oidc-identities [delete]
+func (app *App) APIDeleteOIDCIdentity() func(c echo.Context) error {
+ return app.withAPIToken(true, func(c echo.Context, caller *User) error {
+ req := new(APIDeleteOIDCIdentityRequest)
+ if err := c.Bind(req); err != nil {
+ return err
+ }
+
+ userUUID := caller.UUID
+ if req.UserUUID != nil {
+ userUUID = *req.UserUUID
+ }
+
+ oidcProvider, ok := app.OIDCProvidersByIssuer[req.Issuer]
+ if !ok {
+ return NewBadRequestUserError("Unknown OIDC provider: %s", req.Issuer)
+ }
+
+ err := app.DeleteOIDCIdentity(caller, userUUID, oidcProvider.Config.Name)
+ if err != nil {
+ return err
+ }
+
+ return c.NoContent(http.StatusNoContent)
+ })
+}
+
// APIGetInvites godoc
//
// @Summary Get invites
@@ -966,11 +1103,11 @@ func (app *App) APIDeleteInvite() func(c echo.Context) error {
result := app.DB.Where("code = ?", code).Delete(&Invite{})
if result.Error != nil {
- if errors.Is(result.Error, gorm.ErrRecordNotFound) {
- return echo.NewHTTPError(http.StatusNotFound, "Unknown invite code")
- }
return result.Error
}
+ if result.RowsAffected == 0 {
+ return NewUserError(http.StatusNotFound, "Unknown invite code")
+ }
return c.NoContent(http.StatusNoContent)
})
@@ -1045,7 +1182,7 @@ func (app *App) APILogin() func(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "Malformed JSON request")
}
- user, err := app.Login(req.Username, req.Password)
+ user, err := app.AuthenticateUser(req.Username, req.Password)
if err != nil {
return err
}
diff --git a/api_test.go b/api_test.go
index ba80322..9086464 100644
--- a/api_test.go
+++ b/api_test.go
@@ -38,6 +38,8 @@ func TestAPI(t *testing.T) {
t.Run("Test DELETE /drasl/api/vX/players/{uuid}", ts.testAPIDeletePlayer)
t.Run("Test PATCH /drasl/api/vX/players/{uuid}", ts.testAPIUpdatePlayer)
+ t.Run("Test POST/DELETE /drasl/api/vX/oidc-identities", ts.testAPICreateDeleteOIDCIdentity)
+
t.Run("Test DELETE /drasl/api/vX/invites/{code}", ts.testAPIDeleteInvite)
t.Run("Test GET /drasl/api/vX/invites", ts.testAPIGetInvites)
t.Run("Test POST /drasl/api/vX/invites", ts.testAPICreateInvite)
@@ -65,14 +67,14 @@ func (ts *TestSuite) testAPIGetSelf(t *testing.T) {
username := "user"
user, _ := ts.CreateTestUser(t, ts.App, ts.Server, username)
- // admin (admin) should get a response
+ // admin should get a response
rec := ts.Get(t, ts.Server, DRASL_API_PREFIX+"/user", nil, &admin.APIToken)
assert.Equal(t, http.StatusOK, rec.Code)
var response APIUser
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
assert.Equal(t, admin.UUID, response.UUID)
- // user2 (not admin) should also get a response
+ // user (not admin) should also get a response
rec = ts.Get(t, ts.Server, DRASL_API_PREFIX+"/user", nil, &user.APIToken)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
@@ -141,13 +143,13 @@ func (ts *TestSuite) testAPIDeleteUser(t *testing.T) {
user2, _ := ts.CreateTestUser(t, ts.App, ts.Server, username2)
// user2 (not admin) should get a StatusForbidden
- rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/users/"+admin.UUID, nil, &user2.APIToken)
+ rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/users/"+admin.UUID, nil, nil, &user2.APIToken)
assert.Equal(t, http.StatusForbidden, rec.Code)
var err APIError
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&err))
// admin should get a response
- rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/users/"+user2.UUID, nil, &admin.APIToken)
+ rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/users/"+user2.UUID, nil, nil, &admin.APIToken)
assert.Equal(t, http.StatusNoContent, rec.Code)
// user2 should no longer exist in the database
@@ -162,7 +164,7 @@ func (ts *TestSuite) testAPIDeleteSelf(t *testing.T) {
username := "user"
user, _ := ts.CreateTestUser(t, ts.App, ts.Server, username)
- rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/user", nil, &user.APIToken)
+ rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/user", nil, nil, &user.APIToken)
assert.Equal(t, http.StatusNoContent, rec.Code)
// user should no longer exist in the database
@@ -241,7 +243,7 @@ func (ts *TestSuite) testAPICreateUser(t *testing.T) {
// Simple case
payload := APICreateUserRequest{
Username: createdUsername,
- Password: TEST_PASSWORD,
+ Password: Ptr(TEST_PASSWORD),
}
rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users", payload, nil, &admin.APIToken)
@@ -261,7 +263,7 @@ func (ts *TestSuite) testAPICreateUser(t *testing.T) {
// With skin and cape
payload := APICreateUserRequest{
Username: createdUsername,
- Password: TEST_PASSWORD,
+ Password: Ptr(TEST_PASSWORD),
SkinBase64: Ptr(RED_SKIN_BASE64_STRING),
CapeBase64: Ptr(RED_CAPE_BASE64_STRING),
}
@@ -284,7 +286,7 @@ func (ts *TestSuite) testAPICreateUser(t *testing.T) {
// Username in use as another user's player name
payload := APICreateUserRequest{
Username: adminPlayerName,
- Password: TEST_PASSWORD,
+ Password: Ptr(TEST_PASSWORD),
}
rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users", payload, nil, &admin.APIToken)
@@ -545,13 +547,13 @@ func (ts *TestSuite) testAPIDeletePlayer(t *testing.T) {
assert.Nil(t, err)
// user (not admin) should get a StatusForbidden when deleting admin's player
- rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+adminPlayer.UUID, nil, &user.APIToken)
+ rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+adminPlayer.UUID, nil, nil, &user.APIToken)
assert.Equal(t, http.StatusForbidden, rec.Code)
var apiError APIError
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
// admin should get a response
- rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+adminPlayer.UUID, nil, &admin.APIToken)
+ rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+adminPlayer.UUID, nil, nil, &admin.APIToken)
assert.Equal(t, http.StatusNoContent, rec.Code)
// adminPlayer should no longer exist in the database
@@ -560,7 +562,7 @@ func (ts *TestSuite) testAPIDeletePlayer(t *testing.T) {
assert.Equal(t, int64(0), count)
// user should be able to delete its own player
- rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+player.UUID, nil, &user.APIToken)
+ rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+player.UUID, nil, nil, &user.APIToken)
assert.Equal(t, http.StatusNoContent, rec.Code)
// player should no longer exist in the database
@@ -568,7 +570,7 @@ func (ts *TestSuite) testAPIDeletePlayer(t *testing.T) {
assert.Equal(t, int64(0), count)
// admin should be able to delete any user's player
- rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+secondPlayer.UUID, nil, &admin.APIToken)
+ rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/players/"+secondPlayer.UUID, nil, nil, &admin.APIToken)
assert.Equal(t, http.StatusNoContent, rec.Code)
// secondPlayer should no longer exist in the database
@@ -579,6 +581,205 @@ func (ts *TestSuite) testAPIDeletePlayer(t *testing.T) {
assert.Nil(t, ts.App.DeleteUser(&GOD, user))
}
+func (ts *TestSuite) testAPICreateDeleteOIDCIdentity(t *testing.T) {
+ adminUsername := "admin"
+ admin, _ := ts.CreateTestUser(t, ts.App, ts.Server, adminUsername)
+ assert.True(t, admin.IsAdmin)
+ username := "user"
+ user, _ := ts.CreateTestUser(t, ts.App, ts.Server, username)
+
+ fakeOIDCProvider1 := OIDCProvider{
+ Config: RegistrationOIDCConfig{
+ Name: "Fake IDP 1",
+ Issuer: "https://idm.example.com/oauth2/openid/drasl1",
+ },
+ }
+ fakeOIDCProvider2 := OIDCProvider{
+ Config: RegistrationOIDCConfig{
+ Name: "Fake IDP 2",
+ Issuer: "https://idm.example.com/oauth2/openid/drasl2",
+ },
+ }
+ provider1Subject1 := "11111111-1111-1111-1111-111111111111"
+ provider1Subject2 := "11111111-1111-1111-1111-222222222222"
+ provider1Subject3 := "11111111-1111-1111-1111-333333333333"
+
+ provider2Subject1 := "22222222-2222-2222-2222-111111111111"
+ provider2Subject2 := "22222222-2222-2222-2222-222222222222"
+ provider2Subject3 := "22222222-2222-2222-2222-333333333333"
+ // Monkey-patch these until we can properly mock an OIDC IDP in the test environment...
+ ts.App.OIDCProvidersByName[fakeOIDCProvider1.Config.Name] = &fakeOIDCProvider1
+ ts.App.OIDCProvidersByName[fakeOIDCProvider2.Config.Name] = &fakeOIDCProvider2
+ ts.App.OIDCProvidersByIssuer[fakeOIDCProvider1.Config.Issuer] = &fakeOIDCProvider1
+ ts.App.OIDCProvidersByIssuer[fakeOIDCProvider2.Config.Issuer] = &fakeOIDCProvider2
+
+ {
+ // admin should be able to create OIDC identities for themself
+ payload := APICreateOIDCIdentityRequest{
+ UserUUID: &admin.UUID,
+ Issuer: fakeOIDCProvider1.Config.Issuer,
+ Subject: provider1Subject1,
+ }
+ rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ var apiOIDCIdentity APIOIDCIdentity
+ assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiOIDCIdentity))
+ assert.Equal(t, provider1Subject1, apiOIDCIdentity.Subject)
+ assert.Equal(t, fakeOIDCProvider1.Config.Issuer, apiOIDCIdentity.Issuer)
+
+ assert.Nil(t, ts.App.DB.First(&admin, "uuid = ?", admin.UUID).Error)
+ assert.Equal(t, 1, len(admin.OIDCIdentities))
+ assert.Equal(t, fakeOIDCProvider1.Config.Issuer, admin.OIDCIdentities[0].Issuer)
+ assert.Equal(t, provider1Subject1, admin.OIDCIdentities[0].Subject)
+ }
+ {
+ // If UserUUID is ommitted, default to the caller's UUID
+ payload := APICreateOIDCIdentityRequest{
+ Issuer: fakeOIDCProvider2.Config.Issuer,
+ Subject: provider2Subject1,
+ }
+ rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ var apiOIDCIdentity APIOIDCIdentity
+ assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiOIDCIdentity))
+ assert.Equal(t, provider2Subject1, apiOIDCIdentity.Subject)
+ assert.Equal(t, fakeOIDCProvider2.Config.Issuer, apiOIDCIdentity.Issuer)
+ }
+ {
+ // admin should be able to create OIDC identities for other users
+ payload := APICreateOIDCIdentityRequest{
+ UserUUID: &user.UUID,
+ Issuer: fakeOIDCProvider1.Config.Issuer,
+ Subject: provider1Subject2,
+ }
+ rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ var apiOIDCIdentity APIOIDCIdentity
+ assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiOIDCIdentity))
+ assert.Equal(t, provider1Subject2, apiOIDCIdentity.Subject)
+ assert.Equal(t, fakeOIDCProvider1.Config.Issuer, apiOIDCIdentity.Issuer)
+ }
+ {
+ // Duplicate issuer and subject should fail
+ payload := APICreateOIDCIdentityRequest{
+ UserUUID: &admin.UUID,
+ Issuer: fakeOIDCProvider1.Config.Issuer,
+ Subject: provider1Subject1,
+ }
+ rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ var apiError APIError
+ assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
+ assert.Equal(t, "That Fake IDP 1 account is already linked to another user.", apiError.Message)
+ }
+ {
+ // Duplicate issuer on the same user should fail
+ payload := APICreateOIDCIdentityRequest{
+ UserUUID: &admin.UUID,
+ Issuer: fakeOIDCProvider1.Config.Issuer,
+ Subject: provider1Subject3,
+ }
+ rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ var apiError APIError
+ assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
+ assert.Equal(t, "That user is already linked to a Fake IDP 1 account.", apiError.Message)
+ }
+ {
+ // Non-admin should not be able to link an OIDC identity for another user
+ payload := APICreateOIDCIdentityRequest{
+ UserUUID: &admin.UUID,
+ Issuer: fakeOIDCProvider2.Config.Issuer,
+ Subject: provider2Subject3,
+ }
+ rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken)
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ var apiError APIError
+ assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
+ assert.Equal(t, "Can't link an OIDC account for another user unless you're an admin.", apiError.Message)
+ }
+ {
+ // Non-admin should be able to link an OIDC identity for themself
+ payload := APICreateOIDCIdentityRequest{
+ UserUUID: &user.UUID,
+ Issuer: fakeOIDCProvider2.Config.Issuer,
+ Subject: provider2Subject2,
+ }
+ rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ var apiOIDCIdentity APIOIDCIdentity
+ assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiOIDCIdentity))
+ assert.Equal(t, provider2Subject2, apiOIDCIdentity.Subject)
+ assert.Equal(t, fakeOIDCProvider2.Config.Issuer, apiOIDCIdentity.Issuer)
+ }
+ {
+ // admin should be able to delete OIDC identity for other users
+ payload := APIDeleteOIDCIdentityRequest{
+ UserUUID: &user.UUID,
+ Issuer: fakeOIDCProvider1.Config.Issuer,
+ }
+ rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
+ assert.Equal(t, http.StatusNoContent, rec.Code)
+ }
+ {
+ // Add the identity back for future tests...
+ payload := APICreateOIDCIdentityRequest{
+ UserUUID: &user.UUID,
+ Issuer: fakeOIDCProvider1.Config.Issuer,
+ Subject: provider1Subject2,
+ }
+ rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &admin.APIToken)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ }
+ {
+ // Non-admin user should not be able to delete OIDC identity for other users
+ payload := APIDeleteOIDCIdentityRequest{
+ UserUUID: &admin.UUID,
+ Issuer: fakeOIDCProvider1.Config.Issuer,
+ }
+ rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken)
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ var apiError APIError
+ assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
+ assert.Equal(t, "Can't unlink an OIDC account for another user unless you're an admin.", apiError.Message)
+ }
+ {
+ // Non-admin user should be able to delete OIDC identity for themself
+ payload := APIDeleteOIDCIdentityRequest{
+ UserUUID: &user.UUID,
+ Issuer: fakeOIDCProvider2.Config.Issuer,
+ }
+ rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken)
+ assert.Equal(t, http.StatusNoContent, rec.Code)
+ }
+ {
+ // Can't delete nonexistent OIDC identity
+ payload := APIDeleteOIDCIdentityRequest{
+ UserUUID: &user.UUID,
+ Issuer: fakeOIDCProvider2.Config.Issuer,
+ }
+ rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken)
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+ var apiError APIError
+ assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
+ assert.Equal(t, "No linked Fake IDP 2 account found.", apiError.Message)
+ }
+ {
+ // Can't delete last OIDC identity
+ payload := APIDeleteOIDCIdentityRequest{
+ UserUUID: &user.UUID,
+ Issuer: fakeOIDCProvider1.Config.Issuer,
+ }
+ rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/oidc-identities", payload, nil, &user.APIToken)
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ var apiError APIError
+ assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
+ assert.Equal(t, "Can't remove the last linked OIDC account.", apiError.Message)
+ }
+ assert.Nil(t, ts.App.DeleteUser(&GOD, admin))
+ assert.Nil(t, ts.App.DeleteUser(&GOD, user))
+}
+
func (ts *TestSuite) testAPIGetInvites(t *testing.T) {
username1 := "admin"
admin, _ := ts.CreateTestUser(t, ts.App, ts.Server, username1)
@@ -628,13 +829,13 @@ func (ts *TestSuite) testAPIDeleteInvite(t *testing.T) {
assert.Nil(t, err)
// user (not admin) should get a StatusForbidden
- rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/invites/"+invite.Code, nil, &user.APIToken)
+ rec := ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/invites/"+invite.Code, nil, nil, &user.APIToken)
assert.Equal(t, http.StatusForbidden, rec.Code)
var apiError APIError
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
// admin should get a response
- rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/invites/"+invite.Code, nil, &admin.APIToken)
+ rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/invites/"+invite.Code, nil, nil, &admin.APIToken)
assert.Equal(t, http.StatusNoContent, rec.Code)
// invite should no longer exist in the database
@@ -642,6 +843,11 @@ func (ts *TestSuite) testAPIDeleteInvite(t *testing.T) {
assert.Nil(t, ts.App.DB.Model(&Invite{}).Where("code = ?", invite.Code).Count(&count).Error)
assert.Equal(t, int64(0), count)
+ // should not be able to delete the same invite twice
+ rec = ts.Delete(t, ts.Server, DRASL_API_PREFIX+"/invites/"+invite.Code, nil, nil, &admin.APIToken)
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+ assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiError))
+
assert.Nil(t, ts.App.DeleteUser(&GOD, admin))
assert.Nil(t, ts.App.DeleteUser(&GOD, user))
}
diff --git a/auth.go b/auth.go
index 39e852f..2c8082c 100644
--- a/auth.go
+++ b/auth.go
@@ -39,18 +39,23 @@ type UserResponse struct {
Properties []UserProperty `json:"properties"`
}
-var invalidCredentialsBlob []byte = Unwrap(json.Marshal(ErrorResponse{
- Error: Ptr("ForbiddenOperationException"),
- ErrorMessage: Ptr("Invalid credentials. Invalid username or password."),
-}))
-var invalidAccessTokenBlob []byte = Unwrap(json.Marshal(ErrorResponse{
- Error: Ptr("ForbiddenOperationException"),
- ErrorMessage: Ptr("Invalid token."),
-}))
-var playerNotFoundBlob []byte = Unwrap(json.Marshal(ErrorResponse{
- Error: Ptr("IllegalArgumentException"),
- ErrorMessage: Ptr("Player not found."),
-}))
+var invalidCredentialsError = &YggdrasilError{
+ Code: http.StatusUnauthorized,
+ Error_: mo.Some("ForbiddenOperationException"),
+ ErrorMessage: mo.Some("Invalid credentials. Invalid username or password."),
+}
+
+var invalidAccessTokenError = &YggdrasilError{
+ Code: http.StatusForbidden,
+ Error_: mo.Some("ForbiddenOperationException"),
+ ErrorMessage: mo.Some("Invalid token"),
+}
+
+var playerNotFoundError = &YggdrasilError{
+ Code: http.StatusBadRequest,
+ Error_: mo.Some("IllegalArgumentException"),
+ ErrorMessage: mo.Some("Player not found."),
+}
type serverInfoResponse struct {
Status string `json:"Status"`
@@ -94,6 +99,57 @@ type authenticateResponse struct {
User *UserResponse `json:"user,omitempty"`
}
+func (app *App) AuthAuthenticateUser(c echo.Context, playerNameOrUsername string, password string) (*User, mo.Option[Player], error) {
+ var user *User
+ player := mo.None[Player]()
+
+ var playerStruct Player
+ if err := app.DB.Preload("User").First(&playerStruct, "name = ?", playerNameOrUsername).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ var userStruct User
+ if err := app.DB.First(&userStruct, "username = ?", playerNameOrUsername).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, mo.None[Player](), invalidCredentialsError
+ }
+ return nil, mo.None[Player](), err
+ }
+ user = &userStruct
+ if len(user.Players) == 1 {
+ player = mo.Some(user.Players[0])
+ }
+ } else {
+ return nil, mo.None[Player](), err
+ }
+ } else {
+ // player query succeeded
+ player = mo.Some(playerStruct)
+ user = &player.ToPointer().User
+ }
+
+ if password == user.MinecraftToken {
+ return user, player, nil
+ }
+
+ if !app.Config.AllowPasswordLogin || len(app.OIDCProvidersByName) > 0 {
+ return nil, mo.None[Player](), invalidCredentialsError
+ }
+
+ passwordHash, err := HashPassword(password, user.PasswordSalt)
+ if err != nil {
+ return nil, mo.None[Player](), err
+ }
+
+ if !bytes.Equal(passwordHash, user.PasswordHash) {
+ return nil, mo.None[Player](), invalidCredentialsError
+ }
+
+ if user.IsLocked {
+ return nil, mo.None[Player](), invalidCredentialsError
+ }
+
+ return user, player, nil
+}
+
// POST /authenticate
// https://minecraft.wiki/w/Yggdrasil#Authenticate
func AuthAuthenticate(app *App) func(c echo.Context) error {
@@ -103,41 +159,11 @@ func AuthAuthenticate(app *App) func(c echo.Context) error {
return err
}
- usernameOrPlayerName := req.Username
-
- var user User
- player := mo.None[Player]()
-
- if err := app.DB.First(&user, "username = ?", usernameOrPlayerName).Error; err == nil {
- if len(user.Players) == 1 {
- player = mo.Some(user.Players[0])
- }
- } else {
- var playerStruct Player
- if errors.Is(err, gorm.ErrRecordNotFound) {
- if err := app.DB.Preload("User").First(&playerStruct, "name = ?", usernameOrPlayerName).Error; err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return c.JSONBlob(http.StatusUnauthorized, invalidCredentialsBlob)
- } else {
- return err
- }
- }
- player = mo.Some(playerStruct)
- user = playerStruct.User
- } else {
- return err
- }
- }
-
- passwordHash, err := HashPassword(req.Password, user.PasswordSalt)
+ user, player, err := app.AuthAuthenticateUser(c, req.Username, req.Password)
if err != nil {
return err
}
- if !bytes.Equal(passwordHash, user.PasswordHash) {
- return c.JSONBlob(http.StatusUnauthorized, invalidCredentialsBlob)
- }
-
playerUUID := mo.None[string]()
if p, ok := player.Get(); ok {
playerUUID = mo.Some(p.UUID)
@@ -199,7 +225,7 @@ func AuthAuthenticate(app *App) func(c echo.Context) error {
Name: p.Name,
}
}
- availableProfilesArray, err := getAvailableProfiles(&user)
+ availableProfilesArray, err := getAvailableProfiles(user)
if err != nil {
return err
}
@@ -267,7 +293,7 @@ func AuthRefresh(app *App) func(c echo.Context) error {
client := app.GetClient(req.AccessToken, StalePolicyAllow)
if client == nil || client.ClientToken != req.ClientToken {
- return c.JSONBlob(http.StatusUnauthorized, invalidAccessTokenBlob)
+ return invalidAccessTokenError
}
user := client.User
player := client.Player
@@ -288,7 +314,7 @@ func AuthRefresh(app *App) func(c echo.Context) error {
}
}
if player == nil {
- return c.JSONBlob(http.StatusBadRequest, playerNotFoundBlob)
+ return playerNotFoundError
}
}
}
@@ -379,22 +405,12 @@ func AuthSignout(app *App) func(c echo.Context) error {
return err
}
- var user User
- result := app.DB.First(&user, "username = ?", req.Username)
- if result.Error != nil {
- return result.Error
- }
-
- passwordHash, err := HashPassword(req.Password, user.PasswordSalt)
+ user, _, err := app.AuthAuthenticateUser(c, req.Username, req.Password)
if err != nil {
return err
}
- if !bytes.Equal(passwordHash, user.PasswordHash) {
- return c.JSONBlob(http.StatusUnauthorized, invalidCredentialsBlob)
- }
-
- err = app.InvalidateUser(app.DB, &user)
+ err = app.InvalidateUser(app.DB, user)
if err != nil {
return err
}
@@ -419,7 +435,7 @@ func AuthInvalidate(app *App) func(c echo.Context) error {
client := app.GetClient(req.AccessToken, StalePolicyAllow)
if client == nil {
- return c.JSONBlob(http.StatusUnauthorized, invalidAccessTokenBlob)
+ return invalidAccessTokenError
}
if client.Player == nil {
diff --git a/auth_test.go b/auth_test.go
index 86daea0..8279e9c 100644
--- a/auth_test.go
+++ b/auth_test.go
@@ -76,6 +76,22 @@ func (ts *TestSuite) testAuthenticate(t *testing.T) {
// We did not pass requestUser
assert.Nil(t, response.User)
}
+ {
+ // Authentication should succeed if we use the player's Minecraft token
+ // as the password
+
+ var user User
+ assert.Nil(t, ts.App.DB.First(&user, "username = ?", TEST_PLAYER_NAME).Error)
+
+ response := ts.authenticate(t, TEST_PLAYER_NAME, user.MinecraftToken)
+
+ // We did not pass an agent
+ assert.Nil(t, response.SelectedProfile)
+ assert.Nil(t, response.AvailableProfiles)
+
+ // We did not pass requestUser
+ assert.Nil(t, response.User)
+ }
{
// If we send our own clientToken, the server should use it
clientToken := "12345678901234567890123456789012"
@@ -161,7 +177,7 @@ func (ts *TestSuite) testAuthenticate(t *testing.T) {
rec := ts.PostJSON(t, ts.Server, "/authenticate", payload, nil, nil)
// Authentication should fail
- var response ErrorResponse
+ var response YggdrasilErrorResponse
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
assert.Equal(t, "ForbiddenOperationException", *response.Error)
assert.Equal(t, "Invalid credentials. Invalid username or password.", *response.ErrorMessage)
@@ -225,15 +241,28 @@ func (ts *TestSuite) testAuthenticate(t *testing.T) {
}
}
+func findProfile(profiles []Profile, playerName string) mo.Option[Profile] {
+ for _, profile := range profiles {
+ if profile.Name == playerName {
+ return mo.Some(profile)
+ }
+ }
+ return mo.None[Profile]()
+}
+
func (ts *TestSuite) testAuthenticateMultipleProfiles(t *testing.T) {
{
var user User
assert.Nil(t, ts.App.DB.First(&user, "username = ?", TEST_USERNAME).Error)
+ // Set up two players on the test account, each distrinct from TEST_USERNAME
+ firstPlayerName := "FirstPlayer"
secondPlayerName := "SecondPlayer"
- // player := user.Players[0]
- otherPlayer, err := ts.App.CreatePlayer(&GOD, user.UUID, secondPlayerName, nil, false, nil, nil, nil, nil, nil, nil, nil)
+ _, err := ts.App.UpdatePlayer(&GOD, user.Players[0], &firstPlayerName, nil, nil, nil, nil, false, nil, nil, false)
+ assert.Nil(t, err)
+
+ secondPlayer, err := ts.App.CreatePlayer(&GOD, user.UUID, secondPlayerName, nil, false, nil, nil, nil, nil, nil, nil, nil)
assert.Nil(t, err)
authenticatePayload := authenticateRequest{
@@ -259,14 +288,7 @@ func (ts *TestSuite) testAuthenticateMultipleProfiles(t *testing.T) {
assert.Equal(t, 2, len(*authenticateRes.AvailableProfiles))
- p := mo.None[Profile]()
- for _, availableProfile := range *authenticateRes.AvailableProfiles {
- if availableProfile.Name == secondPlayerName {
- p = mo.Some(availableProfile)
- break
- }
- }
- profile, ok := p.Get()
+ profile, ok := findProfile(*authenticateRes.AvailableProfiles, secondPlayerName).Get()
assert.True(t, ok)
// Now, refresh to select a profile
@@ -287,7 +309,22 @@ func (ts *TestSuite) testAuthenticateMultipleProfiles(t *testing.T) {
assert.Equal(t, profile, *refreshRes.SelectedProfile)
- assert.Nil(t, ts.App.DeletePlayer(&GOD, &otherPlayer))
+ // When the username matches one of the available player names, that
+ // player should automatically become the selectedProfile.
+ _, err = ts.App.UpdatePlayer(&GOD, user.Players[0], Ptr(TEST_USERNAME), nil, nil, nil, nil, false, nil, nil, false)
+ assert.Nil(t, err)
+
+ rec = ts.PostJSON(t, ts.Server, "/authenticate", authenticatePayload, nil, nil)
+
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.Nil(t, json.NewDecoder(rec.Body).Decode(&authenticateRes))
+
+ usernameProfile, ok := findProfile(*authenticateRes.AvailableProfiles, TEST_USERNAME).Get()
+ assert.True(t, ok)
+
+ assert.Equal(t, usernameProfile, *authenticateRes.SelectedProfile)
+
+ assert.Nil(t, ts.App.DeletePlayer(&GOD, &secondPlayer))
}
}
@@ -341,11 +378,11 @@ func (ts *TestSuite) testInvalidate(t *testing.T) {
rec := ts.PostJSON(t, ts.Server, "/invalidate", payload, nil, nil)
// Invalidate should fail
- var response ErrorResponse
+ var response YggdrasilErrorResponse
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
- assert.Equal(t, http.StatusUnauthorized, rec.Code)
+ assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Equal(t, "ForbiddenOperationException", *response.Error)
- assert.Equal(t, "Invalid token.", *response.ErrorMessage)
+ assert.Equal(t, "Invalid token", *response.ErrorMessage)
}
}
@@ -412,7 +449,7 @@ func (ts *TestSuite) testRefresh(t *testing.T) {
expectedUser := UserResponse{
ID: Unwrap(UUIDToID(player.UUID)),
- Properties: []UserProperty{UserProperty{
+ Properties: []UserProperty{{
Name: "preferredLanguage",
Value: player.User.PreferredLanguage,
}},
@@ -431,7 +468,7 @@ func (ts *TestSuite) testRefresh(t *testing.T) {
rec := ts.PostJSON(t, ts.Server, "/refresh", payload, nil, nil)
// Refresh should fail
- var response ErrorResponse
+ var response YggdrasilErrorResponse
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
assert.Equal(t, "ForbiddenOperationException", *response.Error)
}
@@ -445,10 +482,10 @@ func (ts *TestSuite) testRefresh(t *testing.T) {
rec := ts.PostJSON(t, ts.Server, "/refresh", payload, nil, nil)
// Refresh should fail
- var response ErrorResponse
+ var response YggdrasilErrorResponse
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
assert.Equal(t, "ForbiddenOperationException", *response.Error)
- assert.Equal(t, "Invalid token.", *response.ErrorMessage)
+ assert.Equal(t, "Invalid token", *response.ErrorMessage)
}
}
@@ -503,7 +540,7 @@ func (ts *TestSuite) testSignout(t *testing.T) {
rec := ts.PostJSON(t, ts.Server, "/signout", payload, nil, nil)
// Signout should fail
- var response ErrorResponse
+ var response YggdrasilErrorResponse
assert.Nil(t, json.NewDecoder(rec.Body).Decode(&response))
assert.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Equal(t, "ForbiddenOperationException", *response.Error)
diff --git a/authlib_injector.go b/authlib_injector.go
index 825d92e..73a4cda 100644
--- a/authlib_injector.go
+++ b/authlib_injector.go
@@ -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(
diff --git a/authlib_injector_test.go b/authlib_injector_test.go
index 7208ae2..87b27fd 100644
--- a/authlib_injector_test.go
+++ b/authlib_injector_test.go
@@ -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)
diff --git a/common.go b/common.go
index 7ae3085..2625f83 100644
--- a/common.go
+++ b/common.go
@@ -2,6 +2,7 @@ package main
import (
"bytes"
+ "crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/hex"
@@ -10,11 +11,13 @@ import (
"fmt"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
+ "github.com/samber/mo"
+ "github.com/zitadel/oidc/v3/pkg/client/rp"
"image/png"
"io"
"log"
"lukechampine.com/blake3"
- "math/rand"
+ mathRand "math/rand"
"net/http"
"net/url"
"os"
@@ -25,34 +28,76 @@ import (
"time"
)
+func (app *App) AEADEncrypt(plaintext []byte) ([]byte, error) {
+ nonceSize := app.AEAD.NonceSize()
+
+ nonce := make([]byte, nonceSize)
+ if _, err := rand.Read(nonce); err != nil {
+ return nil, err
+ }
+
+ ciphertext := app.AEAD.Seal(nil, nonce, plaintext, nil)
+ return append(nonce, ciphertext...), nil
+}
+
+func (app *App) AEADDecrypt(ciphertext []byte) ([]byte, error) {
+ nonceSize := app.AEAD.NonceSize()
+ if len(ciphertext) < nonceSize {
+ return nil, errors.New("ciphertext too short")
+ }
+
+ nonce := ciphertext[0:nonceSize]
+ message := ciphertext[nonceSize:]
+ return app.AEAD.Open(nil, nonce, message, nil)
+}
+
+func (app *App) EncryptCookieValue(plaintext string) (string, error) {
+ ciphertext, err := app.AEADEncrypt([]byte(plaintext))
+ if err != nil {
+ return "", err
+ }
+ return base64.StdEncoding.EncodeToString(ciphertext), nil
+}
+
+func (app *App) DecryptCookieValue(armored string) ([]byte, error) {
+ ciphertext, err := base64.StdEncoding.DecodeString(armored)
+ if err != nil {
+ return nil, err
+ }
+ return app.AEADDecrypt(ciphertext)
+}
+
+type OIDCProvider struct {
+ Config RegistrationOIDCConfig
+ RelyingParty rp.RelyingParty
+}
+
type UserError struct {
- Code int
+ Code mo.Option[int]
Err error
}
-func (e UserError) Error() string {
+func (e *UserError) Error() string {
return e.Err.Error()
}
func NewUserError(code int, message string, args ...interface{}) error {
return &UserError{
- Code: code,
+ Code: mo.Some(code),
Err: fmt.Errorf(message, args...),
}
}
func NewBadRequestUserError(message string, args ...interface{}) error {
return &UserError{
- Code: http.StatusBadRequest,
+ Code: mo.Some(http.StatusBadRequest),
Err: fmt.Errorf(message, args...),
}
}
-func NewForbiddenUserError(message string, args ...interface{}) error {
- return &UserError{
- Code: http.StatusForbidden,
- Err: fmt.Errorf(message, args...),
- }
+var InternalServerError error = &UserError{
+ Code: mo.Some(http.StatusInternalServerError),
+ Err: errors.New("Internal server error"),
}
type ConstantsType struct {
@@ -187,24 +232,22 @@ type Agent struct {
Version uint `json:"version"`
}
-var DEFAULT_ERROR_BLOB []byte = Unwrap(json.Marshal(ErrorResponse{
- ErrorMessage: Ptr("internal server error"),
-}))
+type YggdrasilError struct {
+ Code int
+ Error_ mo.Option[string]
+ ErrorMessage mo.Option[string]
+}
-type ErrorResponse struct {
+func (e *YggdrasilError) Error() string {
+ return e.ErrorMessage.OrElse(e.Error_.OrElse("internal server error"))
+}
+
+type YggdrasilErrorResponse struct {
Path *string `json:"path,omitempty"`
Error *string `json:"error,omitempty"`
ErrorMessage *string `json:"errorMessage,omitempty"`
}
-func MakeErrorResponse(c *echo.Context, code int, error_ *string, errorMessage *string) error {
- return (*c).JSON(code, ErrorResponse{
- Path: Ptr((*c).Request().URL.Path),
- Error: error_,
- ErrorMessage: errorMessage,
- })
-}
-
type PathType int
const (
@@ -230,18 +273,27 @@ func GetPathType(path_ string) PathType {
}
func (app *App) HandleYggdrasilError(err error, c *echo.Context) error {
- if httpError, ok := err.(*echo.HTTPError); ok {
+ path_ := (*c).Request().URL.Path
+ var yggdrasilError *YggdrasilError
+ if errors.As(err, &yggdrasilError) {
+ return (*c).JSON(yggdrasilError.Code, YggdrasilErrorResponse{
+ Path: &path_,
+ Error: yggdrasilError.Error_.ToPointer(),
+ ErrorMessage: yggdrasilError.ErrorMessage.ToPointer(),
+ })
+ }
+ var httpError *echo.HTTPError
+ if errors.As(err, &httpError) {
switch httpError.Code {
case http.StatusNotFound,
http.StatusRequestEntityTooLarge,
http.StatusTooManyRequests,
http.StatusMethodNotAllowed:
- path_ := (*c).Request().URL.Path
- return (*c).JSON(httpError.Code, ErrorResponse{Path: &path_})
+ return (*c).JSON(httpError.Code, YggdrasilErrorResponse{Path: &path_})
}
}
app.LogError(err, c)
- return (*c).JSON(http.StatusInternalServerError, ErrorResponse{ErrorMessage: Ptr("internal server error")})
+ return (*c).JSON(http.StatusInternalServerError, YggdrasilErrorResponse{Path: &path_, ErrorMessage: Ptr("internal server error")})
}
@@ -521,7 +573,7 @@ func (app *App) DeleteCapeIfUnused(hash *string) error {
return nil
}
-func StripQueryParam(urlString string, param string) (string, error) {
+func UnsetQueryParam(urlString string, param string) (string, error) {
parsedURL, err := url.Parse(urlString)
if err != nil {
return "", err
@@ -535,6 +587,20 @@ func StripQueryParam(urlString string, param string) (string, error) {
return parsedURL.String(), nil
}
+func SetQueryParam(urlString string, param string, value string) (string, error) {
+ parsedURL, err := url.Parse(urlString)
+ if err != nil {
+ return "", err
+ }
+
+ query := parsedURL.Query()
+ query.Set(param, value)
+
+ parsedURL.RawQuery = query.Encode()
+
+ return parsedURL.String(), nil
+}
+
func (app *App) CreateInvite() (Invite, error) {
code, err := RandomBase62(8)
if err != nil {
@@ -705,7 +771,7 @@ func (app *App) ChooseFileForUser(player *Player, glob string) (*string, error)
}
seed := int64(binary.BigEndian.Uint64(userUUID[8:]))
- r := rand.New(rand.NewSource(seed))
+ r := mathRand.New(mathRand.NewSource(seed))
fileIndex := r.Intn(len(filenames))
diff --git a/common_test.go b/common_test.go
new file mode 100644
index 0000000..1564e7b
--- /dev/null
+++ b/common_test.go
@@ -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))
+}
diff --git a/config.go b/config.go
index 42b118a..4c2fe14 100644
--- a/config.go
+++ b/config.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"github.com/BurntSushi/toml"
+ mapset "github.com/deckarep/golang-set/v2"
"github.com/dgraph-io/ristretto"
"log"
"net/url"
@@ -36,32 +37,65 @@ type FallbackAPIServer struct {
DenyUnknownUsers bool
}
+type RegistrationOIDCConfig struct {
+ Name string
+ Issuer string
+ ClientID string
+ ClientSecret string
+ PKCE bool
+ RequireInvite bool
+ AllowChoosingPlayerName bool
+}
+
type transientUsersConfig struct {
Allow bool
UsernameRegex string
Password string
}
-type registrationNewPlayerConfig struct {
- Allow bool
+type v2RegistrationNewPlayerConfig struct {
AllowChoosingUUID bool
- RequireInvite bool
+}
+
+type registrationNewPlayerConfig struct {
+ v2RegistrationNewPlayerConfig
+ Allow bool
+ RequireInvite bool
+}
+
+type v2RegistrationExistingPlayerConfig struct {
+ Nickname string
+ SessionURL string
+ AccountURL string
+ SetSkinURL string
+ RequireSkinVerification bool
}
type registrationExistingPlayerConfig struct {
+ v2RegistrationExistingPlayerConfig
+ Allow bool
+ RequireInvite bool
+}
+
+type createNewPlayerConfig struct {
+ Allow bool
+ AllowChoosingUUID bool
+}
+
+type importExistingPlayerConfig struct {
Allow bool
Nickname string
SessionURL string
AccountURL string
SetSkinURL string
RequireSkinVerification bool
- RequireInvite bool
}
type Config struct {
AllowCapes bool
AllowChangingPlayerName bool
AllowMultipleAccessTokens bool
+ AllowPasswordLogin bool
AllowSkins bool
AllowTextureFromURL bool
ApplicationOwner string
@@ -69,6 +103,7 @@ type Config struct {
BaseURL string
BodyLimit bodyLimitConfig
CORSAllowOrigins []string
+ CreateNewPlayer createNewPlayerConfig
DataDirectory string
DefaultAdmins []string
DefaultPreferredLanguage string
@@ -80,9 +115,11 @@ type Config struct {
FallbackAPIServers []FallbackAPIServer
ForwardSkins bool
InstanceName string
+ ImportExistingPlayer importExistingPlayerConfig
ListenAddress string
LogRequests bool
MinPasswordLength int
+ RegistrationOIDC []RegistrationOIDCConfig
PreMigrationBackups bool
RateLimit rateLimitConfig
RegistrationExistingPlayer registrationExistingPlayerConfig
@@ -110,15 +147,20 @@ var defaultBodyLimitConfig = bodyLimitConfig{
func DefaultConfig() Config {
return Config{
- AllowCapes: true,
- AllowChangingPlayerName: true,
- AllowSkins: true,
- AllowTextureFromURL: false,
- ApplicationName: "Drasl",
- ApplicationOwner: "Anonymous",
- BaseURL: "",
- BodyLimit: defaultBodyLimitConfig,
- CORSAllowOrigins: []string{},
+ AllowCapes: true,
+ AllowChangingPlayerName: true,
+ AllowPasswordLogin: true,
+ AllowSkins: true,
+ AllowTextureFromURL: false,
+ ApplicationName: "Drasl",
+ ApplicationOwner: "Anonymous",
+ BaseURL: "",
+ BodyLimit: defaultBodyLimitConfig,
+ CORSAllowOrigins: []string{},
+ CreateNewPlayer: createNewPlayerConfig{
+ Allow: true,
+ AllowChoosingUUID: false,
+ },
DataDirectory: GetDefaultDataDirectory(),
DefaultAdmins: []string{},
DefaultPreferredLanguage: "en",
@@ -128,20 +170,23 @@ func DefaultConfig() Config {
EnableFooter: true,
EnableWebFrontEnd: true,
ForwardSkins: true,
- InstanceName: "Drasl",
- ListenAddress: "0.0.0.0:25585",
- LogRequests: true,
- MinPasswordLength: 8,
- OfflineSkins: true,
- PreMigrationBackups: true,
- RateLimit: defaultRateLimitConfig,
+ ImportExistingPlayer: importExistingPlayerConfig{
+ Allow: false,
+ },
+ InstanceName: "Drasl",
+ ListenAddress: "0.0.0.0:25585",
+ LogRequests: true,
+ MinPasswordLength: 8,
+ RegistrationOIDC: []RegistrationOIDCConfig{},
+ OfflineSkins: true,
+ PreMigrationBackups: true,
+ RateLimit: defaultRateLimitConfig,
RegistrationExistingPlayer: registrationExistingPlayerConfig{
Allow: false,
},
RegistrationNewPlayer: registrationNewPlayerConfig{
- Allow: true,
- AllowChoosingUUID: false,
- RequireInvite: false,
+ Allow: true,
+ RequireInvite: false,
},
RequestCache: ristretto.Config{
// Defaults from https://pkg.go.dev/github.com/dgraph-io/ristretto#readme-config
@@ -189,25 +234,44 @@ func CleanConfig(config *Config) error {
if config.DefaultMaxPlayerCount < 0 && config.DefaultMaxPlayerCount != Constants.MaxPlayerCountUnlimited {
return fmt.Errorf("DefaultMaxPlayerCount must be >= 0, or %d to indicate unlimited players", Constants.MaxPlayerCountUnlimited)
}
+ if config.RegistrationNewPlayer.Allow {
+ if !config.CreateNewPlayer.Allow {
+ return errors.New("If RegisterNewPlayer is allowed, CreateNewPlayer must be allowed.")
+ }
+ }
if config.RegistrationExistingPlayer.Allow {
- if config.RegistrationExistingPlayer.Nickname == "" {
- return errors.New("RegistrationExistingPlayer.Nickname must be set")
+ if !config.ImportExistingPlayer.Allow {
+ return errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer must be allowed.")
}
- if config.RegistrationExistingPlayer.SessionURL == "" {
- return errors.New("RegistrationExistingPlayer.SessionURL must be set. Example: https://sessionserver.mojang.com")
+ if config.ImportExistingPlayer.Nickname == "" {
+ return errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.Nickname must be set")
}
- if _, err := url.Parse(config.RegistrationExistingPlayer.SessionURL); err != nil {
- return fmt.Errorf("Invalid RegistrationExistingPlayer.SessionURL: %s", err)
+ if config.ImportExistingPlayer.SessionURL == "" {
+ return errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.SessionURL must be set. Example: https://sessionserver.mojang.com")
}
- config.RegistrationExistingPlayer.SessionURL = strings.TrimRight(config.RegistrationExistingPlayer.SessionURL, "/")
+ if config.ImportExistingPlayer.AccountURL == "" {
+ return errors.New("If RegistrationExistingPlayer is allowed, ImportExistingPlayer.AccountURL must be set. Example: https://api.mojang.com")
+ }
+ }
+ if config.ImportExistingPlayer.Allow {
+ if config.ImportExistingPlayer.Nickname == "" {
+ return errors.New("ImportExistingPlayer.Nickname must be set")
+ }
+ if config.ImportExistingPlayer.SessionURL == "" {
+ return errors.New("ImportExistingPlayer.SessionURL must be set. Example: https://sessionserver.mojang.com")
+ }
+ if _, err := url.Parse(config.ImportExistingPlayer.SessionURL); err != nil {
+ return fmt.Errorf("Invalid ImportExistingPlayer.SessionURL: %s", err)
+ }
+ config.ImportExistingPlayer.SessionURL = strings.TrimRight(config.ImportExistingPlayer.SessionURL, "/")
- if config.RegistrationExistingPlayer.AccountURL == "" {
- return errors.New("RegistrationExistingPlayer.AccountURL must be set. Example: https://api.mojang.com")
+ if config.ImportExistingPlayer.AccountURL == "" {
+ return errors.New("ImportExistingPlayer.AccountURL must be set. Example: https://api.mojang.com")
}
- if _, err := url.Parse(config.RegistrationExistingPlayer.AccountURL); err != nil {
- return fmt.Errorf("Invalid RegistrationExistingPlayer.AccountURL: %s", err)
+ if _, err := url.Parse(config.ImportExistingPlayer.AccountURL); err != nil {
+ return fmt.Errorf("Invalid ImportExistingPlayer.AccountURL: %s", err)
}
- config.RegistrationExistingPlayer.AccountURL = strings.TrimRight(config.RegistrationExistingPlayer.AccountURL, "/")
+ config.ImportExistingPlayer.AccountURL = strings.TrimRight(config.ImportExistingPlayer.AccountURL, "/")
}
for _, fallbackAPIServer := range PtrSlice(config.FallbackAPIServers) {
if fallbackAPIServer.Nickname == "" {
@@ -243,6 +307,17 @@ func CleanConfig(config *Config) error {
}
}
}
+
+ oidcNames := mapset.NewSet[string]()
+ for _, oidcConfig := range PtrSlice(config.RegistrationOIDC) {
+ if oidcNames.Contains(oidcConfig.Name) {
+ return fmt.Errorf("Duplicate RegistrationOIDC Name: %s", oidcConfig.Name)
+ }
+ if _, err := url.Parse(oidcConfig.Issuer); err != nil {
+ return fmt.Errorf("Invalid RegistrationOIDC URL %s: %s", oidcConfig.Issuer, err)
+ }
+ oidcNames.Add(oidcConfig.Name)
+ }
return nil
}
@@ -259,10 +334,49 @@ DefaultAdmins = [""]
[RegistrationNewPlayer]
Allow = true
-AllowChoosingUUID = true
RequireInvite = true
`
+func HandleDeprecations(config Config, metadata *toml.MetaData) {
+ warningTemplate := "Warning: config option %s is deprecated and will be removed in a future version. Use %s instead."
+ if metadata.IsDefined("RegistrationNewPlayer", "AllowChoosingUUID") {
+ log.Printf(warningTemplate, "RegistrationNewPlayer.AllowChoosingUUID", "CreateNewPlayer.AllowChoosingUUID")
+ if !metadata.IsDefined("CreateNewPlayer", "AllowChoosingUUID") {
+ config.CreateNewPlayer.AllowChoosingUUID = config.RegistrationNewPlayer.AllowChoosingUUID
+ }
+ }
+ if metadata.IsDefined("RegistrationExistingPlayer", "Nickname") {
+ log.Printf(warningTemplate, "RegistrationExistingPlayer.Nickname", "ImportExistingPlayer.Nickname")
+ if !metadata.IsDefined("ImportExistingPlayer", "Nickname") {
+ config.ImportExistingPlayer.Nickname = config.RegistrationExistingPlayer.Nickname
+ }
+ }
+ if metadata.IsDefined("RegistrationExistingPlayer", "SessionURL") {
+ log.Printf(warningTemplate, "RegistrationExistingPlayer.SessionURL", "ImportExistingPlayer.SessionURL")
+ if !metadata.IsDefined("ImportExistingPlayer", "SessionURL") {
+ config.ImportExistingPlayer.SessionURL = config.RegistrationExistingPlayer.SessionURL
+ }
+ }
+ if metadata.IsDefined("RegistrationExistingPlayer", "AccountURL") {
+ log.Printf(warningTemplate, "RegistrationExistingPlayer.AccountURL", "ImportExistingPlayer.AccountURL")
+ if !metadata.IsDefined("ImportExistingPlayer", "AccountURL") {
+ config.ImportExistingPlayer.AccountURL = config.RegistrationExistingPlayer.AccountURL
+ }
+ }
+ if metadata.IsDefined("RegistrationExistingPlayer", "SetSkinURL") {
+ log.Printf(warningTemplate, "RegistrationExistingPlayer.SetSkinURL", "ImportExistingPlayer.SetSkinURL")
+ if !metadata.IsDefined("ImportExistingPlayer", "SetSkinURL") {
+ config.ImportExistingPlayer.SetSkinURL = config.RegistrationExistingPlayer.SetSkinURL
+ }
+ }
+ if metadata.IsDefined("RegistrationExistingPlayer", "RequireSkinVerification") {
+ log.Printf(warningTemplate, "RegistrationExistingPlayer.RequireSkinVerification", "ImportExistingPlayer.RequireSkinVerification")
+ if !metadata.IsDefined("ImportExistingPlayer", "RequireSkinVerification") {
+ config.ImportExistingPlayer.RequireSkinVerification = config.RegistrationExistingPlayer.RequireSkinVerification
+ }
+ }
+}
+
func ReadOrCreateConfig(path string) *Config {
config := DefaultConfig()
@@ -282,6 +396,7 @@ func ReadOrCreateConfig(path string) *Config {
Check(err)
}
+ log.Println("Loading config from", path)
metadata, err := toml.DecodeFile(path, &config)
Check(err)
@@ -289,8 +404,7 @@ func ReadOrCreateConfig(path string) *Config {
log.Println("Warning: unknown config option", strings.Join(key, "."))
}
- log.Println("Loading config from", path)
-
+ HandleDeprecations(config, &metadata)
err = CleanConfig(&config)
if err != nil {
log.Fatal(fmt.Errorf("Error in config: %s", err))
diff --git a/config_test.go b/config_test.go
index 7e7f396..8dea8ba 100644
--- a/config_test.go
+++ b/config_test.go
@@ -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)
diff --git a/db.go b/db.go
index 2c28ce9..4cec2d7 100644
--- a/db.go
+++ b/db.go
@@ -30,7 +30,7 @@ func IsErrorUniqueFailed(err error) bool {
}
// Work around https://stackoverflow.com/questions/75489773/why-do-i-get-second-argument-to-errors-as-should-not-be-error-build-error-in
e := (errors.New("UNIQUE constraint failed")).(Error)
- return errors.As(err, &e) || IsErrorPlayerNameTakenByUsername(err) || IsErrorUsernameTakenByPlayerName(err)
+ return errors.As(err, &e)
}
func IsErrorUniqueFailedField(err error, field string) bool {
@@ -311,6 +311,10 @@ func Migrate(config *Config, dbPath mo.Option[string], db *gorm.DB, alreadyExist
if playerName != v3User.Username && allUsernames.Contains(playerName) {
playerName = v3User.Username
}
+ minecraftPassword, err := MakeMinecraftToken()
+ if err != nil {
+ return err
+ }
player := V4Player{
UUID: v3User.UUID,
Name: playerName,
@@ -332,6 +336,7 @@ func Migrate(config *Config, dbPath mo.Option[string], db *gorm.DB, alreadyExist
PasswordSalt: v3User.PasswordSalt,
PasswordHash: v3User.PasswordHash,
BrowserToken: v3User.BrowserToken,
+ MinecraftToken: minecraftPassword,
APIToken: v3User.APIToken,
PreferredLanguage: v3User.PreferredLanguage,
Players: []Player{player},
@@ -368,6 +373,11 @@ func Migrate(config *Config, dbPath mo.Option[string], db *gorm.DB, alreadyExist
return err
}
+ err = tx.AutoMigrate(&UserOIDCIdentity{})
+ if err != nil {
+ return err
+ }
+
err = tx.Exec(fmt.Sprintf(`
DROP TRIGGER IF EXISTS v4_insert_unique_username;
CREATE TRIGGER v4_insert_unique_username
@@ -437,6 +447,41 @@ func Migrate(config *Config, dbPath mo.Option[string], db *gorm.DB, alreadyExist
return err
}
+ err = tx.Exec(`
+ DROP TRIGGER IF EXISTS v4_insert_unique_user_oidc_identities;
+ CREATE TRIGGER v4_insert_unique_user_oidc_identities
+ BEFORE INSERT ON user_oidc_identities
+ BEGIN
+ SELECT RAISE(ABORT, 'UNIQUE constraint failed: user_oidc_identities.issuer, user_oidc_identities.subject')
+ WHERE EXISTS(
+ SELECT 1 from user_oidc_identities WHERE id != NEW.id AND issuer == NEW.issuer AND subject == NEW.subject
+ );
+
+ SELECT RAISE(ABORT, 'UNIQUE constraint failed: user_oidc_identities.issuer')
+ WHERE EXISTS(
+ SELECT 1 from user_oidc_identities WHERE id != NEW.id AND user_uuid == NEW.user_uuid AND issuer == NEW.issuer
+ );
+ END;
+
+ DROP TRIGGER IF EXISTS v4_update_unique_user_oidc_identities;
+ CREATE TRIGGER v4_update_unique_user_oidc_identities
+ BEFORE UPDATE ON user_oidc_identities
+ BEGIN
+ SELECT RAISE(ABORT, 'UNIQUE constraint failed: user_oidc_identities.issuer, user_oidc_identities.subject')
+ WHERE EXISTS(
+ SELECT 1 from user_oidc_identities WHERE id != NEW.id AND issuer == NEW.issuer AND subject == NEW.subject
+ );
+
+ SELECT RAISE(ABORT, 'UNIQUE constraint failed: user_oidc_identities.issuer')
+ WHERE EXISTS(
+ SELECT 1 from user_oidc_identities WHERE id != NEW.id AND user_uuid == NEW.user_uuid AND issuer == NEW.issuer
+ );
+ END;
+ `).Error
+ if err != nil {
+ return err
+ }
+
if err := setUserVersion(tx, userVersion); err != nil {
return err
}
diff --git a/doc/configuration.md b/doc/configuration.md
index 1364414..59e1aeb 100644
--- a/doc/configuration.md
+++ b/doc/configuration.md
@@ -61,19 +61,22 @@ Other available options:
-- `[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`.
diff --git a/doc/recipes.md b/doc/recipes.md
index 972be64..661c607 100644
--- a/doc/recipes.md
+++ b/doc/recipes.md
@@ -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
+```
+
+
+
+### 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.
+
+
+
+Show config.toml
+
+```
+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
```
@@ -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"
-
```
diff --git a/flake.nix b/flake.nix
index f175523..8d98fed 100644
--- a/flake.nix
+++ b/flake.nix
@@ -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 = ''
diff --git a/front.go b/front.go
index 700a476..149d6b2 100644
--- a/front.go
+++ b/front.go
@@ -1,12 +1,18 @@
package main
import (
+ "context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
+ mapset "github.com/deckarep/golang-set/v2"
+ "github.com/google/uuid"
+ "github.com/jxskiss/base62"
"github.com/labstack/echo/v4"
"github.com/samber/mo"
+ "github.com/zitadel/oidc/v3/pkg/client/rp"
+ "github.com/zitadel/oidc/v3/pkg/oidc"
"gorm.io/gorm"
"html/template"
"io"
@@ -14,6 +20,7 @@ import (
"net/url"
"path"
"strconv"
+ "strings"
)
/*
@@ -21,6 +28,14 @@ Web front end for creating user accounts, changing passwords, skins, player name
*/
const BROWSER_TOKEN_AGE_SEC = 24 * 60 * 60
+const COOKIE_PREFIX = "__Host-"
+const BROWSER_TOKEN_COOKIE_NAME = COOKIE_PREFIX + "browserToken"
+const SUCCESS_MESSAGE_COOKIE_NAME = COOKIE_PREFIX + "successMessage"
+const WARNING_MESSAGE_COOKIE_NAME = COOKIE_PREFIX + "warningMessage"
+const ERROR_MESSAGE_COOKIE_NAME = COOKIE_PREFIX + "errorMessage"
+const OIDC_STATE_COOKIE_NAME = COOKIE_PREFIX + "state"
+const ID_TOKEN_COOKIE_NAME = COOKIE_PREFIX + "idToken"
+const CHALLENGE_TOKEN_COOKIE_NAME = COOKIE_PREFIX + "challengeToken"
// https://echo.labstack.com/guide/templates/
// https://stackoverflow.com/questions/36617949/how-to-use-base-template-file-for-golang-html-template/69244593#69244593
@@ -37,9 +52,11 @@ func NewTemplate(app *App) *Template {
names := []string{
"root",
+ "error",
"user",
"player",
"registration",
+ "complete-registration",
"challenge",
"admin",
}
@@ -67,35 +84,39 @@ func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Con
return t.Templates[name].ExecuteTemplate(w, "base", data)
}
-func setSuccessMessage(c *echo.Context, message string) {
+func (app *App) setMessageCookie(c *echo.Context, cookieName string, template string, args ...interface{}) {
+ message := fmt.Sprintf(template, args...)
(*c).SetCookie(&http.Cookie{
- Name: "successMessage",
+ Name: cookieName,
Value: url.QueryEscape(message),
Path: "/",
- SameSite: http.SameSiteStrictMode,
+ SameSite: http.SameSiteLaxMode,
HttpOnly: true,
+ Secure: true,
})
}
-// Set a warning message
-func setWarningMessage(c *echo.Context, message string) {
- (*c).SetCookie(&http.Cookie{
- Name: "warningMessage",
- Value: url.QueryEscape(message),
- Path: "/",
- SameSite: http.SameSiteStrictMode,
- HttpOnly: true,
- })
+func (app *App) setSuccessMessage(c *echo.Context, template string, args ...interface{}) {
+ app.setMessageCookie(c, SUCCESS_MESSAGE_COOKIE_NAME, template, args...)
}
-// Set an error message cookie
-func setErrorMessage(c *echo.Context, message string) {
+func (app *App) setWarningMessage(c *echo.Context, template string, args ...interface{}) {
+ app.setMessageCookie(c, WARNING_MESSAGE_COOKIE_NAME, template, args...)
+}
+
+func (app *App) setErrorMessage(c *echo.Context, template string, args ...interface{}) {
+ app.setMessageCookie(c, ERROR_MESSAGE_COOKIE_NAME, template, args...)
+}
+
+func (app *App) setBrowserToken(c *echo.Context, browserToken string) {
(*c).SetCookie(&http.Cookie{
- Name: "errorMessage",
- Value: url.QueryEscape(message),
+ Name: BROWSER_TOKEN_COOKIE_NAME,
+ Value: browserToken,
+ MaxAge: BROWSER_TOKEN_AGE_SEC,
Path: "/",
- SameSite: http.SameSiteStrictMode,
+ SameSite: http.SameSiteLaxMode,
HttpOnly: true,
+ Secure: true,
})
}
@@ -115,36 +136,69 @@ func NewWebError(returnURL string, message string, args ...interface{}) error {
}
}
+type errorContext struct {
+ App *App
+ User *User
+ URL string
+ SuccessMessage string
+ WarningMessage string
+ ErrorMessage string
+ Message string
+ StatusCode int
+}
+
// Set error message and redirect
func (app *App) HandleWebError(err error, c *echo.Context) error {
var webError *WebError
var userError *UserError
if errors.As(err, &webError) {
- returnURL := webError.ReturnURL
- setErrorMessage(c, webError.Error())
- return (*c).Redirect(http.StatusSeeOther, returnURL)
+ app.setErrorMessage(c, webError.Error())
+ return (*c).Redirect(http.StatusSeeOther, webError.ReturnURL)
} else if errors.As(err, &userError) {
returnURL := getReturnURL(app, c)
- setErrorMessage(c, userError.Error())
+ app.setErrorMessage(c, userError.Error())
return (*c).Redirect(http.StatusSeeOther, returnURL)
- } else if httpError, ok := err.(*echo.HTTPError); ok {
- switch httpError.Code {
- case http.StatusNotFound, http.StatusRequestEntityTooLarge, http.StatusTooManyRequests:
- if message, ok := httpError.Message.(string); ok {
- returnURL := getReturnURL(app, c)
- setErrorMessage(c, message)
- return (*c).Redirect(http.StatusSeeOther, returnURL)
- }
+ }
+
+ code := http.StatusInternalServerError
+ message := "Internal server error"
+ var httpError *echo.HTTPError
+ if errors.As(err, &httpError) {
+ code = httpError.Code
+ if m, ok := httpError.Message.(string); ok {
+ message = m
}
}
+
app.LogError(err, c)
- returnURL := getReturnURL(app, c)
- setErrorMessage(c, "Internal server error")
- return (*c).Redirect(http.StatusSeeOther, returnURL)
+
+ safeMethods := []string{
+ "GET",
+ "HEAD",
+ "OPTIONS",
+ "TRACE",
+ }
+ if Contains(safeMethods, (*c).Request().Method) {
+ return (*c).Render(code, "error", errorContext{
+ App: app,
+ User: nil,
+ URL: (*c).Request().URL.RequestURI(),
+ Message: message,
+ SuccessMessage: app.lastSuccessMessage(c),
+ WarningMessage: app.lastWarningMessage(c),
+ ErrorMessage: app.lastErrorMessage(c),
+ StatusCode: code,
+ })
+ } else {
+ returnURL := getReturnURL(app, c)
+ app.setErrorMessage(c, message)
+ return (*c).Redirect(http.StatusSeeOther, returnURL)
+ }
}
-func lastSuccessMessage(c *echo.Context) string {
- cookie, err := (*c).Cookie("successMessage")
+// Read and clear the message cookie
+func (app *App) lastMessageCookie(c *echo.Context, cookieName string) string {
+ cookie, err := (*c).Cookie(cookieName)
if err != nil || cookie.Value == "" {
return ""
}
@@ -152,35 +206,20 @@ func lastSuccessMessage(c *echo.Context) string {
if err != nil {
return ""
}
- setSuccessMessage(c, "")
+ app.setMessageCookie(c, cookieName, "")
return decoded
}
-func lastWarningMessage(c *echo.Context) string {
- cookie, err := (*c).Cookie("warningMessage")
- if err != nil || cookie.Value == "" {
- return ""
- }
- decoded, err := url.QueryUnescape(cookie.Value)
- if err != nil {
- return ""
- }
- setWarningMessage(c, "")
- return decoded
+func (app *App) lastSuccessMessage(c *echo.Context) string {
+ return app.lastMessageCookie(c, SUCCESS_MESSAGE_COOKIE_NAME)
}
-// Read and clear the error message cookie
-func lastErrorMessage(c *echo.Context) string {
- cookie, err := (*c).Cookie("errorMessage")
- if err != nil || cookie.Value == "" {
- return ""
- }
- decoded, err := url.QueryUnescape(cookie.Value)
- if err != nil {
- return ""
- }
- setErrorMessage(c, "")
- return decoded
+func (app *App) lastWarningMessage(c *echo.Context) string {
+ return app.lastMessageCookie(c, WARNING_MESSAGE_COOKIE_NAME)
+}
+
+func (app *App) lastErrorMessage(c *echo.Context) string {
+ return app.lastMessageCookie(c, ERROR_MESSAGE_COOKIE_NAME)
}
func getReturnURL(app *App, c *echo.Context) string {
@@ -203,7 +242,7 @@ func withBrowserAuthentication(app *App, requireLogin bool, f func(c echo.Contex
return err
}
- cookie, err := c.Cookie("browserToken")
+ cookie, err := c.Cookie(BROWSER_TOKEN_COOKIE_NAME)
var user User
if err != nil || cookie.Value == "" {
@@ -216,20 +255,17 @@ func withBrowserAuthentication(app *App, requireLogin bool, f func(c echo.Contex
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
if requireLogin {
- c.SetCookie(&http.Cookie{
- Name: "browserToken",
- Value: "",
- MaxAge: -1,
- Path: "/",
- SameSite: http.SameSiteStrictMode,
- HttpOnly: true,
- })
+ app.setBrowserToken(&c, "")
return NewWebError(returnURL, "You are not logged in.")
}
return f(c, nil)
}
return err
}
+ if user.IsLocked {
+ app.setBrowserToken(&c, "")
+ return NewWebError(returnURL, "That account is locked.")
+ }
return f(c, &user)
}
}
@@ -247,48 +283,96 @@ func withBrowserAdmin(app *App, f func(c echo.Context, user *User) error) func(c
})
}
+func EncodeOIDCState(state oidcState) (string, error) {
+ nonce, err := RandomHex(32)
+ if err != nil {
+ return "", err
+ }
+ state.Nonce = nonce
+ stateBytes, err := json.Marshal(state)
+ if err != nil {
+ return "", err
+ }
+ return base64.StdEncoding.EncodeToString(stateBytes), nil
+}
+
// GET /
func FrontRoot(app *App) func(c echo.Context) error {
type rootContext struct {
- App *App
- User *User
- URL string
- Destination string
- SuccessMessage string
- WarningMessage string
- ErrorMessage string
+ App *App
+ User *User
+ URL string
+ Destination string
+ SuccessMessage string
+ WarningMessage string
+ ErrorMessage string
+ WebOIDCProviders []webOIDCProvider
}
return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error {
+ destination := c.QueryParam("destination")
+ webOIDCProviders := make([]webOIDCProvider, 0, len(app.OIDCProvidersByName))
+ if len(app.OIDCProvidersByName) > 0 {
+ stateBase64, err := EncodeOIDCState(oidcState{
+ Action: OIDCActionSignIn,
+ Destination: destination,
+ ReturnURL: c.Request().URL.RequestURI(),
+ })
+ if err != nil {
+ return err
+ }
+
+ c.SetCookie(&http.Cookie{
+ Name: OIDC_STATE_COOKIE_NAME,
+ Value: stateBase64,
+ Path: "/",
+ SameSite: http.SameSiteLaxMode,
+ HttpOnly: true,
+ Secure: true,
+ })
+
+ for name, provider := range app.OIDCProvidersByName {
+ authURL, err := makeOIDCAuthURL(&c, provider, stateBase64)
+ if err != nil {
+ return err
+ }
+ webOIDCProviders = append(webOIDCProviders, webOIDCProvider{
+ Name: name,
+ RequireInvite: provider.Config.RequireInvite,
+ AuthURL: authURL,
+ })
+ }
+ }
+
return c.Render(http.StatusOK, "root", rootContext{
- App: app,
- User: user,
- URL: c.Request().URL.RequestURI(),
- Destination: c.QueryParam("destination"),
- SuccessMessage: lastSuccessMessage(&c),
- WarningMessage: lastWarningMessage(&c),
- ErrorMessage: lastErrorMessage(&c),
+ App: app,
+ User: user,
+ URL: c.Request().URL.RequestURI(),
+ Destination: destination,
+ SuccessMessage: app.lastSuccessMessage(&c),
+ WarningMessage: app.lastWarningMessage(&c),
+ ErrorMessage: app.lastErrorMessage(&c),
+ WebOIDCProviders: webOIDCProviders,
})
})
}
-type webManifestIcon struct {
+type WebManifestIcon struct {
Src string `json:"src"`
Type string `json:"type"`
Sizes string `json:"sizes"`
}
-type webManifest struct {
- Icons []webManifestIcon `json:"icons"`
+type WebManifest struct {
+ Icons []WebManifestIcon `json:"icons"`
}
func FrontWebManifest(app *App) func(c echo.Context) error {
- url, err := url.JoinPath(app.FrontEndURL, "web/icon.png")
- Check(err)
+ iconURL := Unwrap(url.JoinPath(app.PublicURL, "icon.png"))
- manifest := webManifest{
- Icons: []webManifestIcon{{
- Src: url,
+ manifest := WebManifest{
+ Icons: []WebManifestIcon{{
+ Src: iconURL,
Type: "image/png",
Sizes: "512x512",
}},
@@ -299,29 +383,387 @@ func FrontWebManifest(app *App) func(c echo.Context) error {
}
}
+type webOIDCProvider struct {
+ Name string
+ RequireInvite bool
+ AuthURL string
+}
+
+const (
+ OIDCActionSignIn string = "sign-in"
+ OIDCActionLink string = "link"
+)
+
+type oidcState struct {
+ Nonce string `json:"nonce"`
+ Action string `json:"action"`
+ Destination string `json:"destination,omitempty"`
+ InviteCode string `json:"inviteCode,omitempty"`
+ ReturnURL string `json:"returnUrl"`
+}
+
// GET /registration
func FrontRegistration(app *App) func(c echo.Context) error {
- type context struct {
- App *App
- User *User
- URL string
- SuccessMessage string
- WarningMessage string
- ErrorMessage string
- InviteCode string
+ type registrationContext struct {
+ App *App
+ User *User
+ URL string
+ SuccessMessage string
+ WarningMessage string
+ ErrorMessage string
+ InviteCode string
+ WebOIDCProviders []webOIDCProvider
}
return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error {
inviteCode := c.QueryParam("invite")
- return c.Render(http.StatusOK, "registration", context{
- App: app,
- User: user,
- URL: c.Request().URL.RequestURI(),
- SuccessMessage: lastSuccessMessage(&c),
- WarningMessage: lastWarningMessage(&c),
- ErrorMessage: lastErrorMessage(&c),
- InviteCode: inviteCode,
+ webOIDCProviders := make([]webOIDCProvider, 0, len(app.OIDCProvidersByName))
+
+ stateBase64, err := EncodeOIDCState(oidcState{
+ Action: OIDCActionSignIn,
+ InviteCode: inviteCode,
+ ReturnURL: c.Request().URL.RequestURI(),
})
+ if err != nil {
+ return err
+ }
+
+ c.SetCookie(&http.Cookie{
+ Name: OIDC_STATE_COOKIE_NAME,
+ Value: stateBase64,
+ Path: "/",
+ SameSite: http.SameSiteLaxMode,
+ HttpOnly: true,
+ Secure: true,
+ })
+
+ for name, provider := range app.OIDCProvidersByName {
+ authURL, err := makeOIDCAuthURL(&c, provider, stateBase64)
+ if err != nil {
+ return err
+ }
+ webOIDCProviders = append(webOIDCProviders, webOIDCProvider{
+ Name: name,
+ RequireInvite: provider.Config.RequireInvite,
+ AuthURL: authURL,
+ })
+ }
+
+ return c.Render(http.StatusOK, "registration", registrationContext{
+ App: app,
+ User: user,
+ URL: c.Request().URL.RequestURI(),
+ SuccessMessage: app.lastSuccessMessage(&c),
+ WarningMessage: app.lastWarningMessage(&c),
+ ErrorMessage: app.lastErrorMessage(&c),
+ InviteCode: inviteCode,
+ WebOIDCProviders: webOIDCProviders,
+ })
+ })
+}
+
+func (app *App) getPreferredPlayerName(userInfo *oidc.UserInfo) mo.Option[string] {
+ preferredPlayerName := userInfo.PreferredUsername
+ if preferredPlayerName == "" {
+ return mo.None[string]()
+ }
+ if index := strings.IndexByte(userInfo.PreferredUsername, '@'); index >= 0 {
+ preferredPlayerName = userInfo.PreferredUsername[:index]
+ }
+ if app.ValidatePlayerName(preferredPlayerName) != nil {
+ return mo.None[string]()
+ }
+ return mo.Some(preferredPlayerName)
+}
+
+func (app *App) getIDTokenCookie(c *echo.Context) (*OIDCProvider, string, oidc.IDTokenClaims, error) {
+ cookie, err := (*c).Cookie(ID_TOKEN_COOKIE_NAME)
+ if err != nil || cookie.Value == "" {
+ return nil, "", oidc.IDTokenClaims{}, &UserError{Err: errors.New("Missing ID token cookie")}
+ }
+
+ idTokenBytes, err := app.DecryptCookieValue(cookie.Value)
+ if err != nil {
+ return nil, "", oidc.IDTokenClaims{}, &UserError{Err: errors.New("Invalid ID token")}
+ }
+ idToken := string(idTokenBytes)
+
+ oidcProvider, claims, err := app.ValidateIDToken(idToken)
+ if err != nil {
+ return nil, "", oidc.IDTokenClaims{}, err
+ }
+
+ return oidcProvider, idToken, claims, nil
+}
+
+func FrontCompleteRegistration(app *App) func(c echo.Context) error {
+ type completeRegistrationContext struct {
+ App *App
+ User *User
+ URL string
+ SuccessMessage string
+ WarningMessage string
+ ErrorMessage string
+ InviteCode string
+ AnyUnmigratedUsers bool
+ AllowChoosingPlayerName bool
+ PreferredPlayerName string
+ }
+
+ returnURL := Unwrap(url.JoinPath(app.FrontEndURL, "web/registration"))
+
+ return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error {
+ inviteCode := c.QueryParam("invite")
+
+ provider, _, claims, err := app.getIDTokenCookie(&c)
+ if err != nil {
+ var userError *UserError
+ if errors.As(err, &userError) {
+ return &WebError{ReturnURL: returnURL, Err: userError.Err}
+ }
+ return err
+ }
+
+ preferredPlayerName := app.getPreferredPlayerName(claims.GetUserInfo()).OrElse("")
+ if preferredPlayerName == "" && !provider.Config.AllowChoosingPlayerName {
+ return NewWebError(returnURL, "That %s account does not have a preferred username.", provider.Config.Name)
+ }
+
+ var anyUnmigratedUsers bool
+ err = app.DB.Raw(`
+ SELECT EXISTS (
+ SELECT 1 from users u
+ WHERE NOT EXISTS (
+ SELECT 1 FROM user_oidc_identities uoi WHERE uoi.user_uuid = u.uuid
+ )
+ )
+ `).Scan(&anyUnmigratedUsers).Error
+ if err != nil {
+ return err
+ }
+
+ return c.Render(http.StatusOK, "complete-registration", completeRegistrationContext{
+ App: app,
+ User: user,
+ URL: c.Request().URL.RequestURI(),
+ SuccessMessage: app.lastSuccessMessage(&c),
+ WarningMessage: app.lastWarningMessage(&c),
+ ErrorMessage: app.lastErrorMessage(&c),
+ InviteCode: inviteCode,
+ PreferredPlayerName: preferredPlayerName,
+ AllowChoosingPlayerName: provider.Config.AllowChoosingPlayerName,
+ AnyUnmigratedUsers: anyUnmigratedUsers,
+ })
+ })
+}
+
+func (app *App) FrontOIDCUnlink() func(c echo.Context) error {
+ return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
+ returnURL := getReturnURL(app, &c)
+
+ targetUUID := c.FormValue("userUuid")
+ providerName := c.FormValue("providerName")
+
+ if err := app.DeleteOIDCIdentity(user, targetUUID, providerName); err != nil {
+ return err
+ }
+
+ app.setSuccessMessage(&c, "%s account unlinked.", providerName)
+ return c.Redirect(http.StatusSeeOther, returnURL)
+ })
+}
+
+func pkceCookieName(provider *OIDCProvider) string {
+ return "__Host-pkce-" + base62.EncodeToString([]byte(provider.Config.Issuer))
+}
+
+func makeOIDCAuthURL(c *echo.Context, provider *OIDCProvider, stateBase64 string) (string, error) {
+ w := (*c).Response().Unwrap()
+
+ var opts []rp.AuthURLOpt
+ if provider.RelyingParty.IsPKCE() {
+ codeVerifier := base64.RawURLEncoding.EncodeToString([]byte(uuid.New().String()))
+ if err := provider.RelyingParty.CookieHandler().SetCookie(w, pkceCookieName(provider), codeVerifier); err != nil {
+ return "", err
+ }
+ codeChallenge := oidc.NewSHACodeChallenge(codeVerifier)
+ opts = append(opts, rp.WithCodeChallenge(codeChallenge))
+ }
+
+ return rp.AuthURL(stateBase64, provider.RelyingParty, opts...), nil
+}
+
+func (app *App) oidcLink(c echo.Context, oidcProvider *OIDCProvider, tokens *oidc.Tokens[*oidc.IDTokenClaims], state oidcState, user *User) error {
+ returnURL := state.ReturnURL
+
+ if user == nil {
+ return NewWebError(app.FrontEndURL, "You are not logged in.")
+ }
+
+ _, claims, err := app.ValidateIDToken(tokens.IDToken)
+ if err != nil {
+ var userError *UserError
+ if errors.As(err, &userError) {
+ return &WebError{ReturnURL: returnURL, Err: userError.Err}
+ }
+ return err
+ }
+
+ _, err = app.CreateOIDCIdentity(user, user.UUID, claims.Issuer, claims.Subject)
+ if err != nil {
+ var userError *UserError
+ if errors.As(err, &userError) {
+ return &WebError{ReturnURL: returnURL, Err: userError.Err}
+ }
+ return err
+ }
+
+ app.setSuccessMessage(&c, "Successfully linked your %s account.", oidcProvider.Config.Name)
+
+ return c.Redirect(http.StatusSeeOther, returnURL)
+}
+
+func (app *App) oidcSignIn(c echo.Context, oidcProvider *OIDCProvider, tokens *oidc.Tokens[*oidc.IDTokenClaims], state oidcState) error {
+ failureURL := state.ReturnURL
+ completeRegistrationURL, err := url.JoinPath(app.FrontEndURL, "web/complete-registration")
+ if err != nil {
+ return err
+ }
+
+ if state.InviteCode != "" {
+ var err error
+ completeRegistrationURL, err = SetQueryParam(completeRegistrationURL, "invite", state.InviteCode)
+ if err != nil {
+ return err
+ }
+ failureURL, err = SetQueryParam(failureURL, "invite", state.InviteCode)
+ if err != nil {
+ return err
+ }
+ }
+
+ var claims oidc.IDTokenClaims
+ _, err = oidc.ParseToken(tokens.IDToken, &claims)
+ if err != nil {
+ return err
+ }
+
+ var oidcIdentity UserOIDCIdentity
+ result := app.DB.Preload("User").First(&oidcIdentity, "subject = ? AND issuer = ?", claims.Subject, claims.Issuer)
+ if result.Error == nil {
+ // User already exists, log in
+ user := oidcIdentity.User
+
+ if user.IsLocked {
+ return NewWebError(failureURL, "Account is locked.")
+ }
+
+ browserToken, err := RandomHex(32)
+ if err != nil {
+ return err
+ }
+ user.BrowserToken = MakeNullString(&browserToken)
+ if err := app.DB.Save(&user).Error; err != nil {
+ return err
+ }
+ app.setBrowserToken(&c, browserToken)
+
+ returnURL, err := url.JoinPath(app.FrontEndURL, "web/user")
+ if err != nil {
+ return err
+ }
+
+ if state.Destination != "" {
+ returnURL = state.Destination
+ }
+ return c.Redirect(http.StatusSeeOther, returnURL)
+ } else {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return result.Error
+ }
+ }
+
+ encryptedIDToken, err := app.EncryptCookieValue(tokens.IDToken)
+ if err != nil {
+ return err
+ }
+
+ // User doesn't already exist, set ID token cookie and complete registration
+ c.SetCookie(&http.Cookie{
+ Name: ID_TOKEN_COOKIE_NAME,
+ Value: encryptedIDToken,
+ Path: "/",
+ SameSite: http.SameSiteLaxMode,
+ HttpOnly: true,
+ Secure: true,
+ })
+
+ return c.Redirect(http.StatusSeeOther, completeRegistrationURL)
+}
+
+// GET /oidc-callback/:providerName
+func FrontOIDCCallback(app *App) func(c echo.Context) error {
+ failureURL := app.FrontEndURL
+
+ return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error {
+ providerName := c.Param("providerName")
+ oidcProvider, ok := app.OIDCProvidersByName[providerName]
+ if !ok {
+ return NewWebError(failureURL, "Unknown OIDC provider: %s", providerName)
+ }
+
+ stateCookie, err := c.Cookie(OIDC_STATE_COOKIE_NAME)
+ if err != nil {
+ return NewWebError(failureURL, "Missing state cookie")
+ }
+ c.SetCookie(&http.Cookie{
+ Name: OIDC_STATE_COOKIE_NAME,
+ Value: "",
+ Path: "/",
+ SameSite: http.SameSiteLaxMode,
+ HttpOnly: true,
+ Secure: true,
+ })
+
+ stateParam := c.QueryParam("state")
+ if stateCookie.Value != stateParam {
+ fmt.Println("stateCookie.Value", stateCookie.Value, "stateParam", stateParam)
+ return NewWebError(failureURL, "\"state\" param doesn't match \"%s\" cookie.", OIDC_STATE_COOKIE_NAME)
+ }
+
+ stateBytes, err := base64.StdEncoding.DecodeString(stateParam)
+ if err != nil {
+ return NewWebError(failureURL, "Invalid OIDC state cookie")
+ }
+
+ var state oidcState
+ err = json.Unmarshal(stateBytes, &state)
+ if err != nil {
+ return NewWebError(failureURL, "Invalid OIDC state cookie")
+ }
+
+ failureURL := state.ReturnURL
+ var opts []rp.CodeExchangeOpt
+ if oidcProvider.RelyingParty.IsPKCE() {
+ codeVerifier, err := oidcProvider.RelyingParty.CookieHandler().CheckCookie(c.Request(), pkceCookieName(oidcProvider))
+ if err != nil {
+ return err
+ }
+ opts = append(opts, rp.WithCodeVerifier(codeVerifier))
+ }
+ tokens, err := rp.CodeExchange[*oidc.IDTokenClaims](context.Background(), c.FormValue("code"), oidcProvider.RelyingParty, opts...)
+ if err != nil {
+ return NewWebError(failureURL, "OIDC code exchange failed.")
+ }
+
+ switch state.Action {
+ case OIDCActionSignIn:
+ return app.oidcSignIn(c, oidcProvider, tokens, state)
+ case OIDCActionLink:
+ return app.oidcLink(c, oidcProvider, tokens, state, user)
+ default:
+ return NewWebError(failureURL, "Unknown OIDC action: %s", state.Action)
+ }
})
}
@@ -355,9 +797,9 @@ func FrontAdmin(app *App) func(c echo.Context) error {
App: app,
User: user,
URL: c.Request().URL.RequestURI(),
- SuccessMessage: lastSuccessMessage(&c),
- WarningMessage: lastWarningMessage(&c),
- ErrorMessage: lastErrorMessage(&c),
+ SuccessMessage: app.lastSuccessMessage(&c),
+ WarningMessage: app.lastWarningMessage(&c),
+ ErrorMessage: app.lastErrorMessage(&c),
Users: users,
Invites: invites,
})
@@ -428,6 +870,7 @@ func FrontUpdateUsers(app *App) func(c echo.Context) error {
&shouldBeAdmin, // isAdmin
&shouldBeLocked, // isLocked
false,
+ false,
nil,
&maxPlayerCount,
)
@@ -450,7 +893,7 @@ func FrontUpdateUsers(app *App) func(c echo.Context) error {
return err
}
- setSuccessMessage(&c, "Changes saved.")
+ app.setSuccessMessage(&c, "Changes saved.")
return c.Redirect(http.StatusSeeOther, returnURL)
})
}
@@ -473,22 +916,24 @@ func FrontNewInvite(app *App) func(c echo.Context) error {
})
}
-// GET /drasl/user
-// GET /drasl/user/:uuid
+// GET /web/user
+// GET /web/user/:uuid
func FrontUser(app *App) func(c echo.Context) error {
type userContext struct {
- App *App
- User *User
- URL string
- SuccessMessage string
- WarningMessage string
- ErrorMessage string
- TargetUser *User
- TargetUserID string
- SkinURL *string
- CapeURL *string
- AdminView bool
- MaxPlayerCount int
+ App *App
+ User *User
+ URL string
+ SuccessMessage string
+ WarningMessage string
+ ErrorMessage string
+ TargetUser *User
+ TargetUserID string
+ SkinURL *string
+ CapeURL *string
+ AdminView bool
+ MaxPlayerCount int
+ LinkedOIDCProviderNames []string
+ UnlinkedOIDCProviders []webOIDCProvider
}
return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
@@ -521,23 +966,64 @@ func FrontUser(app *App) func(c echo.Context) error {
maxPlayerCount := app.GetMaxPlayerCount(targetUser)
+ linkedOIDCProviderNames := mapset.NewSet[string]()
+ unlinkedOIDCProviders := make([]webOIDCProvider, 0, len(app.OIDCProvidersByName))
+
+ if len(app.OIDCProvidersByName) > 0 {
+ stateBase64, err := EncodeOIDCState(oidcState{
+ Action: OIDCActionLink,
+ ReturnURL: c.Request().URL.RequestURI(),
+ })
+ if err != nil {
+ return err
+ }
+
+ c.SetCookie(&http.Cookie{
+ Name: OIDC_STATE_COOKIE_NAME,
+ Value: stateBase64,
+ Path: "/",
+ SameSite: http.SameSiteLaxMode,
+ HttpOnly: true,
+ Secure: true,
+ })
+
+ for _, oidcIdentity := range targetUser.OIDCIdentities {
+ if oidcProvider, ok := app.OIDCProvidersByIssuer[oidcIdentity.Issuer]; ok {
+ linkedOIDCProviderNames.Add(oidcProvider.Config.Name)
+ }
+ }
+
+ for name, provider := range app.OIDCProvidersByName {
+ if !linkedOIDCProviderNames.Contains(name) {
+ authURL, err := makeOIDCAuthURL(&c, provider, stateBase64)
+ if err != nil {
+ return err
+ }
+ unlinkedOIDCProviders = append(unlinkedOIDCProviders, webOIDCProvider{
+ Name: name,
+ AuthURL: authURL,
+ })
+ }
+ }
+ }
+
return c.Render(http.StatusOK, "user", userContext{
- App: app,
- User: user,
- URL: c.Request().URL.RequestURI(),
- SuccessMessage: lastSuccessMessage(&c),
- WarningMessage: lastWarningMessage(&c),
- ErrorMessage: lastErrorMessage(&c),
- TargetUser: targetUser,
- // SkinURL: skinURL,
- // CapeURL: capeURL,
- AdminView: adminView,
- MaxPlayerCount: maxPlayerCount,
+ App: app,
+ User: user,
+ URL: c.Request().URL.RequestURI(),
+ SuccessMessage: app.lastSuccessMessage(&c),
+ WarningMessage: app.lastWarningMessage(&c),
+ ErrorMessage: app.lastErrorMessage(&c),
+ TargetUser: targetUser,
+ AdminView: adminView,
+ LinkedOIDCProviderNames: linkedOIDCProviderNames.ToSlice(),
+ UnlinkedOIDCProviders: unlinkedOIDCProviders,
+ MaxPlayerCount: maxPlayerCount,
})
})
}
-// GET /drasl/player/:uuid
+// GET /web/player/:uuid
func FrontPlayer(app *App) func(c echo.Context) error {
type playerContext struct {
App *App
@@ -546,6 +1032,7 @@ func FrontPlayer(app *App) func(c echo.Context) error {
SuccessMessage string
WarningMessage string
ErrorMessage string
+ PlayerUser *User
Player *Player
PlayerID string
SkinURL *string
@@ -591,9 +1078,10 @@ func FrontPlayer(app *App) func(c echo.Context) error {
App: app,
User: user,
URL: c.Request().URL.RequestURI(),
- SuccessMessage: lastSuccessMessage(&c),
- WarningMessage: lastWarningMessage(&c),
- ErrorMessage: lastErrorMessage(&c),
+ SuccessMessage: app.lastSuccessMessage(&c),
+ WarningMessage: app.lastWarningMessage(&c),
+ ErrorMessage: app.lastErrorMessage(&c),
+ PlayerUser: &playerUser,
Player: &player,
PlayerID: id,
SkinURL: skinURL,
@@ -627,6 +1115,7 @@ func FrontUpdateUser(app *App) func(c echo.Context) error {
targetUUID := nilIfEmpty(c.FormValue("uuid"))
password := nilIfEmpty(c.FormValue("password"))
resetAPIToken := c.FormValue("resetApiToken") == "on"
+ resetMinecraftToken := c.FormValue("resetMinecraftToken") == "on"
preferredLanguage := nilIfEmpty(c.FormValue("preferredLanguage"))
maybeMaxPlayerCountString := getFormValue(&c, "maxPlayerCount")
@@ -667,6 +1156,7 @@ func FrontUpdateUser(app *App) func(c echo.Context) error {
nil, // isAdmin
nil, // isLocked
resetAPIToken,
+ resetMinecraftToken,
preferredLanguage,
maybeMaxPlayerCount.ToPointer(),
)
@@ -678,12 +1168,12 @@ func FrontUpdateUser(app *App) func(c echo.Context) error {
return err
}
- setSuccessMessage(&c, "Changes saved.")
+ app.setSuccessMessage(&c, "Changes saved.")
return c.Redirect(http.StatusSeeOther, returnURL)
})
}
-// POST /update-player
+// POST /web/update-player
func FrontUpdatePlayer(app *App) func(c echo.Context) error {
return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
returnURL := getReturnURL(app, &c)
@@ -752,25 +1242,20 @@ func FrontUpdatePlayer(app *App) func(c echo.Context) error {
return err
}
- setSuccessMessage(&c, "Changes saved.")
+ app.setSuccessMessage(&c, "Changes saved.")
return c.Redirect(http.StatusSeeOther, returnURL)
})
}
-// POST /logout
+// POST /web/logout
func FrontLogout(app *App) func(c echo.Context) error {
return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
returnURL := app.FrontEndURL
- c.SetCookie(&http.Cookie{
- Name: "browserToken",
- Value: "",
- MaxAge: -1,
- Path: "/",
- SameSite: http.SameSiteStrictMode,
- HttpOnly: true,
- })
user.BrowserToken = MakeNullString(nil)
- app.DB.Save(user)
+ if err := app.DB.Save(user).Error; err != nil {
+ return err
+ }
+ app.setBrowserToken(&c, "")
return c.Redirect(http.StatusSeeOther, returnURL)
})
}
@@ -780,12 +1265,12 @@ const (
ChallengeActionCreatePlayer string = "create-player"
)
-// GET /create-player-challenge
+// GET /web/create-player-challenge
func FrontCreatePlayerChallenge(app *App) func(c echo.Context) error {
return frontChallenge(app, ChallengeActionCreatePlayer)
}
-// GET /register-challenge
+// GET /web/register-challenge
func FrontRegisterChallenge(app *App) func(c echo.Context) error {
return frontChallenge(app, ChallengeActionRegister)
}
@@ -804,6 +1289,7 @@ func frontChallenge(app *App, action string) func(c echo.Context) error {
SkinFilename string
ChallengeToken string
InviteCode string
+ UseIDToken bool
Action string
UserUUID *string
}
@@ -811,18 +1297,40 @@ func frontChallenge(app *App, action string) func(c echo.Context) error {
return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error {
returnURL := getReturnURL(app, &c)
+ useIDToken := c.QueryParam("useIdToken") == "on"
+
var playerName string
var userUUID *string
if action == ChallengeActionRegister {
- username := c.QueryParam("username")
- if err := app.ValidateUsername(username); err != nil {
- return NewWebError(returnURL, "Invalid username: %s", err)
+ if useIDToken {
+ provider, _, claims, err := app.getIDTokenCookie(&c)
+ if err != nil {
+ var userError *UserError
+ if errors.As(err, &userError) {
+ return &WebError{ReturnURL: returnURL, Err: userError.Err}
+ }
+ return err
+ }
+
+ if provider.Config.AllowChoosingPlayerName {
+ playerName = c.FormValue("playerName")
+ } else {
+ if preferredPlayerName, ok := app.getPreferredPlayerName(claims.GetUserInfo()).Get(); ok {
+ playerName = preferredPlayerName
+ } else {
+ return NewWebError(returnURL, "That %s account does not have a preferred username.", provider.Config.Name)
+ }
+ }
+ } else {
+ username := c.QueryParam("username")
+ if err := app.ValidateUsername(username); err != nil {
+ return NewWebError(returnURL, "Invalid username: %s", err)
+ }
+ playerName = username
}
- playerName = username
} else if action == ChallengeActionCreatePlayer {
playerName = c.QueryParam("playerName")
- userUUIDString := c.QueryParam("userUuid")
- userUUID = &userUUIDString
+ userUUID = Ptr(c.QueryParam("userUuid"))
}
if err := app.ValidatePlayerName(playerName); err != nil {
@@ -832,19 +1340,20 @@ func frontChallenge(app *App, action string) func(c echo.Context) error {
inviteCode := c.QueryParam("inviteCode")
var challengeToken string
- cookie, err := c.Cookie("challengeToken")
+ cookie, err := c.Cookie(CHALLENGE_TOKEN_COOKIE_NAME)
if err != nil || cookie.Value == "" {
challengeToken, err = MakeChallengeToken()
if err != nil {
return err
}
c.SetCookie(&http.Cookie{
- Name: "challengeToken",
+ Name: CHALLENGE_TOKEN_COOKIE_NAME,
Value: challengeToken,
MaxAge: BROWSER_TOKEN_AGE_SEC,
Path: "/",
- SameSite: http.SameSiteStrictMode,
+ SameSite: http.SameSiteLaxMode,
HttpOnly: true,
+ Secure: true,
})
} else {
challengeToken = cookie.Value
@@ -864,21 +1373,22 @@ func frontChallenge(app *App, action string) func(c echo.Context) error {
App: app,
User: user,
URL: c.Request().URL.RequestURI(),
- SuccessMessage: lastSuccessMessage(&c),
- WarningMessage: lastWarningMessage(&c),
- ErrorMessage: lastErrorMessage(&c),
+ SuccessMessage: app.lastSuccessMessage(&c),
+ WarningMessage: app.lastWarningMessage(&c),
+ ErrorMessage: app.lastErrorMessage(&c),
PlayerName: playerName,
SkinBase64: skinBase64,
SkinFilename: playerName + "-challenge.png",
ChallengeToken: challengeToken,
InviteCode: inviteCode,
+ UseIDToken: useIDToken,
Action: action,
UserUUID: userUUID,
})
})
}
-// POST /create-player
+// POST /web/create-player
func FrontCreatePlayer(app *App) func(c echo.Context) error {
return withBrowserAuthentication(app, true, func(c echo.Context, caller *User) error {
userUUID := c.FormValue("userUuid")
@@ -920,38 +1430,73 @@ func FrontCreatePlayer(app *App) func(c echo.Context) error {
})
}
-// POST /register
+// POST /web/register
func FrontRegister(app *App) func(c echo.Context) error {
returnURL := Unwrap(url.JoinPath(app.FrontEndURL, "web/user"))
return func(c echo.Context) error {
- username := c.FormValue("username")
+ useIDToken := c.FormValue("useIdToken") == "on"
honeypot := c.FormValue("email")
- password := c.FormValue("password")
chosenUUID := nilIfEmpty(c.FormValue("uuid"))
existingPlayer := c.FormValue("existingPlayer") == "on"
challengeToken := nilIfEmpty(c.FormValue("challengeToken"))
inviteCode := nilIfEmpty(c.FormValue("inviteCode"))
failureURL := getReturnURL(app, &c)
- noInviteFailureURL, err := StripQueryParam(failureURL, "invite")
+ noInviteFailureURL, err := UnsetQueryParam(failureURL, "invite")
if err != nil {
return err
}
if honeypot != "" {
- setErrorMessage(&c, "You are now covered in bee stings.")
- return c.Redirect(http.StatusSeeOther, failureURL)
+ return NewWebError(failureURL, "You are now covered in bee stings.")
+ }
+
+ var username string
+ var playerName string
+ var password mo.Option[string]
+ oidcIdentitySpecs := []OIDCIdentitySpec{}
+ if useIDToken {
+ provider, _, claims, err := app.getIDTokenCookie(&c)
+ if err != nil {
+ var userError *UserError
+ if errors.As(err, &userError) {
+ return &WebError{ReturnURL: failureURL, Err: userError.Err}
+ }
+ return err
+ }
+ username = claims.Email
+
+ if provider.Config.AllowChoosingPlayerName {
+ playerName = c.FormValue("playerName")
+ } else {
+ if preferredPlayerName, ok := app.getPreferredPlayerName(claims.GetUserInfo()).Get(); ok {
+ playerName = preferredPlayerName
+ } else {
+ return NewWebError(failureURL, "That %s account does not have a preferred username.", provider.Config.Name)
+ }
+ }
+
+ claims.GetUserInfo()
+ oidcIdentitySpecs = []OIDCIdentitySpec{{
+ Issuer: claims.Issuer,
+ Subject: claims.Subject,
+ }}
+ } else {
+ playerName = c.FormValue("playerName")
+ username = playerName
+ password = mo.Some(c.FormValue("password"))
}
user, err := app.CreateUser(
nil, // caller
username,
- password,
+ password.ToPointer(),
+ PotentiallyInsecure[[]OIDCIdentitySpec]{Value: oidcIdentitySpecs},
false, // isAdmin
false, // isLocked
inviteCode,
nil, // preferredLanguage
- nil, // playerName
+ &playerName,
chosenUUID,
existingPlayer,
challengeToken,
@@ -980,19 +1525,23 @@ func FrontRegister(app *App) func(c echo.Context) error {
return err
}
user.BrowserToken = MakeNullString(&browserToken)
- result := app.DB.Save(&user)
- if result.Error != nil {
- return result.Error
+ if err := app.DB.Save(&user).Error; err != nil {
+ return err
+ }
+ app.setBrowserToken(&c, browserToken)
+
+ if useIDToken {
+ c.SetCookie(&http.Cookie{
+ Name: ID_TOKEN_COOKIE_NAME,
+ Value: "",
+ Path: "/",
+ SameSite: http.SameSiteLaxMode,
+ HttpOnly: true,
+ Secure: true,
+ })
}
- c.SetCookie(&http.Cookie{
- Name: "browserToken",
- Value: browserToken,
- MaxAge: BROWSER_TOKEN_AGE_SEC,
- Path: "/",
- SameSite: http.SameSiteStrictMode,
- HttpOnly: true,
- })
+ app.setSuccessMessage(&c, "Account created.")
return c.Redirect(http.StatusSeeOther, returnURL)
}
@@ -1015,15 +1564,35 @@ func addDestination(url_ string, destination string) (string, error) {
}
}
-// POST /login
-func FrontLogin(app *App) func(c echo.Context) error {
+// POST /web/oidc-migrate
+func (app *App) FrontOIDCMigrate() func(c echo.Context) error {
return func(c echo.Context) error {
failureURL := getReturnURL(app, &c)
username := c.FormValue("username")
password := c.FormValue("password")
- user, err := app.Login(username, password)
+ oidcProvider, _, claims, err := app.getIDTokenCookie(&c)
+ if err != nil {
+ var userError *UserError
+ if errors.As(err, &userError) {
+ return &WebError{ReturnURL: failureURL, Err: userError.Err}
+ }
+ return err
+ }
+
+ user, err := app.AuthenticateUserForMigration(username, password)
+ if err != nil {
+ var userError *UserError
+ if err == PasswordLoginNotAllowedError {
+ return NewWebError(failureURL, "That account is already migrated. Log in via OpenID Connect.")
+ }
+ if errors.As(err, &userError) {
+ return &WebError{ReturnURL: failureURL, Err: userError.Err}
+ }
+ }
+
+ _, err = app.CreateOIDCIdentity(&user, user.UUID, claims.Issuer, claims.Subject)
if err != nil {
var userError *UserError
if errors.As(err, &userError) {
@@ -1036,18 +1605,51 @@ func FrontLogin(app *App) func(c echo.Context) error {
if err != nil {
return err
}
-
- c.SetCookie(&http.Cookie{
- Name: "browserToken",
- Value: browserToken,
- MaxAge: BROWSER_TOKEN_AGE_SEC,
- Path: "/",
- SameSite: http.SameSiteStrictMode,
- HttpOnly: true,
- })
-
user.BrowserToken = MakeNullString(&browserToken)
- app.DB.Save(&user)
+ if err := app.DB.Save(&user).Error; err != nil {
+ return err
+ }
+ app.setBrowserToken(&c, browserToken)
+
+ returnURL, err := url.JoinPath(app.FrontEndURL, "web/user")
+ if err != nil {
+ return err
+ }
+
+ app.setSuccessMessage(&c, "Successfully migrated account. From now on, log in with %s.", oidcProvider.Config.Name)
+ return c.Redirect(http.StatusSeeOther, returnURL)
+ }
+}
+
+// POST /web/login
+func FrontLogin(app *App) func(c echo.Context) error {
+ return func(c echo.Context) error {
+ failureURL := getReturnURL(app, &c)
+
+ username := c.FormValue("username")
+ password := c.FormValue("password")
+
+ user, err := app.AuthenticateUser(username, password)
+ if err != nil {
+ var userError *UserError
+ if err == PasswordLoginNotAllowedError {
+ return NewWebError(failureURL, "%s Log in via OpenID Connect instead.", err.Error())
+ }
+ if errors.As(err, &userError) {
+ return &WebError{ReturnURL: failureURL, Err: userError.Err}
+ }
+ return err
+ }
+
+ browserToken, err := RandomHex(32)
+ if err != nil {
+ return err
+ }
+ user.BrowserToken = MakeNullString(&browserToken)
+ if err := app.DB.Save(&user).Error; err != nil {
+ return err
+ }
+ app.setBrowserToken(&c, browserToken)
returnURL, err := url.JoinPath(app.FrontEndURL, "web/user")
if err != nil {
@@ -1061,7 +1663,7 @@ func FrontLogin(app *App) func(c echo.Context) error {
}
}
-// POST /delete-user
+// POST /web/delete-user
func FrontDeleteUser(app *App) func(c echo.Context) error {
return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
returnURL := getReturnURL(app, &c)
@@ -1090,22 +1692,15 @@ func FrontDeleteUser(app *App) func(c echo.Context) error {
}
if targetUser == user {
- c.SetCookie(&http.Cookie{
- Name: "browserToken",
- Value: "",
- MaxAge: -1,
- Path: "/",
- SameSite: http.SameSiteStrictMode,
- HttpOnly: true,
- })
+ app.setBrowserToken(&c, "")
}
- setSuccessMessage(&c, "Account deleted")
+ app.setSuccessMessage(&c, "Account deleted")
return c.Redirect(http.StatusSeeOther, returnURL)
})
}
-// POST /delete-player
+// POST /web/delete-player
func FrontDeletePlayer(app *App) func(c echo.Context) error {
return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error {
returnURL := getReturnURL(app, &c)
@@ -1127,9 +1722,10 @@ func FrontDeletePlayer(app *App) func(c echo.Context) error {
if errors.As(err, &userError) {
return &WebError{ReturnURL: returnURL, Err: userError.Err}
}
+ return err
}
- setSuccessMessage(&c, fmt.Sprintf("Player \"%s\" deleted", player.Name))
+ app.setSuccessMessage(&c, "Player \"%s\" deleted", player.Name)
return c.Redirect(http.StatusSeeOther, returnURL)
})
diff --git a/front_test.go b/front_test.go
index 4d2ad79..3f7d62f 100644
--- a/front_test.go
+++ b/front_test.go
@@ -33,12 +33,15 @@ func setupRegistrationExistingPlayerTS(t *testing.T, requireSkinVerification boo
config := testConfig()
config.RegistrationNewPlayer.Allow = false
config.RegistrationExistingPlayer = registrationExistingPlayerConfig{
+ Allow: true,
+ RequireInvite: requireInvite,
+ }
+ config.ImportExistingPlayer = importExistingPlayerConfig{
Allow: true,
Nickname: "Aux",
SessionURL: ts.AuxApp.SessionURL,
AccountURL: ts.AuxApp.AccountURL,
RequireSkinVerification: requireSkinVerification,
- RequireInvite: requireInvite,
}
config.FallbackAPIServers = []FallbackAPIServer{
{
@@ -72,27 +75,32 @@ func (ts *TestSuite) testWebManifest(t *testing.T) {
func (ts *TestSuite) testPublic(t *testing.T) {
ts.testStatusOK(t, "/")
ts.testStatusOK(t, "/web/registration")
- ts.testStatusOK(t, "/web/public/bundle.js")
- ts.testStatusOK(t, "/web/public/style.css")
- ts.testStatusOK(t, "/web/public/logo.svg")
- ts.testStatusOK(t, "/web/public/icon.png")
+ ts.testStatusOK(t, "/web/manifest.webmanifest")
+ ts.testStatusOK(t, ts.App.PublicURL+"/bundle.js")
+ ts.testStatusOK(t, ts.App.PublicURL+"/style.css")
+ ts.testStatusOK(t, ts.App.PublicURL+"/logo.svg")
+ ts.testStatusOK(t, ts.App.PublicURL+"/icon.png")
+ {
+ rec := ts.Get(t, ts.Server, "/web/thisdoesnotexist", nil, nil)
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+ }
}
func getErrorMessage(rec *httptest.ResponseRecorder) string {
- return Unwrap(url.QueryUnescape(getCookie(rec, "errorMessage").Value))
+ return Unwrap(url.QueryUnescape(getCookie(rec, ERROR_MESSAGE_COOKIE_NAME).Value))
}
func (ts *TestSuite) registrationShouldFail(t *testing.T, rec *httptest.ResponseRecorder, errorMessage string, returnURL string) {
assert.Equal(t, http.StatusSeeOther, rec.Code)
assert.Equal(t, errorMessage, getErrorMessage(rec))
- assert.Equal(t, "", getCookie(rec, "browserToken").Value)
+ assert.Equal(t, "", getCookie(rec, BROWSER_TOKEN_COOKIE_NAME).Value)
assert.Equal(t, returnURL, rec.Header().Get("Location"))
}
func (ts *TestSuite) registrationShouldSucceed(t *testing.T, rec *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusSeeOther, rec.Code)
assert.Equal(t, "", getErrorMessage(rec))
- assert.NotEqual(t, "", getCookie(rec, "browserToken").Value)
+ assert.NotEqual(t, "", getCookie(rec, BROWSER_TOKEN_COOKIE_NAME).Value)
assert.Equal(t, ts.App.FrontEndURL+"/web/user", rec.Header().Get("Location"))
}
@@ -142,19 +150,20 @@ func (ts *TestSuite) updatePlayerShouldSucceed(t *testing.T, rec *httptest.Respo
func (ts *TestSuite) loginShouldSucceed(t *testing.T, rec *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusSeeOther, rec.Code)
assert.Equal(t, "", getErrorMessage(rec))
- assert.NotEqual(t, "", getCookie(rec, "browserToken").Value)
+ assert.NotEqual(t, "", getCookie(rec, BROWSER_TOKEN_COOKIE_NAME).Value)
assert.Equal(t, ts.App.FrontEndURL+"/web/user", rec.Header().Get("Location"))
}
func (ts *TestSuite) loginShouldFail(t *testing.T, rec *httptest.ResponseRecorder, errorMessage string) {
assert.Equal(t, http.StatusSeeOther, rec.Code)
assert.Equal(t, errorMessage, getErrorMessage(rec))
- assert.Equal(t, "", getCookie(rec, "browserToken").Value)
+ assert.Equal(t, "", getCookie(rec, BROWSER_TOKEN_COOKIE_NAME).Value)
assert.Equal(t, ts.App.FrontEndURL, rec.Header().Get("Location"))
}
func TestFront(t *testing.T) {
t.Parallel()
+
{
// Registration as existing player not allowed
ts := &TestSuite{}
@@ -330,7 +339,7 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
{
// Tripping the honeypot should fail
form := url.Values{}
- form.Set("username", usernameA)
+ form.Set("playerName", usernameA)
form.Set("password", TEST_PASSWORD)
form.Set("email", "mail@example.com")
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
@@ -340,11 +349,11 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
{
// Register
form := url.Values{}
- form.Set("username", usernameA)
+ form.Set("playerName", usernameA)
form.Set("password", TEST_PASSWORD)
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
ts.registrationShouldSucceed(t, rec)
- browserTokenCookie := getCookie(rec, "browserToken")
+ browserTokenCookie := getCookie(rec, BROWSER_TOKEN_COOKIE_NAME)
// Check that the user has been created with a correct password hash/salt
var user User
@@ -375,12 +384,12 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
{
// Register
form := url.Values{}
- form.Set("username", usernameB)
+ form.Set("playerName", usernameB)
form.Set("password", TEST_PASSWORD)
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
ts.registrationShouldSucceed(t, rec)
- browserTokenCookie := getCookie(rec, "browserToken")
+ browserTokenCookie := getCookie(rec, BROWSER_TOKEN_COOKIE_NAME)
// Users not in the DefaultAdmins list should not be admins
var user User
@@ -398,7 +407,7 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
{
// Try registering again with the same username
form := url.Values{}
- form.Set("username", usernameA)
+ form.Set("playerName", usernameA)
form.Set("password", TEST_PASSWORD)
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
@@ -410,7 +419,7 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
// username, but uppercase. Usernames are case-sensitive, but player
// names are.
form := url.Values{}
- form.Set("username", usernameAUppercase)
+ form.Set("playerName", usernameAUppercase)
form.Set("password", TEST_PASSWORD)
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
@@ -420,17 +429,17 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
{
// Registration with a too-long username should fail
form := url.Values{}
- form.Set("username", "AReallyReallyReallyLongUsername")
+ form.Set("playerName", "AReallyReallyReallyLongUsername")
form.Set("password", TEST_PASSWORD)
form.Set("returnUrl", returnURL)
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
- ts.registrationShouldFail(t, rec, "Invalid username: can't be longer than 16 characters", returnURL)
+ ts.registrationShouldFail(t, rec, "Invalid username: neither a valid player name (can't be longer than 16 characters) nor an email address", returnURL)
}
{
// Registration with a too-short password should fail
form := url.Values{}
- form.Set("username", usernameC)
+ form.Set("playerName", usernameC)
form.Set("password", "")
form.Set("returnUrl", returnURL)
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
@@ -440,7 +449,7 @@ func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
{
// Registration from an existing player should fail
form := url.Values{}
- form.Set("username", usernameC)
+ form.Set("playerName", usernameC)
form.Set("password", TEST_PASSWORD)
form.Set("existingPlayer", "on")
form.Set("challengeToken", "This is not a valid challenge token.")
@@ -461,7 +470,7 @@ func (ts *TestSuite) testRegistrationNewPlayerChosenUUIDNotAllowed(t *testing.T)
returnURL := ts.App.FrontEndURL + "/web/registration"
form := url.Values{}
- form.Set("username", username)
+ form.Set("playerName", username)
form.Set("password", TEST_PASSWORD)
form.Set("uuid", uuid)
form.Set("returnUrl", returnURL)
@@ -478,14 +487,14 @@ func (ts *TestSuite) testRegistrationNewPlayerChosenUUID(t *testing.T) {
{
// Register
form := url.Values{}
- form.Set("username", usernameA)
+ form.Set("playerName", usernameA)
form.Set("password", TEST_PASSWORD)
form.Set("uuid", uuid)
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
// Registration should succeed, grant a browserToken, and redirect to user page
- assert.NotEqual(t, "", getCookie(rec, "browserToken"))
+ assert.NotEqual(t, "", getCookie(rec, BROWSER_TOKEN_COOKIE_NAME))
ts.registrationShouldSucceed(t, rec)
// Check that the user has been created and has a player with the chosen UUID
@@ -498,7 +507,7 @@ func (ts *TestSuite) testRegistrationNewPlayerChosenUUID(t *testing.T) {
{
// Try registering again with the same UUID
form := url.Values{}
- form.Set("username", usernameB)
+ form.Set("playerName", usernameB)
form.Set("password", TEST_PASSWORD)
form.Set("uuid", uuid)
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
@@ -509,7 +518,7 @@ func (ts *TestSuite) testRegistrationNewPlayerChosenUUID(t *testing.T) {
{
// Try registering with a garbage UUID
form := url.Values{}
- form.Set("username", usernameB)
+ form.Set("playerName", usernameB)
form.Set("password", TEST_PASSWORD)
form.Set("uuid", "This is not a UUID.")
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
@@ -525,7 +534,7 @@ func (ts *TestSuite) testRegistrationNewPlayerInvite(t *testing.T) {
// Registration without an invite should fail
returnURL := ts.App.FrontEndURL + "/web/registration"
form := url.Values{}
- form.Set("username", usernameA)
+ form.Set("playerName", usernameA)
form.Set("password", TEST_PASSWORD)
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
@@ -536,7 +545,7 @@ func (ts *TestSuite) testRegistrationNewPlayerInvite(t *testing.T) {
// registration page without ?invite
returnURL := ts.App.FrontEndURL + "/web/registration"
form := url.Values{}
- form.Set("username", usernameA)
+ form.Set("playerName", usernameA)
form.Set("password", TEST_PASSWORD)
form.Set("inviteCode", "invalid")
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration?invite=invalid")
@@ -559,15 +568,15 @@ func (ts *TestSuite) testRegistrationNewPlayerInvite(t *testing.T) {
// registration page with the same unused invite code
returnURL := ts.App.FrontEndURL + "/web/registration?invite=" + invite.Code
form := url.Values{}
- form.Set("username", "")
+ form.Set("playerName", "")
form.Set("password", TEST_PASSWORD)
form.Set("inviteCode", invite.Code)
form.Set("returnUrl", returnURL)
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
- ts.registrationShouldFail(t, rec, "Invalid username: can't be blank", returnURL)
+ ts.registrationShouldFail(t, rec, "Invalid username: neither a valid player name (can't be blank) nor an email address", returnURL)
// Then, set a valid username and continnue
- form.Set("username", usernameA)
+ form.Set("playerName", usernameA)
rec = ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
ts.registrationShouldSucceed(t, rec)
@@ -584,7 +593,7 @@ func (ts *TestSuite) solveRegisterChallenge(t *testing.T, username string) *http
rec := httptest.NewRecorder()
ts.Server.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
- challengeToken := getCookie(rec, "challengeToken")
+ challengeToken := getCookie(rec, CHALLENGE_TOKEN_COOKIE_NAME)
assert.NotEqual(t, "", challengeToken.Value)
base64Exp, err := regexp.Compile("src=\"data:image\\/png;base64,([A-Za-z0-9+/]*={0,2})\"")
@@ -614,7 +623,7 @@ func (ts *TestSuite) solveCreatePlayerChallenge(t *testing.T, playerName string)
rec := httptest.NewRecorder()
ts.Server.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
- challengeToken := getCookie(rec, "challengeToken")
+ challengeToken := getCookie(rec, CHALLENGE_TOKEN_COOKIE_NAME)
assert.NotEqual(t, "", challengeToken.Value)
base64Exp, err := regexp.Compile("src=\"data:image\\/png;base64,([A-Za-z0-9+/]*={0,2})\"")
@@ -644,7 +653,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerInvite(t *testing.T) {
// Registration without an invite should fail
returnURL := ts.App.FrontEndURL + "/web/registration"
form := url.Values{}
- form.Set("username", username)
+ form.Set("playerName", username)
form.Set("password", TEST_PASSWORD)
form.Set("existingPlayer", "on")
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
@@ -656,7 +665,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerInvite(t *testing.T) {
// registration page without ?invite
returnURL := ts.App.FrontEndURL + "/web/registration"
form := url.Values{}
- form.Set("username", username)
+ form.Set("playerName", username)
form.Set("password", TEST_PASSWORD)
form.Set("existingPlayer", "on")
form.Set("inviteCode", "invalid")
@@ -682,18 +691,18 @@ func (ts *TestSuite) testRegistrationExistingPlayerInvite(t *testing.T) {
// Registration with an invalid username should redirect to the
// registration page with the same unused invite code
form := url.Values{}
- form.Set("username", "")
+ form.Set("playerName", "")
form.Set("password", TEST_PASSWORD)
form.Set("existingPlayer", "on")
form.Set("inviteCode", invite.Code)
form.Set("returnUrl", returnURL)
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
- ts.registrationShouldFail(t, rec, "Invalid username: can't be blank", returnURL)
+ ts.registrationShouldFail(t, rec, "Invalid username: neither a valid player name (can't be blank) nor an email address", returnURL)
}
{
// Registration should fail if we give the wrong challenge token, and the invite should not be used
form := url.Values{}
- form.Set("username", username)
+ form.Set("playerName", username)
form.Set("password", TEST_PASSWORD)
form.Set("existingPlayer", "on")
form.Set("inviteCode", invite.Code)
@@ -706,7 +715,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerInvite(t *testing.T) {
{
// Registration should succeed if everything is correct
form := url.Values{}
- form.Set("username", username)
+ form.Set("playerName", username)
form.Set("password", TEST_PASSWORD)
form.Set("existingPlayer", "on")
form.Set("inviteCode", invite.Code)
@@ -737,7 +746,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerInvite(t *testing.T) {
func (ts *TestSuite) testLoginLogout(t *testing.T) {
username := "loginLogout"
- ts.CreateTestUser(t, ts.App, ts.Server, username)
+ user, _ := ts.CreateTestUser(t, ts.App, ts.Server, username)
{
// Login
@@ -747,7 +756,7 @@ func (ts *TestSuite) testLoginLogout(t *testing.T) {
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
rec := ts.PostForm(t, ts.Server, "/web/login", form, nil, nil)
ts.loginShouldSucceed(t, rec)
- browserTokenCookie := getCookie(rec, "browserToken")
+ browserTokenCookie := getCookie(rec, BROWSER_TOKEN_COOKIE_NAME)
// The BrowserToken we get should match the one in the database
var user User
@@ -779,6 +788,14 @@ func (ts *TestSuite) testLoginLogout(t *testing.T) {
rec := ts.PostForm(t, ts.Server, "/web/login", form, nil, nil)
ts.loginShouldFail(t, rec, "Incorrect password.")
}
+ {
+ // Web login with the user's Minecraft token should fail
+ form := url.Values{}
+ form.Set("username", username)
+ form.Set("password", user.MinecraftToken)
+ rec := ts.PostForm(t, ts.Server, "/web/login", form, nil, nil)
+ ts.loginShouldFail(t, rec, "Incorrect password.")
+ }
{
// GET /web/user without valid BrowserToken should fail
req := httptest.NewRequest(http.MethodGet, "/web/user", nil)
@@ -802,7 +819,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerNoVerification(t *testing.T)
// Register from the existing account
form := url.Values{}
- form.Set("username", username)
+ form.Set("playerName", username)
form.Set("password", TEST_PASSWORD)
form.Set("existingPlayer", "on")
form.Set("returnUrl", returnURL)
@@ -825,7 +842,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerNoVerification(t *testing.T)
{
// Registration as a new user should fail
form := url.Values{}
- form.Set("username", username)
+ form.Set("playerName", username)
form.Set("password", TEST_PASSWORD)
form.Set("returnUrl", returnURL)
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
@@ -835,7 +852,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerNoVerification(t *testing.T)
// Registration with a missing existing account should fail
returnURL := ts.App.FrontEndURL + "/web/registration"
form := url.Values{}
- form.Set("username", "nonexistent")
+ form.Set("playerName", "nonexistent")
form.Set("password", TEST_PASSWORD)
form.Set("existingPlayer", "on")
form.Set("returnUrl", returnURL)
@@ -947,7 +964,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerVerification(t *testing.T) {
{
// Registration without setting a skin should fail
form := url.Values{}
- form.Set("username", username)
+ form.Set("playerName", username)
form.Set("password", TEST_PASSWORD)
form.Set("existingPlayer", "on")
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
@@ -960,7 +977,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerVerification(t *testing.T) {
rec := httptest.NewRecorder()
ts.Server.ServeHTTP(rec, req)
assert.Equal(t, http.StatusSeeOther, rec.Code)
- assert.Equal(t, "Invalid username: can't be longer than 16 characters", getErrorMessage(rec))
+ assert.Equal(t, "Invalid username: neither a valid player name (can't be longer than 16 characters) nor an email address", getErrorMessage(rec))
assert.Equal(t, returnURL, rec.Header().Get("Location"))
}
{
@@ -968,7 +985,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerVerification(t *testing.T) {
{
// Registration should fail if we give the wrong challenge token
form := url.Values{}
- form.Set("username", username)
+ form.Set("playerName", username)
form.Set("password", TEST_PASSWORD)
form.Set("existingPlayer", "on")
form.Set("challengeToken", "invalid-challenge-token")
@@ -980,7 +997,7 @@ func (ts *TestSuite) testRegistrationExistingPlayerVerification(t *testing.T) {
{
// Registration should succeed if everything is correct
form := url.Values{}
- form.Set("username", username)
+ form.Set("playerName", username)
form.Set("password", TEST_PASSWORD)
form.Set("existingPlayer", "on")
form.Set("challengeToken", challengeToken.Value)
@@ -1078,7 +1095,7 @@ func (ts *TestSuite) testUserUpdate(t *testing.T) {
form.Set("returnUrl", ts.App.FrontEndURL+"/web/registration")
rec = ts.PostForm(t, ts.Server, "/web/login", form, nil, nil)
ts.loginShouldSucceed(t, rec)
- browserTokenCookie = getCookie(rec, "browserToken")
+ browserTokenCookie = getCookie(rec, BROWSER_TOKEN_COOKIE_NAME)
}
{
// As an admin, test updating another user's account
@@ -1447,11 +1464,11 @@ func (ts *TestSuite) testDeleteAccount(t *testing.T) {
{
// Register usernameB again
form := url.Values{}
- form.Set("username", usernameB)
+ form.Set("playerName", usernameB)
form.Set("password", TEST_PASSWORD)
rec := ts.PostForm(t, ts.Server, "/web/register", form, nil, nil)
ts.registrationShouldSucceed(t, rec)
- browserTokenCookie := getCookie(rec, "browserToken")
+ browserTokenCookie := getCookie(rec, BROWSER_TOKEN_COOKIE_NAME)
// Check that usernameB has been created
var otherUser User
diff --git a/go.mod b/go.mod
index 1e6d816..f3df0c0 100644
--- a/go.mod
+++ b/go.mod
@@ -4,12 +4,15 @@ go 1.21
require (
github.com/BurntSushi/toml v1.3.2
+ github.com/deckarep/golang-set/v2 v2.6.0
github.com/dgraph-io/ristretto v0.1.1
github.com/golang-jwt/jwt/v5 v5.1.0
- github.com/google/uuid v1.4.0
+ github.com/google/uuid v1.6.0
github.com/jxskiss/base62 v1.1.0
github.com/labstack/echo/v4 v4.11.4
- github.com/stretchr/testify v1.8.4
+ github.com/samber/mo v1.13.0
+ github.com/stretchr/testify v1.9.0
+ github.com/zitadel/oidc/v3 v3.33.1
golang.org/x/crypto v0.31.0
golang.org/x/time v0.5.0
gorm.io/driver/sqlite v1.3.6
@@ -20,10 +23,13 @@ require (
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/deckarep/golang-set/v2 v2.6.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-jose/go-jose/v4 v4.0.4 // indirect
+ github.com/go-logr/logr v1.4.2 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/glog v1.1.2 // indirect
+ github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
@@ -32,13 +38,21 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.18 // indirect
+ github.com/muhlemmer/gu v0.3.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
- github.com/samber/mo v1.13.0 // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
+ github.com/zitadel/logging v0.6.1 // indirect
+ github.com/zitadel/oidc v1.13.5 // indirect
+ github.com/zitadel/schema v1.3.0 // indirect
+ go.opentelemetry.io/otel v1.29.0 // indirect
+ go.opentelemetry.io/otel/metric v1.29.0 // indirect
+ go.opentelemetry.io/otel/trace v1.29.0 // indirect
golang.org/x/net v0.33.0 // indirect
+ golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
diff --git a/go.sum b/go.sum
index 70f03a5..83533f4 100644
--- a/go.sum
+++ b/go.sum
@@ -11,11 +11,17 @@ github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80N
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
-github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
+github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.1.0 h1:UGKbA/IPjtS6zLcdB7i5TyACMgSbOTiR8qzXgw8HWQU=
@@ -23,8 +29,10 @@ github.com/golang-jwt/jwt/v5 v5.1.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo=
github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ=
-github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
-github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
@@ -35,11 +43,9 @@ github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
-github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
@@ -53,28 +59,49 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
+github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
-github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/samber/mo v1.13.0 h1:LB1OwfJMju3a6FjghH+AIvzMG0ZPOzgTWj1qaHs1IQ4=
github.com/samber/mo v1.13.0/go.mod h1:BfkrCPuYzVG3ZljnZB783WIJIGk1mcZr9c9CPf8tAxs=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/zitadel/logging v0.6.1 h1:Vyzk1rl9Kq9RCevcpX6ujUaTYFX43aa4LkvV1TvUk+Y=
+github.com/zitadel/logging v0.6.1/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow=
+github.com/zitadel/oidc v1.13.5 h1:7jhh68NGZitLqwLiVU9Dtwa4IraJPFF1vS+4UupO93U=
+github.com/zitadel/oidc v1.13.5/go.mod h1:rHs1DhU3Sv3tnI6bQRVlFa3u0lCwtR7S21WHY+yXgPA=
+github.com/zitadel/oidc/v3 v3.33.1 h1:e3w9PDV0Mh50/ZiJWtzyT0E4uxJ6RXll+hqVDnqGbTU=
+github.com/zitadel/oidc/v3 v3.33.1/go.mod h1:zkoZ1Oq6CweX3BaLrftLEGCs6YK6zDpjjVGZrP10AWU=
+github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0=
+github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc=
+go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
+go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
+go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
+go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
+go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
+go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
+golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -86,9 +113,9 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
diff --git a/main.go b/main.go
index e89ffc8..e1df96a 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,9 @@
package main
import (
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
"crypto/rsa"
"crypto/x509"
"encoding/json"
@@ -10,6 +13,8 @@ import (
"github.com/dgraph-io/ristretto"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
+ "github.com/zitadel/oidc/v3/pkg/client/rp"
+ httphelper "github.com/zitadel/oidc/v3/pkg/http"
"golang.org/x/time/rate"
"gorm.io/gorm"
"image"
@@ -22,6 +27,7 @@ import (
"path"
"regexp"
"sync"
+ "time"
)
var DEBUG = os.Getenv("DRASL_DEBUG") != ""
@@ -33,6 +39,7 @@ var bodyDump = middleware.BodyDump(func(c echo.Context, reqBody, resBody []byte)
type App struct {
FrontEndURL string
+ PublicURL string
AuthURL string
AccountURL string
ServicesURL string
@@ -47,10 +54,14 @@ type App struct {
Constants *ConstantsType
PlayerCertificateKeys []rsa.PublicKey
ProfilePropertyKeys []rsa.PublicKey
- Key *rsa.PrivateKey
- KeyB3Sum512 []byte
+ PrivateKey *rsa.PrivateKey
+ PrivateKeyB3Sum256 [256 / 8]byte
+ PrivateKeyB3Sum512 [512 / 8]byte
+ AEAD cipher.AEAD
SkinMutex *sync.Mutex
VerificationSkinTemplate *image.NRGBA
+ OIDCProvidersByName map[string]*OIDCProvider
+ OIDCProvidersByIssuer map[string]*OIDCProvider
}
func (app *App) LogError(err error, c *echo.Context) {
@@ -157,14 +168,16 @@ func (app *App) MakeServer() *echo.Echo {
if app.Config.EnableWebFrontEnd {
t := NewTemplate(app)
e.Renderer = t
+ frontUser := FrontUser(app)
e.GET("/", FrontRoot(app))
e.GET("/web/admin", FrontAdmin(app))
+ e.GET("/web/complete-registration", FrontCompleteRegistration(app))
e.GET("/web/create-player-challenge", FrontCreatePlayerChallenge(app))
e.GET("/web/manifest.webmanifest", FrontWebManifest(app))
+ e.GET("/web/oidc-callback/:providerName", FrontOIDCCallback(app))
e.GET("/web/player/:uuid", FrontPlayer(app))
e.GET("/web/register-challenge", FrontRegisterChallenge(app))
e.GET("/web/registration", FrontRegistration(app))
- frontUser := FrontUser(app)
e.GET("/web/user", frontUser)
e.GET("/web/user/:uuid", frontUser)
e.POST("/web/admin/delete-invite", FrontDeleteInvite(app))
@@ -175,6 +188,8 @@ func (app *App) MakeServer() *echo.Echo {
e.POST("/web/delete-user", FrontDeleteUser(app))
e.POST("/web/login", FrontLogin(app))
e.POST("/web/logout", FrontLogout(app))
+ e.POST("/web/oidc-migrate", app.FrontOIDCMigrate())
+ e.POST("/web/oidc-unlink", app.FrontOIDCUnlink())
e.POST("/web/register", FrontRegister(app))
e.POST("/web/update-player", FrontUpdatePlayer(app))
e.POST("/web/update-user", FrontUpdateUser(app))
@@ -187,6 +202,7 @@ func (app *App) MakeServer() *echo.Echo {
// Drasl API
e.DELETE(DRASL_API_PREFIX+"/invites/:code", app.APIDeleteInvite())
+ e.DELETE(DRASL_API_PREFIX+"/oidc-identities", app.APIDeleteOIDCIdentity())
e.DELETE(DRASL_API_PREFIX+"/players/:uuid", app.APIDeletePlayer())
e.DELETE(DRASL_API_PREFIX+"/user", app.APIDeleteSelf())
e.DELETE(DRASL_API_PREFIX+"/users/:uuid", app.APIDeleteUser())
@@ -203,8 +219,9 @@ func (app *App) MakeServer() *echo.Echo {
e.PATCH(DRASL_API_PREFIX+"/user", app.APIUpdateSelf())
e.PATCH(DRASL_API_PREFIX+"/users/:uuid", app.APIUpdateUser())
- e.POST(DRASL_API_PREFIX+"/login", app.APILogin())
e.POST(DRASL_API_PREFIX+"/invites", app.APICreateInvite())
+ e.POST(DRASL_API_PREFIX+"/login", app.APILogin())
+ e.POST(DRASL_API_PREFIX+"/oidc-identities", app.APICreateOIDCIdentity())
e.POST(DRASL_API_PREFIX+"/players", app.APICreatePlayer())
e.POST(DRASL_API_PREFIX+"/users", app.APICreateUser())
@@ -358,11 +375,17 @@ func setup(config *Config) *App {
}
}
+ // Crypto
key := ReadOrCreateKey(config)
keyBytes := Unwrap(x509.MarshalPKCS8PrivateKey(key))
- sum := blake3.Sum512(keyBytes)
- keyB3Sum512 := sum[:]
+ keyB3Sum256 := blake3.Sum256(keyBytes)
+ keyB3Sum512 := blake3.Sum512(keyBytes)
+ block, err := aes.NewCipher(keyB3Sum256[:])
+ Check(err)
+ aead, err := cipher.NewGCM(block)
+ Check(err)
+ // Database
db, err := OpenDB(config)
Check(err)
@@ -434,6 +457,39 @@ func setup(config *Config) *App {
}
}
+ // OIDC providers
+ oidcProvidersByName := map[string]*OIDCProvider{}
+ oidcProvidersByIssuer := map[string]*OIDCProvider{}
+ scopes := []string{"openid", "email"}
+ for _, oidcConfig := range config.RegistrationOIDC {
+ options := []rp.Option{
+ rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)),
+ rp.WithHTTPClient(MakeHTTPClient()),
+ rp.WithSigningAlgsFromDiscovery(),
+ }
+ escapedProviderName := url.PathEscape(oidcConfig.Name)
+ redirectURI, err := url.JoinPath(config.BaseURL, "web", "oidc-callback", escapedProviderName)
+ if err != nil {
+ log.Fatalf("Error creating OIDC redirect URI: %s", err)
+ }
+ if oidcConfig.PKCE {
+ cookieHandler := httphelper.NewCookieHandler(keyB3Sum256[:], keyB3Sum256[:], httphelper.WithSameSite(http.SameSiteLaxMode))
+ options = append(options, rp.WithPKCE(cookieHandler))
+ }
+ relyingParty, err := rp.NewRelyingPartyOIDC(context.Background(), oidcConfig.Issuer, oidcConfig.ClientID, oidcConfig.ClientSecret, redirectURI, scopes, options...)
+ if err != nil {
+ log.Fatalf("Error creating OIDC relying party: %s", err)
+ }
+
+ oidcProvider := OIDCProvider{
+ RelyingParty: relyingParty,
+ Config: oidcConfig,
+ }
+
+ oidcProvidersByName[oidcConfig.Name] = &oidcProvider
+ oidcProvidersByIssuer[oidcConfig.Issuer] = &oidcProvider
+ }
+
app := &App{
RequestCache: cache,
Config: config,
@@ -442,9 +498,12 @@ func setup(config *Config) *App {
Constants: Constants,
DB: db,
FSMutex: KeyedMutex{},
- Key: key,
- KeyB3Sum512: keyB3Sum512,
+ PrivateKey: key,
+ PrivateKeyB3Sum256: keyB3Sum256,
+ PrivateKeyB3Sum512: keyB3Sum512,
+ AEAD: aead,
FrontEndURL: config.BaseURL,
+ PublicURL: Unwrap(url.JoinPath(config.BaseURL, "web/public")),
PlayerCertificateKeys: playerCertificateKeys,
ProfilePropertyKeys: profilePropertyKeys,
AccountURL: Unwrap(url.JoinPath(config.BaseURL, "account")),
@@ -453,6 +512,8 @@ func setup(config *Config) *App {
SessionURL: Unwrap(url.JoinPath(config.BaseURL, "session")),
AuthlibInjectorURL: Unwrap(url.JoinPath(config.BaseURL, "authlib-injector")),
VerificationSkinTemplate: verificationSkinTemplate,
+ OIDCProvidersByName: oidcProvidersByName,
+ OIDCProvidersByIssuer: oidcProvidersByIssuer,
}
// Post-setup
diff --git a/model.go b/model.go
index 0c82a64..79caa2e 100644
--- a/model.go
+++ b/model.go
@@ -10,6 +10,8 @@ import (
"github.com/samber/mo"
"golang.org/x/crypto/scrypt"
"gorm.io/gorm"
+ "gorm.io/gorm/clause"
+ "net/mail"
"net/url"
"strings"
"time"
@@ -103,7 +105,16 @@ func (app *App) ValidatePlayerName(playerName string) error {
}
func (app *App) ValidateUsername(username string) error {
- return app.ValidatePlayerName(username)
+ // Valid username are either valid player names or valid email addresses
+ playerNameErr := app.ValidatePlayerName(username)
+ if playerNameErr == nil {
+ return nil
+ }
+ _, emailErr := mail.ParseAddress(username)
+ if emailErr == nil {
+ return nil
+ }
+ return fmt.Errorf("neither a valid player name (%s) nor an email address", playerNameErr)
}
func (app *App) ValidatePlayerNameOrUUID(player string) error {
@@ -299,6 +310,14 @@ func MakeAPIToken() (string, error) {
return RandomBase62(16)
}
+func MakeMinecraftToken() (string, error) {
+ random, err := RandomBase62(16)
+ if err != nil {
+ return "", err
+ }
+ return "MC_" + random, nil
+}
+
type TokenClaims struct {
jwt.RegisteredClaims
Version int `json:"version"`
@@ -332,7 +351,7 @@ func (app *App) MakeAccessToken(client Client) (string, error) {
Version: client.Version,
StaleAt: jwt.NewNumericDate(staleAt),
})
- return token.SignedString(app.Key)
+ return token.SignedString(app.PrivateKey)
}
type StaleTokenPolicy int
@@ -344,7 +363,7 @@ const (
func (app *App) GetClient(accessToken string, stalePolicy StaleTokenPolicy) *Client {
token, err := jwt.ParseWithClaims(accessToken, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
- return app.Key.Public(), nil
+ return app.PrivateKey.Public(), nil
})
if err != nil {
return nil
@@ -384,51 +403,43 @@ func (app *App) GetMaxPlayerCount(user *User) int {
type User struct {
IsAdmin bool
IsLocked bool
- UUID string `gorm:"primaryKey"`
- Username string `gorm:"unique;not null"`
- PasswordSalt []byte `gorm:"not null"`
- PasswordHash []byte `gorm:"not null"`
+ UUID string `gorm:"primaryKey"`
+ Username string `gorm:"unique;not null"`
+ PasswordSalt []byte
+ PasswordHash []byte
BrowserToken sql.NullString `gorm:"index"`
+ MinecraftToken string
APIToken string
PreferredLanguage string
Players []Player
MaxPlayerCount int
Clients []Client
+ OIDCIdentities []UserOIDCIdentity
}
func (user *User) BeforeDelete(tx *gorm.DB) error {
- var players []Player
- if err := tx.Where("user_uuid = ?", user.UUID).Find(&players).Error; err != nil {
+ if err := tx.Clauses(clause.Returning{}).Where("user_uuid = ?", user.UUID).Delete(&Player{}).Error; err != nil {
return err
}
- if len(players) > 0 {
- return tx.Delete(&players).Error
- }
-
- var clients []Client
- if err := tx.Where("user_uuid = ?", user.UUID).Find(&clients).Error; err != nil {
+ if err := tx.Clauses(clause.Returning{}).Where("user_uuid = ?", user.UUID).Delete(&Client{}).Error; err != nil {
return err
}
- if len(clients) > 0 {
- if err := tx.Delete(&clients).Error; err != nil {
- return err
- }
+ if err := tx.Clauses(clause.Returning{}).Where("user_uuid = ?", user.UUID).Delete(&UserOIDCIdentity{}).Error; err != nil {
+ return err
}
-
return nil
}
-func (player *Player) BeforeDelete(tx *gorm.DB) error {
- var clients []Client
- if err := tx.Where("player_uuid = ?", player.UUID).Find(&clients).Error; err != nil {
- return err
- }
- if len(clients) > 0 {
- if err := tx.Delete(&clients).Error; err != nil {
- return err
- }
- }
- return nil
+type UserOIDCIdentity struct {
+ ID uint `gorm:"primaryKey"`
+ User User
+ UserUUID string `gorm:"index;not null"`
+ Subject string `gorm:"uniqueIndex:subject_issuer_unique_index;not null"`
+ Issuer string `gorm:"uniqueIndex:subject_issuer_unique_index;not null"`
+}
+
+func (UserOIDCIdentity) TableName() string {
+ return "user_oidc_identities"
}
func (player *Player) AfterFind(tx *gorm.DB) error {
@@ -439,12 +450,13 @@ func (player *Player) AfterFind(tx *gorm.DB) error {
}
func (user *User) AfterFind(tx *gorm.DB) error {
- err := tx.Find(&user.Players, "user_uuid = ?", user.UUID).Error
- if err != nil {
+ if err := tx.Find(&user.OIDCIdentities, "user_uuid = ?", user.UUID).Error; err != nil {
return err
}
- err = tx.Find(&user.Clients, "user_uuid = ?", user.UUID).Error
- if err != nil {
+ if err := tx.Find(&user.Players, "user_uuid = ?", user.UUID).Error; err != nil {
+ return err
+ }
+ if err := tx.Find(&user.Clients, "user_uuid = ?", user.UUID).Error; err != nil {
return err
}
return nil
@@ -462,8 +474,12 @@ type Player struct {
ServerID sql.NullString
FallbackPlayer string
User User
- UserUUID string `gorm:"not null"`
- Clients []Client
+ UserUUID string `gorm:"not null"`
+ Clients []Client `gorm:"constraint:OnDelete:CASCADE"`
+}
+
+func (player *Player) BeforeDelete(tx *gorm.DB) error {
+ return tx.Clauses(clause.Returning{}).Where("player_uuid = ?", player.UUID).Delete(&Client{}).Error
}
type Client struct {
diff --git a/player.go b/player.go
index e9fe7a9..f4806fc 100644
--- a/player.go
+++ b/player.go
@@ -125,7 +125,7 @@ func (app *App) CreatePlayer(
var err error
details, err := app.ValidateChallenge(playerName, challengeToken)
if err != nil {
- if app.Config.RegistrationExistingPlayer.RequireSkinVerification {
+ if app.Config.ImportExistingPlayer.RequireSkinVerification {
return Player{}, NewBadRequestUserError("Couldn't verify your skin, maybe try again: %s", err)
} else {
return Player{}, NewBadRequestUserError("Couldn't find your account, maybe try again: %s", err)
@@ -373,7 +373,7 @@ type ProxiedAccountDetails struct {
}
func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*ProxiedAccountDetails, error) {
- base, err := url.Parse(app.Config.RegistrationExistingPlayer.AccountURL)
+ base, err := url.Parse(app.Config.ImportExistingPlayer.AccountURL)
if err != nil {
return nil, err
}
@@ -400,9 +400,9 @@ func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*P
return nil, err
}
- base, err = url.Parse(app.Config.RegistrationExistingPlayer.SessionURL)
+ base, err = url.Parse(app.Config.ImportExistingPlayer.SessionURL)
if err != nil {
- return nil, fmt.Errorf("Invalid SessionURL %s: %s", app.Config.RegistrationExistingPlayer.SessionURL, err)
+ return nil, fmt.Errorf("Invalid SessionURL %s: %s", app.Config.ImportExistingPlayer.SessionURL, err)
}
base.Path, err = url.JoinPath(base.Path, "session/minecraft/profile/"+idRes.ID)
if err != nil {
@@ -435,7 +435,7 @@ func (app *App) ValidateChallenge(playerName string, challengeToken *string) (*P
Username: profileRes.Name,
UUID: accountUUID,
}
- if !app.Config.RegistrationExistingPlayer.RequireSkinVerification {
+ if !app.Config.ImportExistingPlayer.RequireSkinVerification {
return &details, nil
}
@@ -520,9 +520,9 @@ func (app *App) GetChallenge(playerName string, token string) []byte {
// the verifying browser
challengeBytes := bytes.Join([][]byte{
[]byte(playerName),
- app.KeyB3Sum512,
+ app.PrivateKeyB3Sum512[:],
[]byte(token),
- }, []byte{})
+ }, []byte{byte(0)})
sum := blake3.Sum512(challengeBytes)
return sum[:]
@@ -583,7 +583,7 @@ func (app *App) InvalidateUser(db *gorm.DB, user *User) error {
func (app *App) DeletePlayer(caller *User, player *Player) error {
if caller.UUID != player.UserUUID && !caller.IsAdmin {
- return NewForbiddenUserError("You don't own that player.")
+ return NewUserError(http.StatusForbidden, "You don't own that player.")
}
if err := app.DB.Delete(player).Error; err != nil {
diff --git a/public/openid-logo.svg b/public/openid-logo.svg
new file mode 100644
index 0000000..21ffaad
--- /dev/null
+++ b/public/openid-logo.svg
@@ -0,0 +1,74 @@
+
+
diff --git a/public/style.css b/public/style.css
index ede3ae5..cdb0b25 100644
--- a/public/style.css
+++ b/public/style.css
@@ -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,
diff --git a/services.go b/services.go
index f3c7a3c..281f387 100644
--- a/services.go
+++ b/services.go
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"github.com/labstack/echo/v4"
+ "github.com/samber/mo"
"gorm.io/gorm"
"math/big"
"net/http"
@@ -27,22 +28,22 @@ func withBearerAuthentication(app *App, f func(c echo.Context, user *User, playe
return func(c echo.Context) error {
authorizationHeader := c.Request().Header.Get("Authorization")
if authorizationHeader == "" {
- return c.JSON(http.StatusUnauthorized, ErrorResponse{Path: Ptr(c.Request().URL.Path)})
+ return &YggdrasilError{Code: http.StatusUnauthorized}
}
accessTokenMatch := bearerExp.FindStringSubmatch(authorizationHeader)
if accessTokenMatch == nil || len(accessTokenMatch) < 2 {
- return c.JSON(http.StatusUnauthorized, ErrorResponse{Path: Ptr(c.Request().URL.Path)})
+ return &YggdrasilError{Code: http.StatusUnauthorized}
}
accessToken := accessTokenMatch[1]
client := app.GetClient(accessToken, StalePolicyAllow)
if client == nil {
- return c.JSON(http.StatusUnauthorized, ErrorResponse{Path: Ptr(c.Request().URL.Path)})
+ return &YggdrasilError{Code: http.StatusUnauthorized}
}
player := client.Player
if player == nil {
- return c.JSON(http.StatusBadRequest, ErrorResponse{Path: Ptr(c.Request().URL.Path), ErrorMessage: Ptr("Access token does not have a selected profile.")})
+ return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("Access token does not have a selected profile.")}
}
return f(c, &client.User, player)
@@ -327,30 +328,30 @@ func ServicesPlayerCertificates(app *App) func(c echo.Context) error {
func ServicesUploadSkin(app *App) func(c echo.Context) error {
return withBearerAuthentication(app, func(c echo.Context, _ *User, player *Player) error {
if !app.Config.AllowSkins {
- return MakeErrorResponse(&c, http.StatusBadRequest, nil, Ptr("Changing your skin is not allowed."))
+ return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("Changing your skin is not allowed.")}
}
model := strings.ToLower(c.FormValue("variant"))
if !IsValidSkinModel(model) {
- return MakeErrorResponse(&c, http.StatusBadRequest, nil, Ptr("Invalid request body for skin upload"))
+ return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("Invalid request body for skin upload")}
}
player.SkinModel = model
file, err := c.FormFile("file")
if err != nil {
- return MakeErrorResponse(&c, http.StatusBadRequest, nil, Ptr("content is marked non-null but is null"))
+ return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("content is marked non-null but is null")}
}
src, err := file.Open()
if err != nil {
- return MakeErrorResponse(&c, http.StatusBadRequest, nil, Ptr("content is marked non-null but is null"))
+ return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("content is marked non-null but is null")}
}
defer src.Close()
err = app.SetSkinAndSave(player, src)
if err != nil {
- return MakeErrorResponse(&c, http.StatusBadRequest, nil, Ptr("Could not read image data."))
+ return &YggdrasilError{Code: http.StatusBadRequest, ErrorMessage: mo.Some("Could not read image data.")}
}
servicesProfile, err := getServicesProfile(app, player)
@@ -452,7 +453,7 @@ func ServicesNameAvailability(app *App) func(c echo.Context) error {
}
if err := app.ValidatePlayerName(playerName); err != nil {
errorMessage := fmt.Sprintf("checkNameAvailability.profileName: %s, checkNameAvailability.profileName: Invalid profile name", err.Error())
- return MakeErrorResponse(&c, http.StatusBadRequest, Ptr("CONSTRAINT_VIOLATION"), Ptr(errorMessage))
+ return &YggdrasilError{Code: http.StatusBadRequest, Error_: mo.Some("CONSTRAINT_VIOLATION"), ErrorMessage: mo.Some(errorMessage)}
}
var otherPlayer Player
result := app.DB.First(&otherPlayer, "name = ?", playerName)
diff --git a/services_test.go b/services_test.go
index f563d8f..27c0e80 100644
--- a/services_test.go
+++ b/services_test.go
@@ -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)
diff --git a/session.go b/session.go
index 5d0af75..e7e6b18 100644
--- a/session.go
+++ b/session.go
@@ -2,8 +2,10 @@ package main
import (
"errors"
+ "fmt"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
+ "github.com/samber/mo"
"gorm.io/gorm"
"log"
"net/http"
@@ -28,7 +30,7 @@ func SessionJoin(app *App) func(c echo.Context) error {
client := app.GetClient(req.AccessToken, StalePolicyDeny)
if client == nil {
- return c.JSONBlob(http.StatusForbidden, invalidAccessTokenBlob)
+ return &YggdrasilError{Code: http.StatusForbidden, Error_: mo.Some("ForbiddenOperationException")}
}
player := client.Player
@@ -224,9 +226,10 @@ func SessionProfile(app *App, fromAuthlibInjector bool) func(c echo.Context) err
if err != nil {
_, err = uuid.Parse(id)
if err != nil {
- return c.JSON(http.StatusBadRequest, ErrorResponse{
- ErrorMessage: Ptr("Not a valid UUID: " + c.Param("id")),
- })
+ return &YggdrasilError{
+ Code: http.StatusBadRequest,
+ ErrorMessage: mo.Some(fmt.Sprintf("Not a valid UUID: %s", c.Param("id"))),
+ }
}
uuid_ = id
}
diff --git a/session_test.go b/session_test.go
index 79e1727..c27911c 100644
--- a/session_test.go
+++ b/session_test.go
@@ -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)
}
diff --git a/swagger.json b/swagger.json
index a535de5..d6104e2 100644
--- a/swagger.json
+++ b/swagger.json
@@ -216,6 +216,90 @@
}
}
},
+ "/drasl/api/v2/oidc-identities": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "users"
+ ],
+ "summary": "Link an OIDC identity to a user",
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/main.APIError"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/main.APIError"
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "$ref": "#/definitions/main.APIError"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/main.APIError"
+ }
+ }
+ }
+ },
+ "delete": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "users"
+ ],
+ "summary": "Unlink an OIDC identity from a user",
+ "responses": {
+ "204": {
+ "description": "No Content"
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/main.APIError"
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "$ref": "#/definitions/main.APIError"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/main.APIError"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/main.APIError"
+ }
+ }
+ }
+ }
+ },
"/drasl/api/v2/players": {
"get": {
"description": "Get details of all players. Requires admin privileges.",
@@ -402,6 +486,12 @@
"204": {
"description": "No Content"
},
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/main.APIError"
+ }
+ },
"403": {
"description": "Forbidden",
"schema": {
@@ -1032,8 +1122,14 @@
"type": "integer",
"example": 3
},
+ "oidcIdentities": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/main.APIOIDCIdentitySpec"
+ }
+ },
"password": {
- "description": "Plaintext password",
+ "description": "Plaintext password. Not needed if OIDCIdentitySpecs are supplied.",
"type": "string",
"example": "hunter2"
},
@@ -1139,6 +1235,40 @@
}
}
},
+ "main.APIOIDCIdentity": {
+ "type": "object",
+ "properties": {
+ "issuer": {
+ "type": "string",
+ "example": "https://idm.example.com/oauth2/openid/drasl"
+ },
+ "oidcProviderName": {
+ "type": "string",
+ "example": "Kanidm"
+ },
+ "subject": {
+ "type": "string",
+ "example": "f85f8c18-9bdf-49ad-a76e-719f9ba3ed25"
+ },
+ "userUuid": {
+ "type": "string",
+ "example": "918bd04e-1bc4-4ccd-860f-60c15c5f1cec"
+ }
+ }
+ },
+ "main.APIOIDCIdentitySpec": {
+ "type": "object",
+ "properties": {
+ "issuer": {
+ "type": "string",
+ "example": "https://idm.example.com/oauth2/openid/drasl"
+ },
+ "subject": {
+ "type": "string",
+ "example": "f85f8c18-9bdf-49ad-a76e-719f9ba3ed25"
+ }
+ }
+ },
"main.APIPlayer": {
"type": "object",
"properties": {
@@ -1273,6 +1403,11 @@
"description": "Pass `true` to reset the user's API token",
"type": "boolean",
"example": true
+ },
+ "resetMinecraftToken": {
+ "description": "Pass `true` to reset the user's Minecraft token",
+ "type": "boolean",
+ "example": true
}
}
},
@@ -1294,6 +1429,13 @@
"type": "integer",
"example": 3
},
+ "oidcIdentities": {
+ "description": "OIDC identities linked to the user",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/main.APIOIDCIdentity"
+ }
+ },
"players": {
"description": "A user can have multiple players.",
"type": "array",
diff --git a/test_suite_test.go b/test_suite_test.go
index c1dcefd..88b4cc4 100644
--- a/test_suite_test.go
+++ b/test_suite_test.go
@@ -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)
diff --git a/user.go b/user.go
index 5b68bf1..4f1351e 100644
--- a/user.go
+++ b/user.go
@@ -2,9 +2,13 @@ package main
import (
"bytes"
+ "context"
"crypto/rand"
"errors"
+ "fmt"
"github.com/google/uuid"
+ "github.com/zitadel/oidc/v3/pkg/client/rp"
+ "github.com/zitadel/oidc/v3/pkg/oidc"
"gorm.io/gorm"
"io"
"net/http"
@@ -20,10 +24,38 @@ const SKIN_WINDOW_Y_MAX = 11
var InviteNotFoundError error = NewBadRequestUserError("Invite not found.")
var InviteMissingError error = NewBadRequestUserError("Registration requires an invite.")
+func (app *App) ValidateIDToken(idToken string) (*OIDCProvider, oidc.IDTokenClaims, error) {
+ var claims oidc.IDTokenClaims
+ _, err := oidc.ParseToken(idToken, &claims)
+ if err != nil {
+ return nil, oidc.IDTokenClaims{}, NewBadRequestUserError("Invalid ID token from %s", claims.Issuer)
+ }
+
+ oidcProvider, ok := app.OIDCProvidersByIssuer[claims.Issuer]
+ if !ok {
+ return nil, oidc.IDTokenClaims{}, NewBadRequestUserError("Unknown OIDC issuer: %s", claims.Issuer)
+ }
+
+ verifier := oidcProvider.RelyingParty.IDTokenVerifier()
+ _, err = rp.VerifyIDToken[*oidc.IDTokenClaims](context.Background(), idToken, verifier)
+ if err != nil {
+ return nil, oidc.IDTokenClaims{}, NewBadRequestUserError("Invalid ID token from %s", claims.Issuer)
+ }
+
+ return oidcProvider, claims, nil
+}
+
+type OIDCIdentitySpec struct {
+ Issuer string
+ Subject string
+}
+
func (app *App) CreateUser(
caller *User,
username string,
- password string,
+ password *string,
+ // You must verify that the caller owns these OIDC identities (or is an admin).
+ oidcIdentitySpecs PotentiallyInsecure[[]OIDCIdentitySpec],
isAdmin bool,
isLocked bool,
inviteCode *string,
@@ -42,21 +74,46 @@ func (app *App) CreateUser(
) (User, error) {
callerIsAdmin := caller != nil && caller.IsAdmin
+ userUUID := uuid.New().String()
+
if err := app.ValidateUsername(username); err != nil {
return User{}, NewBadRequestUserError("Invalid username: %s", err)
}
- if err := app.ValidatePassword(password); err != nil {
- return User{}, NewBadRequestUserError("Invalid password: %s", err)
+
+ if password == nil && len(oidcIdentitySpecs.Value) == 0 {
+ return User{}, NewBadRequestUserError("Must specify either a password or an OIDC identity.")
}
+
+ if password != nil {
+ if !app.Config.AllowPasswordLogin {
+ return User{}, NewBadRequestUserError("Password registration is not allowed.")
+ }
+ if err := app.ValidatePassword(*password); err != nil {
+ return User{}, NewBadRequestUserError("Invalid password: %s", err)
+ }
+ }
+
+ oidcIdentities := make([]UserOIDCIdentity, 0, len(oidcIdentitySpecs.Value))
+ for _, oidcIdentitySpec := range oidcIdentitySpecs.Value {
+ provider, ok := app.OIDCProvidersByIssuer[oidcIdentitySpec.Issuer]
+ if !ok {
+ return User{}, NewBadRequestUserError("Unknown OIDC provider: %s", oidcIdentitySpec.Issuer)
+ }
+ if oidcIdentitySpec.Subject == "" {
+ return User{}, NewBadRequestUserError("OIDC subject for provider %s can't be blank.", provider.Config.Issuer)
+ }
+ oidcIdentities = append(oidcIdentities, UserOIDCIdentity{
+ UserUUID: userUUID,
+ Issuer: provider.Config.Issuer,
+ Subject: oidcIdentitySpec.Subject,
+ })
+ }
+
if playerName == nil {
playerName = &username
- } else {
- if *playerName != username && !app.Config.AllowChangingPlayerName && !callerIsAdmin {
- return User{}, NewBadRequestUserError("Choosing a player name different from your username is not allowed.")
- }
- if err := app.ValidatePlayerName(*playerName); err != nil {
- return User{}, NewBadRequestUserError("Invalid player name: %s", err)
- }
+ }
+ if err := app.ValidatePlayerName(*playerName); err != nil {
+ return User{}, NewBadRequestUserError("Invalid player name: %s", err)
}
if preferredLanguage == nil {
@@ -105,7 +162,7 @@ func (app *App) CreateUser(
details, err := app.ValidateChallenge(*playerName, challengeToken)
if err != nil {
- if app.Config.RegistrationExistingPlayer.RequireSkinVerification {
+ if app.Config.ImportExistingPlayer.RequireSkinVerification {
return User{}, NewBadRequestUserError("Couldn't verify your skin, maybe try again: %s", err)
} else {
return User{}, NewBadRequestUserError("Couldn't find your account, maybe try again: %s", err)
@@ -143,15 +200,18 @@ func (app *App) CreateUser(
}
}
- passwordSalt := make([]byte, 16)
- _, err := rand.Read(passwordSalt)
- if err != nil {
- return User{}, err
- }
-
- passwordHash, err := HashPassword(password, passwordSalt)
- if err != nil {
- return User{}, err
+ passwordSalt := []byte{}
+ passwordHash := []byte{}
+ if password != nil {
+ passwordSalt = make([]byte, 16)
+ _, err := rand.Read(passwordSalt)
+ if err != nil {
+ return User{}, err
+ }
+ passwordHash, err = HashPassword(*password, passwordSalt)
+ if err != nil {
+ return User{}, err
+ }
}
if isAdmin && !callerIsAdmin {
@@ -179,16 +239,23 @@ func (app *App) CreateUser(
return User{}, err
}
+ minecraftToken, err := MakeMinecraftToken()
+ if err != nil {
+ return User{}, err
+ }
+
user := User{
IsAdmin: Contains(app.Config.DefaultAdmins, username) || isAdmin,
IsLocked: isLocked,
- UUID: uuid.New().String(),
+ UUID: userUUID,
Username: username,
PasswordSalt: passwordSalt,
PasswordHash: passwordHash,
PreferredLanguage: app.Config.DefaultPreferredLanguage,
MaxPlayerCount: maxPlayerCountInt,
APIToken: apiToken,
+ MinecraftToken: minecraftToken,
+ OIDCIdentities: oidcIdentities,
}
// Player
@@ -287,7 +354,9 @@ func (app *App) CreateUser(
return user, nil
}
-func (app *App) Login(username string, password string) (User, error) {
+var PasswordLoginNotAllowedError error = NewUserError(http.StatusUnauthorized, "Password login is not allowed.")
+
+func (app *App) AuthenticateUserForMigration(username string, password string) (User, error) {
var user User
result := app.DB.First(&user, "username = ?", username)
if result.Error != nil {
@@ -297,6 +366,36 @@ func (app *App) Login(username string, password string) (User, error) {
return User{}, result.Error
}
+ if len(user.OIDCIdentities) > 0 {
+ return User{}, PasswordLoginNotAllowedError
+ }
+
+ passwordHash, err := HashPassword(password, user.PasswordSalt)
+ if err != nil {
+ return User{}, err
+ }
+
+ if !bytes.Equal(passwordHash, user.PasswordHash) {
+ return User{}, NewUserError(http.StatusUnauthorized, "Incorrect password.")
+ }
+
+ return user, nil
+}
+
+func (app *App) AuthenticateUser(username string, password string) (User, error) {
+ var user User
+ result := app.DB.First(&user, "username = ?", username)
+ if result.Error != nil {
+ if errors.Is(result.Error, gorm.ErrRecordNotFound) {
+ return User{}, NewUserError(http.StatusUnauthorized, "User not found.")
+ }
+ return User{}, result.Error
+ }
+
+ if !app.Config.AllowPasswordLogin || len(user.OIDCIdentities) > 0 {
+ return User{}, PasswordLoginNotAllowedError
+ }
+
passwordHash, err := HashPassword(password, user.PasswordSalt)
if err != nil {
return User{}, err
@@ -307,7 +406,7 @@ func (app *App) Login(username string, password string) (User, error) {
}
if user.IsLocked {
- return User{}, NewForbiddenUserError("User is locked.")
+ return User{}, NewUserError(http.StatusForbidden, "User is locked.")
}
return user, nil
@@ -321,6 +420,7 @@ func (app *App) UpdateUser(
isAdmin *bool,
isLocked *bool,
resetAPIToken bool,
+ resetMinecraftToken bool,
preferredLanguage *string,
maxPlayerCount *int,
) (User, error) {
@@ -377,6 +477,14 @@ func (app *App) UpdateUser(
user.APIToken = apiToken
}
+ if resetMinecraftToken {
+ minecraftToken, err := MakeMinecraftToken()
+ if err != nil {
+ return User{}, err
+ }
+ user.MinecraftToken = minecraftToken
+ }
+
if maxPlayerCount != nil {
if !callerIsAdmin {
return User{}, NewBadRequestUserError("Cannot set a max player count without admin privileges.")
@@ -428,7 +536,7 @@ func (app *App) SetIsLocked(db *gorm.DB, user *User, isLocked bool) error {
func (app *App) DeleteUser(caller *User, user *User) error {
if !caller.IsAdmin && caller.UUID != user.UUID {
- return NewForbiddenUserError("You are not an admin.")
+ return NewUserError(http.StatusForbidden, "You are not an admin.")
}
oldSkinHashes := make([]*string, 0, len(user.Players))
@@ -463,3 +571,106 @@ func (app *App) DeleteUser(caller *User, user *User) error {
return nil
}
+
+func (app *App) CreateOIDCIdentity(
+ caller *User,
+ userUUID string,
+ issuer string,
+ subject string,
+) (UserOIDCIdentity, error) {
+ if caller == nil {
+ return UserOIDCIdentity{}, NewBadRequestUserError("Caller cannot be null.")
+ }
+
+ callerIsAdmin := caller.IsAdmin
+
+ if userUUID != caller.UUID && !callerIsAdmin {
+ return UserOIDCIdentity{}, NewBadRequestUserError("Can't link an OIDC account for another user unless you're an admin.")
+ }
+
+ var user User
+ if err := app.DB.First(&user, "uuid = ?", userUUID).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return UserOIDCIdentity{}, NewBadRequestUserError("User not found.")
+ }
+ return UserOIDCIdentity{}, err
+ }
+
+ userOIDCIdentity := UserOIDCIdentity{
+ UserUUID: userUUID,
+ Issuer: issuer,
+ Subject: subject,
+ }
+
+ err := app.DB.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Create(&userOIDCIdentity).Error; err != nil {
+ if IsErrorUniqueFailedField(err, "user_oidc_identities.issuer, user_oidc_identities.subject") {
+ provider, ok := app.OIDCProvidersByIssuer[issuer]
+ if !ok {
+ return fmt.Errorf("Unknown OIDC provider: %s", issuer)
+ }
+ return NewBadRequestUserError("That %s account is already linked to another user.", provider.Config.Name)
+ }
+ if IsErrorUniqueFailedField(err, "user_oidc_identities.issuer") {
+ provider, ok := app.OIDCProvidersByIssuer[issuer]
+ if !ok {
+ return fmt.Errorf("Unknown OIDC provider: %s", issuer)
+ }
+ return NewBadRequestUserError("That user is already linked to a %s account.", provider.Config.Name)
+ }
+ return err
+ }
+ user.OIDCIdentities = append(user.OIDCIdentities, userOIDCIdentity)
+ if err := tx.Save(&user).Error; err != nil {
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ return UserOIDCIdentity{}, err
+ }
+
+ return userOIDCIdentity, nil
+}
+
+func (app *App) DeleteOIDCIdentity(
+ caller *User,
+ userUUID string,
+ providerName string,
+) error {
+ if caller == nil {
+ return NewBadRequestUserError("Caller cannot be null.")
+ }
+
+ callerIsAdmin := caller.IsAdmin
+
+ if userUUID != caller.UUID && !callerIsAdmin {
+ return NewBadRequestUserError("Can't unlink an OIDC account for another user unless you're an admin.")
+ }
+
+ provider, ok := app.OIDCProvidersByName[providerName]
+ if !ok {
+ return NewBadRequestUserError("Unknown OIDC provider: %s", providerName)
+ }
+
+ return app.DB.Transaction(func(tx *gorm.DB) error {
+ result := app.DB.Where("user_uuid = ? AND issuer = ?", userUUID, provider.Config.Issuer).Delete(&UserOIDCIdentity{})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return NewUserError(http.StatusNotFound, "No linked %s account found.", providerName)
+ }
+
+ var count int64
+ if err := tx.Model(&UserOIDCIdentity{}).Where("user_uuid = ?", userUUID).Count(&count).Error; err != nil {
+ return err
+ }
+
+ if count == 0 {
+ return NewBadRequestUserError("Can't remove the last linked OIDC account.")
+ }
+
+ return nil
+ })
+}
diff --git a/util.go b/util.go
index 7aab10c..a9d5f6c 100644
--- a/util.go
+++ b/util.go
@@ -15,6 +15,12 @@ import (
"sync"
)
+// Wrap arguments that may introduce security issues so the caller is aware to
+// take additional precautions
+type PotentiallyInsecure[T any] struct {
+ Value T
+}
+
func Check(e error) {
if e != nil {
log.Fatal(e)
@@ -121,7 +127,7 @@ func SignSHA256(app *App, plaintext []byte) ([]byte, error) {
hash.Write(plaintext)
sum := hash.Sum(nil)
- return rsa.SignPKCS1v15(rand.Reader, app.Key, crypto.SHA256, sum)
+ return rsa.SignPKCS1v15(rand.Reader, app.PrivateKey, crypto.SHA256, sum)
}
func SignSHA1(app *App, plaintext []byte) ([]byte, error) {
@@ -129,7 +135,7 @@ func SignSHA1(app *App, plaintext []byte) ([]byte, error) {
hash.Write(plaintext)
sum := hash.Sum(nil)
- return rsa.SignPKCS1v15(rand.Reader, app.Key, crypto.SHA1, sum)
+ return rsa.SignPKCS1v15(rand.Reader, app.PrivateKey, crypto.SHA1, sum)
}
type KeyedMutex struct {
diff --git a/view/admin.tmpl b/view/admin.tmpl
index 9e16e65..a1c2bb9 100644
--- a/view/admin.tmpl
+++ b/view/admin.tmpl
@@ -71,7 +71,15 @@
method="post"
onsubmit="return confirm('Are you sure you want to delete the account “{{ $user.Username }}”? This action is irreversible.');"
>
-
+
{{ end }}
@@ -117,7 +125,10 @@
diff --git a/view/challenge.tmpl b/view/challenge.tmpl
index e5687a9..5e98bb4 100644
--- a/view/challenge.tmpl
+++ b/view/challenge.tmpl
@@ -4,14 +4,14 @@
We need to verify that you own the
- {{ .App.Config.RegistrationExistingPlayer.Nickname }} account
+ {{ .App.Config.ImportExistingPlayer.Nickname }} account
"{{ .PlayerName }}" before you register its UUID.
Download this image and set it as your skin on your
- {{ .App.Config.RegistrationExistingPlayer.Nickname }}
- account{{ if .App.Config.RegistrationExistingPlayer.SetSkinURL }}, here{{ end }}.
+ {{ .App.Config.ImportExistingPlayer.Nickname }}
+ account{{ if .App.Config.ImportExistingPlayer.SetSkinURL }}, here{{ end }}.
- When you are done, enter a password for your {{ .App.Config.ApplicationName }} account and hit
- "Register".
-
+ {{ if .UseIDToken }}
+
+ When you are done, hit "Register".
+
+ {{ else }}
+
+ When you are done, enter a password for your {{ .App.Config.ApplicationName }} account and hit
+ "Register".
+
+ {{ end }}
+ {{ $dividerNeeded := true }}
+ {{ end }}
+
+
+ {{ if .App.Config.CreateNewPlayer.Allow }}
+ {{ if $dividerNeeded }}
+
or
+ {{ $dividerNeeded = false }}
+ {{ end }}
+
Create a player
+ {{ if .App.Config.CreateNewPlayer.AllowChoosingUUID }}
+
Complete registration by creating a new player:
+ {{ else }}
+
Complete registration by creating a new player with a random UUID:
+ {{ end }}
+
+ {{ $dividerNeeded = true }}
+ {{ end }}
+
+
+ {{ if .App.Config.ImportExistingPlayer.Allow }}
+ {{ if $dividerNeeded }}
+
or
+ {{ $dividerNeeded = false }}
+ {{ end }}
+
Register from an existing Minecraft player
+ {{ if and .App.Config.RegistrationExistingPlayer.RequireInvite (not
+ .InviteCode)
+ }}
+
Registration as an existing player is invite-only.
+ {{ else }}
+ {{ if .App.Config.ImportExistingPlayer.RequireSkinVerification }}
+
+ Register a new account with the UUID of an existing
+ {{ .App.Config.ImportExistingPlayer.Nickname }} account.
+ Requires verification that you own the account.
+
+ {{ if .InviteCode }}
+
Using invite code {{ .InviteCode }}
+ {{ end }}
+
+ {{ else }}
+
+ Register a new account with the UUID of an existing
+ {{ .App.Config.ImportExistingPlayer.Nickname }} account.
+