package main import ( "bytes" "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/leonelquinteros/gotext" "github.com/samber/mo" "github.com/zitadel/oidc/v3/pkg/client/rp" "github.com/zitadel/oidc/v3/pkg/oidc" "golang.org/x/text/language" "gorm.io/gorm" "html/template" "io" "log" "net/http" "net/url" "path" "strconv" "strings" ) /* Web front end for creating user accounts, changing passwords, skins, player names, etc. */ const CONTEXT_KEY_LOCALE = "DraslLocale" 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 type Template struct { Templates map[string]*template.Template } func NewTemplate(app *App) *Template { t := &Template{ Templates: make(map[string]*template.Template), } templateDir := path.Join(app.Config.DataDirectory, "view") names := []string{ "root", "error", "user", "player", "registration", "complete-registration", "challenge", "admin", } funcMap := template.FuncMap{ "render": RenderHTML, "PrimaryPlayerSkinURL": app.PrimaryPlayerSkinURL, "PlayerSkinURL": app.PlayerSkinURL, "InviteURL": app.InviteURL, "IsDefaultAdmin": app.IsDefaultAdmin, } for _, name := range names { tmpl := Unwrap(template.New("").Funcs(funcMap).ParseFiles( path.Join(templateDir, "layout.tmpl"), path.Join(templateDir, name+".tmpl"), path.Join(templateDir, "header.tmpl"), path.Join(templateDir, "footer.tmpl"), )) t.Templates[name] = tmpl } return t } func (app *App) GetLanguageMiddleware() func(echo.HandlerFunc) echo.HandlerFunc { matcher := language.NewMatcher(app.LocaleTags) return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { header := c.Request().Header.Get("Accept-Language") t, _, _ := language.ParseAcceptLanguage(header) // Use only the returned index, not the returned tag: https://github.com/golang/go/issues/24211 _, localeTagIndex, _ := matcher.Match(t...) l := app.Locales[app.LocaleTags[localeTagIndex]] c.Set(CONTEXT_KEY_LOCALE, l) return next(c) } } } func (t *Template) Render(w io.Writer, name string, data any, c echo.Context) error { return t.Templates[name].ExecuteTemplate(w, "base", data) } func (app *App) setMessageCookie(c *echo.Context, cookieName string, message string) { (*c).SetCookie(&http.Cookie{ Name: cookieName, Value: url.QueryEscape(message), Path: "/", SameSite: http.SameSiteLaxMode, HttpOnly: true, Secure: true, }) } func (app *App) setSuccessMessage(c *echo.Context, message string) { app.setMessageCookie(c, SUCCESS_MESSAGE_COOKIE_NAME, message) } // func (app *App) setWarningMessage(c *echo.Context, message string) { // app.setMessageCookie(c, WARNING_MESSAGE_COOKIE_NAME, message) // } func (app *App) setErrorMessage(c *echo.Context, message string) { app.setMessageCookie(c, ERROR_MESSAGE_COOKIE_NAME, message) } func (app *App) setBrowserToken(c *echo.Context, browserToken string) { (*c).SetCookie(&http.Cookie{ Name: BROWSER_TOKEN_COOKIE_NAME, Value: browserToken, MaxAge: BROWSER_TOKEN_AGE_SEC, Path: "/", SameSite: http.SameSiteLaxMode, HttpOnly: true, Secure: true, }) } type WebError struct { /// Wrap a UserError with a ReturnURL Err *UserError ReturnURL string } func (e *WebError) Error() string { return e.Err.Error() } func (e *WebError) TranslatedError(l *gotext.Locale) string { return e.Err.TranslatedError(l) } func NewWebError(returnURL string, message string, args ...any) error { return &WebError{ Err: &UserError{Message: message, Params: args}, ReturnURL: returnURL, } } func RenderHTML(templateString string, args ...any) (template.HTML, error) { // If there are no args, skip parsing and return the "template" as-is if len(args) == 0 { return template.HTML(templateString), nil } t, err := template.New("").Parse(templateString) if err != nil { return "", err } var buf bytes.Buffer err = t.Execute(&buf, args) if err != nil { return "", err } return template.HTML(buf.String()), nil } type baseContext struct { App *App L *gotext.Locale T func(string, ...any) string TN func(string, string, int, ...any) string URL string SuccessMessage string WarningMessage string ErrorMessage string } func (app *App) NewBaseContext(c *echo.Context) baseContext { l := app.getLocale(c) T := l.Get TN := l.GetN return baseContext{ App: app, L: l, T: T, TN: TN, URL: (*c).Request().URL.RequestURI(), SuccessMessage: app.lastSuccessMessage(c), WarningMessage: app.lastWarningMessage(c), ErrorMessage: app.lastErrorMessage(c), } } func (app *App) getLocale(c *echo.Context) *gotext.Locale { if l := (*c).Get(CONTEXT_KEY_LOCALE); l != nil { return (*c).Get(CONTEXT_KEY_LOCALE).(*gotext.Locale) } else { return app.DefaultLocale } } type errorContext struct { baseContext User *User Message string StatusCode int } // Set error message and redirect func (app *App) HandleWebError(err error, c *echo.Context) error { l := app.getLocale(c) var webError *WebError var userError *UserError if errors.As(err, &webError) { app.setErrorMessage(c, webError.TranslatedError(l)) return (*c).Redirect(http.StatusSeeOther, webError.ReturnURL) } else if errors.As(err, &userError) { returnURL := getReturnURL(app, c) app.setErrorMessage(c, userError.TranslatedError(l)) return (*c).Redirect(http.StatusSeeOther, returnURL) } code := http.StatusInternalServerError message := l.Get("Internal server error") var httpError *echo.HTTPError if errors.As(err, &httpError) { code = httpError.Code if m, ok := httpError.Message.(string); ok { message = m } } LogError(err, c) safeMethods := []string{ "GET", "HEAD", "OPTIONS", "TRACE", } if Contains(safeMethods, (*c).Request().Method) { return (*c).Render(code, "error", errorContext{ baseContext: app.NewBaseContext(c), User: nil, Message: message, StatusCode: code, }) } else { returnURL := getReturnURL(app, c) app.setErrorMessage(c, message) return (*c).Redirect(http.StatusSeeOther, returnURL) } } // 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 "" } decoded, err := url.QueryUnescape(cookie.Value) if err != nil { return "" } app.setMessageCookie(c, cookieName, "") return decoded } func (app *App) lastSuccessMessage(c *echo.Context) string { return app.lastMessageCookie(c, SUCCESS_MESSAGE_COOKIE_NAME) } 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 { if (*c).FormValue("returnUrl") != "" { return (*c).FormValue("returnUrl") } return app.FrontEndURL } // Authenticate a user using the `browserToken` cookie, and call `f` with a // reference to the user func withBrowserAuthentication(app *App, requireLogin bool, f func(c echo.Context, user *User) error) func(c echo.Context) error { return func(c echo.Context) error { destination := c.Request().URL.String() if c.Request().Method != "GET" { destination = getReturnURL(app, &c) } returnURL, err := addDestination(app.FrontEndURL, destination) if err != nil { return err } cookie, err := c.Cookie(BROWSER_TOKEN_COOKIE_NAME) var user User if err != nil || cookie.Value == "" { if requireLogin { return NewWebError(returnURL, "You are not logged in.") } return f(c, nil) } else { result := app.DB.First(&user, "browser_token = ?", cookie.Value) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { if requireLogin { 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) } } } func withBrowserAdmin(app *App, f func(c echo.Context, user *User) error) func(c echo.Context) error { return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error { returnURL := getReturnURL(app, &c) if !user.IsAdmin { return NewWebError(returnURL, "You are not an admin.") } return f(c, user) }) } 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 { baseContext User *User Destination 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 := range app.OIDCProviderNames { provider := app.OIDCProvidersByName[name] 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{ baseContext: app.NewBaseContext(&c), User: user, Destination: destination, WebOIDCProviders: webOIDCProviders, }) }) } type WebManifestIcon struct { Src string `json:"src"` Type string `json:"type"` Sizes string `json:"sizes"` } type WebManifest struct { Icons []WebManifestIcon `json:"icons"` } func FrontWebManifest(app *App) func(c echo.Context) error { iconURL := Unwrap(url.JoinPath(app.PublicURL, "icon.png")) manifest := WebManifest{ Icons: []WebManifestIcon{{ Src: iconURL, Type: "image/png", Sizes: "512x512", }}, } manifestBlob := Unwrap(json.Marshal(manifest)) return func(c echo.Context) error { return c.JSONBlob(http.StatusOK, manifestBlob) } } 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 registrationContext struct { baseContext User *User InviteCode string WebOIDCProviders []webOIDCProvider } return withBrowserAuthentication(app, false, func(c echo.Context, user *User) error { inviteCode := c.QueryParam("invite") 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 := range app.OIDCProviderNames { provider := app.OIDCProvidersByName[name] 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{ baseContext: app.NewBaseContext(&c), User: user, 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{}, NewUserError("Missing ID token cookie") } idTokenBytes, err := app.DecryptCookieValue(cookie.Value) if err != nil { return nil, "", oidc.IDTokenClaims{}, NewUserError("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 { baseContext User *User 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} } 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{ baseContext: app.NewBaseContext(&c), User: user, 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 { l := app.getLocale(&c) 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, l.Get("%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 { l := app.getLocale(&c) 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} } 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} } return err } app.setSuccessMessage(&c, l.Get("Successfully linked your %s account.", oidcProvider.Config.Name)) return c.Redirect(http.StatusSeeOther, returnURL) } func (app *App) oidcSignIn(c echo.Context, _ *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 { log.Printf("OIDC code exchange failed with provider %s: %s", oidcProvider.Config.Name, err) 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) } }) } // GET /web/admin func FrontAdmin(app *App) func(c echo.Context) error { type adminContext struct { baseContext User *User Users []User Invites []Invite } return withBrowserAdmin(app, func(c echo.Context, user *User) error { var users []User result := app.DB.Find(&users) if result.Error != nil { return result.Error } var invites []Invite result = app.DB.Find(&invites) if result.Error != nil { return result.Error } return c.Render(http.StatusOK, "admin", adminContext{ baseContext: app.NewBaseContext(&c), User: user, Users: users, Invites: invites, }) }) } // POST /web/admin/delete-invite func FrontDeleteInvite(app *App) func(c echo.Context) error { returnURL := Unwrap(url.JoinPath(app.FrontEndURL, "web/admin")) return withBrowserAdmin(app, func(c echo.Context, user *User) error { inviteCode := c.FormValue("inviteCode") var invite Invite result := app.DB.Where("code = ?", inviteCode).Delete(&invite) if result.Error != nil { return result.Error } return c.Redirect(http.StatusSeeOther, returnURL) }) } // POST /web/admin/update-users func FrontUpdateUsers(app *App) func(c echo.Context) error { return withBrowserAdmin(app, func(c echo.Context, user *User) error { l := app.getLocale(&c) returnURL := getReturnURL(app, &c) var users []User result := app.DB.Find(&users) if result.Error != nil { return result.Error } tx := app.DB.Begin() defer tx.Rollback() anyUnlockedAdmins := false for _, targetUser := range users { shouldBeAdmin := c.FormValue("admin-"+targetUser.UUID) == "on" if app.IsDefaultAdmin(&targetUser) { shouldBeAdmin = true } shouldBeLocked := c.FormValue("locked-"+targetUser.UUID) == "on" if shouldBeAdmin && !shouldBeLocked { anyUnlockedAdmins = true } maxPlayerCountString := c.FormValue("max-player-count-" + targetUser.UUID) maxPlayerCount := targetUser.MaxPlayerCount if maxPlayerCountString == "" { maxPlayerCount = app.Constants.MaxPlayerCountUseDefault } else { var err error maxPlayerCount, err = strconv.Atoi(maxPlayerCountString) if err != nil { return NewWebError(returnURL, "Max player count must be an integer.") } } if targetUser.IsAdmin != shouldBeAdmin || targetUser.IsLocked != shouldBeLocked || targetUser.MaxPlayerCount != maxPlayerCount { _, err := app.UpdateUser( tx, user, // caller targetUser, // user nil, &shouldBeAdmin, // isAdmin &shouldBeLocked, // isLocked false, false, nil, &maxPlayerCount, ) if err != nil { var userError *UserError if errors.As(err, &userError) { return &WebError{ReturnURL: returnURL, Err: userError} } return err } } } if !anyUnlockedAdmins { return NewWebError(returnURL, "There must be at least one unlocked admin account.") } err := tx.Commit().Error if err != nil { return err } app.setSuccessMessage(&c, l.Get("Changes saved.")) return c.Redirect(http.StatusSeeOther, returnURL) }) } // POST /web/admin/new-invite func FrontNewInvite(app *App) func(c echo.Context) error { return withBrowserAdmin(app, func(c echo.Context, user *User) error { returnURL := getReturnURL(app, &c) _, err := app.CreateInvite() if err != nil { var userError *UserError if errors.As(err, &userError) { return &WebError{ReturnURL: returnURL, Err: userError} } return err } return c.Redirect(http.StatusSeeOther, returnURL) }) } // GET /web/user // GET /web/user/:uuid func FrontUser(app *App) func(c echo.Context) error { type userContext struct { baseContext User *User 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 { var targetUser *User targetUUID := c.Param("uuid") adminView := false if targetUUID == "" || targetUUID == user.UUID { var targetUserStruct User result := app.DB.First(&targetUserStruct, "uuid = ?", user.UUID) if result.Error != nil { return result.Error } targetUser = &targetUserStruct } else { if !user.IsAdmin { return NewWebError(app.FrontEndURL, "You are not an admin.") } adminView = true var targetUserStruct User result := app.DB.First(&targetUserStruct, "uuid = ?", targetUUID) if result.Error != nil { returnURL, err := url.JoinPath(app.FrontEndURL, "web/admin") if err != nil { return err } return NewWebError(returnURL, "User not found.") } targetUser = &targetUserStruct } 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 := range app.OIDCProviderNames { provider := app.OIDCProvidersByName[name] 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{ baseContext: app.NewBaseContext(&c), User: user, TargetUser: targetUser, AdminView: adminView, LinkedOIDCProviderNames: linkedOIDCProviderNames.ToSlice(), UnlinkedOIDCProviders: unlinkedOIDCProviders, MaxPlayerCount: maxPlayerCount, }) }) } // GET /web/player/:uuid func FrontPlayer(app *App) func(c echo.Context) error { type playerContext struct { baseContext User *User PlayerUser *User Player *Player PlayerID string SkinURL *string CapeURL *string AdminView bool } return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error { returnURL := getReturnURL(app, &c) playerUUID := c.Param("uuid") var player Player result := app.DB.Preload("User").First(&player, "uuid = ?", playerUUID) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return NewWebError(returnURL, "Player not found.") } return result.Error } playerUser := player.User if !user.IsAdmin && (player.User.UUID != user.UUID) { return NewWebError(app.FrontEndURL, "You don't own that player.") } adminView := playerUser.UUID != user.UUID skinURL, err := app.GetSkinURL(&player) if err != nil { return err } capeURL, err := app.GetCapeURL(&player) if err != nil { return err } id, err := UUIDToID(player.UUID) if err != nil { return err } return c.Render(http.StatusOK, "player", playerContext{ baseContext: app.NewBaseContext(&c), User: user, PlayerUser: &playerUser, Player: &player, PlayerID: id, SkinURL: skinURL, CapeURL: capeURL, AdminView: adminView, }) }) } func nilIfEmpty(str string) *string { if str == "" { return nil } return &str } func getFormValue(c *echo.Context, key string) mo.Option[string] { // Call FormValue first to parse the form appropriately value := (*c).FormValue(key) if (*c).Request().Form.Has(key) { return mo.Some(value) } return mo.None[string]() } // POST /update-user func FrontUpdateUser(app *App) func(c echo.Context) error { return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error { l := app.getLocale(&c) returnURL := getReturnURL(app, &c) 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") var targetUser *User if targetUUID == nil || *targetUUID == user.UUID { targetUser = user } else { if !user.IsAdmin { return NewWebError(app.FrontEndURL, "You are not an admin.") } var targetUserStruct User result := app.DB.First(&targetUserStruct, "uuid = ?", targetUUID) targetUser = &targetUserStruct if result.Error != nil { return NewWebError(returnURL, "User not found.") } } maybeMaxPlayerCount := mo.None[int]() if maxPlayerCountString, ok := maybeMaxPlayerCountString.Get(); ok { if maxPlayerCountString == "" { maybeMaxPlayerCount = mo.Some(app.Constants.MaxPlayerCountUseDefault) } else { var err error maxPlayerCount, err := strconv.Atoi(maxPlayerCountString) if err != nil { return NewWebError(returnURL, "Max player count must be an integer.") } maybeMaxPlayerCount = mo.Some(maxPlayerCount) } } _, err := app.UpdateUser( app.DB, user, // caller *targetUser, // user password, nil, // isAdmin nil, // isLocked resetAPIToken, resetMinecraftToken, preferredLanguage, maybeMaxPlayerCount.ToPointer(), ) if err != nil { var userError *UserError if errors.As(err, &userError) { return &WebError{ReturnURL: returnURL, Err: userError} } return err } app.setSuccessMessage(&c, l.Get("Changes saved.")) return c.Redirect(http.StatusSeeOther, returnURL) }) } // POST /web/update-player func FrontUpdatePlayer(app *App) func(c echo.Context) error { return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error { l := app.getLocale(&c) returnURL := getReturnURL(app, &c) playerUUID := c.FormValue("uuid") playerName := nilIfEmpty(c.FormValue("playerName")) fallbackPlayer := nilIfEmpty(c.FormValue("fallbackPlayer")) skinModel := nilIfEmpty(c.FormValue("skinModel")) skinURL := nilIfEmpty(c.FormValue("skinUrl")) deleteSkin := c.FormValue("deleteSkin") == "on" capeURL := nilIfEmpty(c.FormValue("capeUrl")) deleteCape := c.FormValue("deleteCape") == "on" var player Player result := app.DB.Preload("User").First(&player, "uuid = ?", playerUUID) if result.Error != nil { return NewWebError(returnURL, "Player not found.") } // Skin var skinReader *io.Reader skinFile, skinFileErr := c.FormFile("skinFile") if skinFileErr == nil { var err error skinHandle, err := skinFile.Open() if err != nil { return err } defer skinHandle.Close() var skinFileReader io.Reader = skinHandle skinReader = &skinFileReader } // Cape var capeReader *io.Reader capeFile, capeFileErr := c.FormFile("capeFile") if capeFileErr == nil { var err error capeHandle, err := capeFile.Open() if err != nil { return err } defer capeHandle.Close() var capeFileReader io.Reader = capeHandle capeReader = &capeFileReader } _, err := app.UpdatePlayer( user, // caller player, playerName, fallbackPlayer, skinModel, skinReader, skinURL, deleteSkin, capeReader, capeURL, deleteCape, ) if err != nil { var userError *UserError if errors.As(err, &userError) { return &WebError{ReturnURL: returnURL, Err: userError} } return err } app.setSuccessMessage(&c, l.Get("Changes saved.")) return c.Redirect(http.StatusSeeOther, returnURL) }) } // 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 user.BrowserToken = MakeNullString(nil) if err := app.DB.Save(user).Error; err != nil { return err } app.setBrowserToken(&c, "") return c.Redirect(http.StatusSeeOther, returnURL) }) } const ( ChallengeActionRegister string = "register" ChallengeActionCreatePlayer string = "create-player" ) // GET /web/create-player-challenge func FrontCreatePlayerChallenge(app *App) func(c echo.Context) error { return frontChallenge(app, ChallengeActionCreatePlayer) } // GET /web/register-challenge func FrontRegisterChallenge(app *App) func(c echo.Context) error { return frontChallenge(app, ChallengeActionRegister) } func frontChallenge(app *App, action string) func(c echo.Context) error { type challengeContext struct { baseContext User *User PlayerName string RegistrationProvider string SkinBase64 string SkinFilename string ChallengeToken string InviteCode string UseIDToken bool Action string UserUUID *string } 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 switch action { case ChallengeActionRegister: 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} } return err } if provider.Config.AllowChoosingPlayerName { playerName = c.QueryParam("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 { playerName = c.QueryParam("playerName") } case ChallengeActionCreatePlayer: playerName = c.QueryParam("playerName") userUUID = Ptr(c.QueryParam("userUuid")) } if err := app.ValidatePlayerName(playerName); err != nil { return NewWebError(returnURL, "Invalid player name: %s", err) } inviteCode := c.QueryParam("inviteCode") var challengeToken string 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: CHALLENGE_TOKEN_COOKIE_NAME, Value: challengeToken, MaxAge: BROWSER_TOKEN_AGE_SEC, Path: "/", SameSite: http.SameSiteLaxMode, HttpOnly: true, Secure: true, }) } else { challengeToken = cookie.Value } challengeSkinBytes, err := app.GetChallengeSkin(playerName, challengeToken) if err != nil { var userError *UserError if errors.As(err, &userError) { return NewWebError(returnURL, "Error: %s", userError) } return err } skinBase64 := base64.StdEncoding.EncodeToString(challengeSkinBytes) return c.Render(http.StatusOK, "challenge", challengeContext{ baseContext: app.NewBaseContext(&c), User: user, PlayerName: playerName, SkinBase64: skinBase64, SkinFilename: playerName + "-challenge.png", ChallengeToken: challengeToken, InviteCode: inviteCode, UseIDToken: useIDToken, Action: action, UserUUID: userUUID, }) }) } // POST /web/create-player func FrontCreatePlayer(app *App) func(c echo.Context) error { return withBrowserAuthentication(app, true, func(c echo.Context, caller *User) error { l := app.getLocale(&c) userUUID := c.FormValue("userUuid") playerName := c.FormValue("playerName") chosenUUID := nilIfEmpty(c.FormValue("playerUuid")) existingPlayer := c.FormValue("existingPlayer") == "on" challengeToken := nilIfEmpty(c.FormValue("challengeToken")) failureURL := getReturnURL(app, &c) player, err := app.CreatePlayer( caller, userUUID, playerName, chosenUUID, existingPlayer, challengeToken, nil, // fallbackPlayer nil, // skinModel nil, // skinReader nil, // skinURL nil, // capeReader nil, // capeURL ) if err != nil { var userError *UserError if errors.As(err, &userError) { return &WebError{ReturnURL: failureURL, Err: userError} } return err } returnURL, err := url.JoinPath(app.FrontEndURL, "web/player", player.UUID) if err != nil { return err } app.setSuccessMessage(&c, l.Get("Player created.")) return c.Redirect(http.StatusSeeOther, returnURL) }) } // 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 { l := app.getLocale(&c) useIDToken := c.FormValue("useIdToken") == "on" honeypot := c.FormValue("email") 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 := UnsetQueryParam(failureURL, "invite") if err != nil { return err } if honeypot != "" { 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} } 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.ToPointer(), PotentiallyInsecure[[]OIDCIdentitySpec]{Value: oidcIdentitySpecs}, false, // isAdmin false, // isLocked inviteCode, nil, // preferredLanguage &playerName, chosenUUID, existingPlayer, challengeToken, nil, // fallbackPlayer nil, // maxPlayerCount nil, // skinModel nil, // skinReader nil, // skinURL nil, // capeReader nil, // capeURL ) if err != nil { if err == InviteNotFoundError || err == InviteMissingError { return &WebError{ReturnURL: noInviteFailureURL, Err: err.(*UserError)} } var userError *UserError if errors.As(err, &userError) { return &WebError{ReturnURL: failureURL, Err: userError} } 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) if useIDToken { c.SetCookie(&http.Cookie{ Name: ID_TOKEN_COOKIE_NAME, Value: "", Path: "/", SameSite: http.SameSiteLaxMode, HttpOnly: true, Secure: true, }) } app.setSuccessMessage(&c, l.Get("Account created.")) return c.Redirect(http.StatusSeeOther, returnURL) } } func addDestination(url_ string, destination string) (string, error) { if destination == "" { return url_, nil } else if url_ == destination { return url_, nil } else { urlStruct, err := url.Parse(url_) if err != nil { return "", err } query := urlStruct.Query() query.Set("destination", destination) urlStruct.RawQuery = query.Encode() return urlStruct.String(), nil } } // POST /web/oidc-migrate func (app *App) FrontOIDCMigrate() func(c echo.Context) error { return func(c echo.Context) error { l := app.getLocale(&c) failureURL := getReturnURL(app, &c) username := c.FormValue("username") password := c.FormValue("password") oidcProvider, _, claims, err := app.getIDTokenCookie(&c) if err != nil { var userError *UserError if errors.As(err, &userError) { return &WebError{ReturnURL: failureURL, Err: userError} } 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 = app.CreateOIDCIdentity(&user, user.UUID, claims.Issuer, claims.Subject) if err != nil { var userError *UserError if errors.As(err, &userError) { return &WebError{ReturnURL: failureURL, Err: userError} } 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 { return err } app.setSuccessMessage(&c, l.Get("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, "Password login is not allowed. Log in via OpenID Connect instead.") } if errors.As(err, &userError) { return &WebError{ReturnURL: failureURL, Err: userError} } 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 { return err } destination := c.FormValue("destination") if destination != "" { returnURL = destination } return c.Redirect(http.StatusSeeOther, returnURL) } } // POST /web/delete-user func FrontDeleteUser(app *App) func(c echo.Context) error { return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error { l := app.getLocale(&c) returnURL := getReturnURL(app, &c) var targetUser *User targetUUID := c.FormValue("uuid") if targetUUID == "" || targetUUID == user.UUID { targetUser = user } else { if !user.IsAdmin { return NewWebError(app.FrontEndURL, "You are not an admin.") } var targetUserStruct User if err := app.DB.First(&targetUserStruct, "uuid = ?", targetUUID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return NewWebError(returnURL, "User not found.") } return err } targetUser = &targetUserStruct } err := app.DeleteUser(user, targetUser) if err != nil { return err } if targetUser == user { app.setBrowserToken(&c, "") } app.setSuccessMessage(&c, l.Get("Account deleted")) return c.Redirect(http.StatusSeeOther, returnURL) }) } // POST /web/delete-player func FrontDeletePlayer(app *App) func(c echo.Context) error { return withBrowserAuthentication(app, true, func(c echo.Context, user *User) error { l := app.getLocale(&c) returnURL := getReturnURL(app, &c) playerUUID := c.FormValue("uuid") var player Player result := app.DB.Preload("User").First(&player, "uuid = ?", playerUUID) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return NewWebError(returnURL, "Player not found.") } return result.Error } err := app.DeletePlayer(user, &player) if err != nil { var userError *UserError if errors.As(err, &userError) { return &WebError{ReturnURL: returnURL, Err: userError} } return err } app.setSuccessMessage(&c, l.Get("Player ā€œ%sā€ deleted", player.Name)) return c.Redirect(http.StatusSeeOther, returnURL) }) }