Repository: unmojang/drasl (language: go)

Issue:
Allow for custom frontends
I don't want my users to be able to access Drasl's frontend. Instead, I want them to use my own frontend for registration and account management


Discussion / hints:
After further investigation it seems like there's currently no way to register or login into Drasl account without a frontend. So this request is now asking for a frontend-less way to register and authenticate in Drasl.
My suggestion is to build your own authentication and authorization service, create and admin account of Drasl for your service, and access drasl using admin token. This is what I'm doing right now. Nevertheless, I do think that an authentication and authorization support for api will be much helpful.
> build your own authentication and authorization service, create and admin account of Drasl for your service, and access drasl using admin token. 

Seems overkill to me. Much better solution would be to just submit a PR to Drasl.
I'm gonna start doing that in following days. Don't expect much though, my only experience with Golang was writing a 50-line HTTP server.
I'm not opposed to exposing more functionality in the API to support custom frontends, including adding an endpoint to validate user passwords. The "right way" to do this might be to have users on alternative front ends "Sign in with Drasl" via OIDC. But then the user still sees the login page from the Drasl web frontend, which is clearly not wanted for this use case.

https://github.com/unmojang/drasl/pull/127 might complicate things here. If OIDC registration is wanted with the Drasl frontend turned off, the alternative frontend is going to have to handle OIDC registration itself.

If we want full parity, it will be a lot of effort to go through everything the Drasl front end does and making sure the API can do the same. Some big items are:

1. Adding a `caller` field to API routes so that an admin API user (i.e. the custom front end) can operate on behalf of other users with the correct permissions
2. Exposing the Drasl config via the API

The web frontend is deceptively complex, mostly due to the number of possible config options. But a custom frontend wouldn't necessarily need to support all possible configs, so for some use cases it makes sense.

I would also like to make the Drasl front end more easy to customize, e.g. via https://github.com/unmojang/drasl/pull/123#issuecomment-2564391434 and supporting user stylesheets. Maybe that should take priority over this?

> Seems overkill to me. Much better solution would be to just submit a PR to Drasl.

Note: you should branch off the `next` branch since the new upcoming version of the API has significant changes. You can see the API docs for the `next` branch by going to https://doc.drasl.unmojang.org and entering `https://raw.githubusercontent.com/unmojang/drasl/next/swagger.json` in the URL field at the top.
As far as an option to disable the front end, I created https://github.com/unmojang/drasl/issues/132 since that'll be easy.
I've finally got to working on this and it seems like we have a bit of a misunderstanding. My idea is to simply make all `/web/...` POST routes available as JSON requests (new routes will be something like `DRASL_API_PREFIX+"/user/..."`).

This way admin token is not used for non-admin users and a custom frontend won't be able to do something unsafe unless its developer explicitly wants to do so. 

Does this sound good to you?

Also it seems like I missed the part where I can just use forms, but those are much less comfortable than JSONs.
So I went and started looking into what actually needs to be implemented. Turns out, only registration and login API endpoints are missing. And maybe some nicer docs.

Fix patch:
diff --git a/api.go b/api.go
index 3406893..024c5e3 100644
--- a/api.go
+++ b/api.go
@@ -110,34 +110,45 @@ func IsDeprecatedAPIPath(path_ string) mo.Option[int] {
 	return mo.None[int]()
 }
 
-func (app *App) withAPIToken(f func(c echo.Context, user *User) error) func(c echo.Context) error {
+func (app *App) APIRequestToMaybeUser(c echo.Context) (mo.Option[User], error) {
+	authorizationHeader := c.Request().Header.Get("Authorization")
+	if authorizationHeader == "" {
+		return mo.None[User](), nil
+	}
+
 	bearerExp := regexp.MustCompile("^Bearer (.*)$")
 
-	return func(c echo.Context) error {
-		authorizationHeader := c.Request().Header.Get("Authorization")
-		if authorizationHeader == "" {
-			return echo.NewHTTPError(http.StatusUnauthorized, "Missing 'Bearer: abcdef' Authorization header")
-		}
+	tokenMatch := bearerExp.FindStringSubmatch(authorizationHeader)
+	if tokenMatch == nil || len(tokenMatch) < 2 {
+		return mo.None[User](), NewUserError(http.StatusUnauthorized, "Malformed Authorization header")
+	}
+	token := tokenMatch[1]
 
-		tokenMatch := bearerExp.FindStringSubmatch(authorizationHeader)
-		if tokenMatch == nil || len(tokenMatch) < 2 {
-			return echo.NewHTTPError(http.StatusUnauthorized, "Malformed Authorization header")
+	var user User
+	if err := app.DB.First(&user, "api_token = ?", token).Error; err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return mo.None[User](), NewUserError(http.StatusUnauthorized, "Unknown API token")
 		}
-		token := tokenMatch[1]
+		return mo.None[User](), err
+	}
 
-		var user User
-		if err := app.DB.First(&user, "api_token = ?", token).Error; err != nil {
-			if errors.Is(err, gorm.ErrRecordNotFound) {
-				return echo.NewHTTPError(http.StatusUnauthorized, "Unknown API token")
-			}
+	if user.IsLocked {
+		return mo.None[User](), NewUserError(http.StatusForbidden, "Account is locked")
+	}
+
+	return mo.Some(user), nil
+}
+
+func (app *App) withAPIToken(requireLogin bool, f func(c echo.Context, user *User) error) func(c echo.Context) error {
+	return func(c echo.Context) error {
+		maybeUser, err := app.APIRequestToMaybeUser(c)
+		if err != nil {
 			return err
 		}
-
-		if user.IsLocked {
-			return echo.NewHTTPError(http.StatusForbidden, "Account is locked")
+		if maybeUser.IsAbsent() && requireLogin {
+			return NewUserError(http.StatusUnauthorized, "Route requires authorization. Missing 'Bearer: abcdef' Authorization header")
 		}
-
-		return f(c, &user)
+		return f(c, maybeUser.ToPointer())
 	}
 }
 
@@ -145,7 +156,7 @@ func (app *App) withAPITokenAdmin(f func(c echo.Context, user *User) error) func
 	notAnAdminBlob := Unwrap(json.Marshal(map[string]string{
 		"error": "You are not an admin.",
 	}))
-	return app.withAPIToken(func(c echo.Context, user *User) error {
+	return app.withAPIToken(true, func(c echo.Context, user *User) error {
 		if !user.IsAdmin {
 			return c.JSONBlob(http.StatusForbidden, notAnAdminBlob)
 		}
@@ -281,10 +292,11 @@ func (app *App) APIGetUsers() func(c echo.Context) error {
 //	@Produce		json
 //	@Success		200	{object}	APIUser
 //	@Failure		403	{object}	APIError
+//	@Failure		429	{object}	APIError
 //	@Failure		500	{object}	APIError
 //	@Router			/drasl/api/v2/user [get]
 func (app *App) APIGetSelf() func(c echo.Context) error {
-	return app.withAPIToken(func(c echo.Context, user *User) error {
+	return app.withAPIToken(true, func(c echo.Context, user *User) error {
 		apiUser, err := app.userToAPIUser(user)
 		if err != nil {
 			return err
@@ -337,6 +349,7 @@ type APICreateUserRequest struct {
 	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`.
@@ -351,6 +364,11 @@ type APICreateUserRequest struct {
 	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 {
+	User     APIUser `json:"user"`                                                // The new user.
+	APIToken *string `json:"apiToken,omitempty" example:"Bq608AtLeG7emJOdvXHYxL"` // An API token for the new user, if requested.
+}
+
 // APICreateUser godoc
 //
 //	@Summary		Create a new user
@@ -359,14 +377,15 @@ type APICreateUserRequest struct {
 //	@Accept			json
 //	@Produce		json
 //	@Param			APICreateUserRequest	body		APICreateUserRequest	true	"Properties of the new user"
-//	@Success		200						{object}	APIUser
+//	@Success		200						{object}	APICreateUserResponse
 //	@Failure		400						{object}	APIError
 //	@Failure		401						{object}	APIError
 //	@Failure		403						{object}	APIError
+//	@Failure		429						{object}	APIError
 //	@Failure		500						{object}	APIError
 //	@Router			/drasl/api/v2/users [post]
 func (app *App) APICreateUser() func(c echo.Context) error {
-	return app.withAPITokenAdmin(func(c echo.Context, caller *User) error {
+	return app.withAPIToken(false, func(c echo.Context, caller *User) error {
 		req := new(APICreateUserRequest)
 		if err := c.Bind(req); err != nil {
 			return err
@@ -404,7 +423,6 @@ func (app *App) APICreateUser() func(c echo.Context) error {
 			capeReader,
 			req.CapeURL,
 		)
-
 		if err != nil {
 			return err
 		}
@@ -413,7 +431,12 @@ func (app *App) APICreateUser() func(c echo.Context) error {
 		if err != nil {
 			return err
 		}
-		return c.JSON(http.StatusOK, apiUser)
+		var response APICreateUserResponse
+		response.User = apiUser
+		if req.RequestAPIToken {
+			response.APIToken = &user.APIToken
+		}
+		return c.JSON(http.StatusOK, response)
 	})
 }
 
@@ -497,10 +520,11 @@ func (app *App) APIUpdateUser() func(c echo.Context) error {
 //	@Failure		400						{object}	APIError
 //	@Failure		403						{object}	APIError
 //	@Failure		404						{object}	APIError
+//	@Failure		429						{object}	APIError
 //	@Failure		500						{object}	APIError
 //	@Router			/drasl/api/v2/user [patch]
 func (app *App) APIUpdateSelf() func(c echo.Context) error {
-	return app.withAPIToken(func(c echo.Context, user *User) error {
+	return app.withAPIToken(true, func(c echo.Context, user *User) error {
 		req := new(APIUpdateUserRequest)
 		if err := c.Bind(req); err != nil {
 			return err
@@ -574,10 +598,11 @@ func (app *App) APIDeleteUser() func(c echo.Context) error {
 //	@Produce		json
 //	@Success		204
 //	@Failure		401	{object}	APIError
+//	@Failure		429	{object}	APIError
 //	@Failure		500	{object}	APIError
 //	@Router			/drasl/api/v2/user [delete]
 func (app *App) APIDeleteSelf() func(c echo.Context) error {
-	return app.withAPIToken(func(c echo.Context, user *User) error {
+	return app.withAPIToken(true, func(c echo.Context, user *User) error {
 		err := app.DeleteUser(user, user)
 		if err != nil {
 			return err
@@ -603,7 +628,7 @@ func (app *App) APIDeleteSelf() func(c echo.Context) error {
 //	@Failure		500		{object}	APIError
 //	@Router			/drasl/api/v2/players/{uuid} [get]
 func (app *App) APIGetPlayer() func(c echo.Context) error {
-	return app.withAPIToken(func(c echo.Context, user *User) error {
+	return app.withAPIToken(true, func(c echo.Context, user *User) error {
 		uuid_ := c.Param("uuid")
 		_, err := uuid.Parse(uuid_)
 		if err != nil {
@@ -692,7 +717,7 @@ type APICreatePlayerRequest struct {
 //	@Failure		500						{object}	APIError
 //	@Router			/drasl/api/v2/players [post]
 func (app *App) APICreatePlayer() func(c echo.Context) error {
-	return app.withAPIToken(func(c echo.Context, caller *User) error {
+	return app.withAPIToken(true, func(c echo.Context, caller *User) error {
 		req := new(APICreatePlayerRequest)
 		if err := c.Bind(req); err != nil {
 			return err
@@ -767,10 +792,11 @@ type APIUpdatePlayerRequest struct {
 //	@Failure		400						{object}	APIError
 //	@Failure		403						{object}	APIError
 //	@Failure		404						{object}	APIError
+//	@Failure		429						{object}	APIError
 //	@Failure		500						{object}	APIError
 //	@Router			/drasl/api/v2/players/{uuid} [patch]
 func (app *App) APIUpdatePlayer() func(c echo.Context) error {
-	return app.withAPIToken(func(c echo.Context, caller *User) error {
+	return app.withAPIToken(true, func(c echo.Context, caller *User) error {
 		req := new(APIUpdatePlayerRequest)
 		if err := c.Bind(req); err != nil {
 			return err
@@ -841,7 +867,7 @@ func (app *App) APIUpdatePlayer() func(c echo.Context) error {
 //	@Failure		500	{object}	APIError
 //	@Router			/drasl/api/v2/players/{uuid} [delete]
 func (app *App) APIDeletePlayer() func(c echo.Context) error {
-	return app.withAPIToken(func(c echo.Context, user *User) error {
+	return app.withAPIToken(true, func(c echo.Context, user *User) error {
 		uuid_ := c.Param("uuid")
 		_, err := uuid.Parse(uuid_)
 		if err != nil {
@@ -967,7 +993,7 @@ type APIChallenge struct {
 //	@Failure		500	{object}	APIError
 //	@Router			/drasl/api/v2/challenge-skin [get]
 func (app *App) APIGetChallengeSkin() func(c echo.Context) error {
-	return app.withAPIToken(func(c echo.Context, _ *User) error {
+	return app.withAPIToken(false, func(c echo.Context, _ *User) error {
 		username := c.QueryParam("username")
 
 		challengeToken, err := MakeChallengeToken()
@@ -987,3 +1013,49 @@ func (app *App) APIGetChallengeSkin() func(c echo.Context) error {
 		})
 	})
 }
+
+type APILoginResponse struct {
+	User     APIUser `json:"user"`                                   // The logged-in user
+	APIToken string  `json:"token" example:"Bq608AtLeG7emJOdvXHYxL"` // An API token for the user
+}
+
+type APILoginRequest struct {
+	Username string `json:"username" example:"Notch"`
+	Password string `json:"password" example:"hunter2"`
+}
+
+// APILogin godoc
+//
+//	@Summary		Get a token
+//	@Description	Get a token for login credentials.
+//	@Tags			users, auth
+//	@Accept			json
+//	@Produce		json
+//	@Success		200	{object}	APILoginResponse
+//	@Failure		400	{object}	APIError
+//	@Failure		401	{object}	APIError
+//	@Failure		403	{object}	APIError
+//	@Failure		429	{object}	APIError
+//	@Failure		500	{object}	APIError
+//	@Router			/drasl/api/v2/login [post]
+func (app *App) APILogin() func(c echo.Context) error {
+	return app.withAPIToken(false, func(c echo.Context, _ *User) error {
+		var req APILoginRequest
+		err := c.Bind(&req)
+		if err != nil {
+			return echo.NewHTTPError(http.StatusBadRequest, "Malformed JSON request")
+		}
+
+		user, err := app.Login(req.Username, req.Password)
+		if err != nil {
+			return err
+		}
+
+		apiUser, err := app.userToAPIUser(&user)
+		if err != nil {
+			return err
+		}
+
+		return c.JSON(http.StatusOK, APILoginResponse{User: apiUser, APIToken: user.APIToken})
+	})
+}
diff --git a/common.go b/common.go
index b24bf75..7ae3085 100644
--- a/common.go
+++ b/common.go
@@ -34,6 +34,13 @@ func (e UserError) Error() string {
 	return e.Err.Error()
 }
 
+func NewUserError(code int, message string, args ...interface{}) error {
+	return &UserError{
+		Code: code,
+		Err:  fmt.Errorf(message, args...),
+	}
+}
+
 func NewBadRequestUserError(message string, args ...interface{}) error {
 	return &UserError{
 		Code: http.StatusBadRequest,
diff --git a/config.go b/config.go
index fd10102..7286e55 100644
--- a/config.go
+++ b/config.go
@@ -68,6 +68,7 @@ type Config struct {
 	ApplicationName            string
 	BaseURL                    string
 	BodyLimit                  bodyLimitConfig
+	CORSAllowOrigins           []string
 	DataDirectory              string
 	DefaultAdmins              []string
 	DefaultPreferredLanguage   string
@@ -116,6 +117,7 @@ func DefaultConfig() Config {
 		ApplicationOwner:         "Anonymous",
 		BaseURL:                  "",
 		BodyLimit:                defaultBodyLimitConfig,
+		CORSAllowOrigins:         []string{},
 		DataDirectory:            GetDefaultDataDirectory(),
 		DefaultAdmins:            []string{},
 		DefaultPreferredLanguage: "en",
diff --git a/doc/configuration.md b/doc/configuration.md
index ff2e3a9..0bde940 100644
--- a/doc/configuration.md
+++ b/doc/configuration.md
@@ -101,3 +101,4 @@ Other available options:
 - `AllowCapes`: Allow users to upload capes. Admins can set capes regardless of this setting. Boolean. Default value: `true`.
 - `AllowTextureFromURL`: Allow users to specify a skin or cape by providing a URL to the texture file. Previously, this option was always allowed; now it is opt-in. Admins can do this regardless of this setting. Boolean. Default value: `false`.
 - `ValidPlayerNameRegex`: Regular expression (regex) that player names must match. Currently, Drasl usernames are validated using this regex too. Player names will be limited to a maximum of 16 characters no matter what. Mojang allows the characters `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_`, and by default, Drasl follows suit. Minecraft servers may misbehave if additional characters are allowed. Change to `.+` if you want to allow any player name (that is 16 characters or shorter). String. Default value: `^[a-zA-Z0-9_]+$`.
+- `CORSAllowOrigins`: List of origins that may access Drasl API routes. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin. Necessary for allowing browsers to access the Drasl API. Set to `["*"]` to allow all origins. Array of strings. Example value: `["https://front-end.example.com"]`. Default value: `[]`.
diff --git a/front.go b/front.go
index 4ca3007..7435ea4 100644
--- a/front.go
+++ b/front.go
@@ -1,7 +1,6 @@
 package main
 
 import (
-	"bytes"
 	"encoding/base64"
 	"encoding/json"
 	"errors"
@@ -117,23 +116,30 @@ func NewWebError(returnURL string, message string, args ...interface{}) error {
 
 // Set error message and redirect
 func (app *App) HandleWebError(err error, c *echo.Context) error {
-	if httpError, ok := err.(*echo.HTTPError); ok {
+	var webError *WebError
+	var userError *UserError
+	if errors.As(err, &webError) {
+		returnURL := webError.ReturnURL
+		setErrorMessage(c, webError.Error())
+		return (*c).Redirect(http.StatusSeeOther, returnURL)
+	} else if errors.As(err, &userError) {
+		returnURL := getReturnURL(app, c)
+		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 {
-				return (*c).String(httpError.Code, message)
+				returnURL := getReturnURL(app, c)
+				setErrorMessage(c, message)
+				return (*c).Redirect(http.StatusSeeOther, returnURL)
 			}
 		}
 	}
-
-	var webError *WebError
-	if errors.As(err, &webError) {
-		setErrorMessage(c, webError.Error())
-		return (*c).Redirect(http.StatusSeeOther, webError.ReturnURL)
-	}
-
 	app.LogError(err, c)
-	return (*c).String(http.StatusInternalServerError, "Internal server error")
+	returnURL := getReturnURL(app, c)
+	setErrorMessage(c, "Internal server error")
+	return (*c).Redirect(http.StatusSeeOther, returnURL)
 }
 
 func lastSuccessMessage(c *echo.Context) string {
@@ -1002,32 +1008,15 @@ func FrontLogin(app *App) func(c echo.Context) error {
 		username := c.FormValue("username")
 		password := c.FormValue("password")
 
-		if app.TransientLoginEligible(username) {
-			return NewWebError(failureURL, "Transient accounts cannot access the web interface.")
-		}
-
-		var user User
-		result := app.DB.First(&user, "username = ?", username)
-		if result.Error != nil {
-			if errors.Is(result.Error, gorm.ErrRecordNotFound) {
-				return NewWebError(failureURL, "User not found!")
-			}
-			return result.Error
-		}
-
-		if user.IsLocked {
-			return NewWebError(failureURL, "Account is locked.")
-		}
-
-		passwordHash, err := HashPassword(password, user.PasswordSalt)
+		user, err := app.Login(username, password)
 		if err != nil {
+			var userError *UserError
+			if errors.As(err, &userError) {
+				return &WebError{ReturnURL: failureURL, Err: userError.Err}
+			}
 			return err
 		}
 
-		if !bytes.Equal(passwordHash, user.PasswordHash) {
-			return NewWebError(failureURL, "Incorrect password!")
-		}
-
 		browserToken, err := RandomHex(32)
 		if err != nil {
 			return err
diff --git a/main.go b/main.go
index 2372051..40e8be1 100644
--- a/main.go
+++ b/main.go
@@ -79,34 +79,42 @@ func makeRateLimiter(app *App) echo.MiddlewareFunc {
 	requestsPerSecond := rate.Limit(app.Config.RateLimit.RequestsPerSecond)
 	return middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
 		Skipper: func(c echo.Context) bool {
-			switch c.Path() {
-			case "/",
-				"/web/create-player",
-				"/web/delete-user",
-				"/web/delete-player",
-				"/web/login",
-				"/web/logout",
-				"/web/register",
-				"/web/update-user",
-				"/web/update-player":
+			path_ := c.Path()
+			switch GetPathType(path_) {
+			case PathTypeWeb:
+				switch path_ {
+				case "/",
+					"/web/create-player",
+					"/web/delete-user",
+					"/web/delete-player",
+					"/web/login",
+					"/web/logout",
+					"/web/register",
+					"/web/update-user",
+					"/web/update-player":
+					return false
+				default:
+					return true
+				}
+			case PathTypeAPI:
+				// Skip rate-limiting API requests if they are an admin. TODO:
+				// this checks the database twice: once here, and once in
+				// withAPIToken. A better way might be to use echo middleware
+				// for API authentication and run the authentication middleware
+				// before the rate-limiting middleware.
+				maybeUser, err := app.APIRequestToMaybeUser(c)
+				if user, ok := maybeUser.Get(); err == nil && ok {
+					return user.IsAdmin
+				}
 				return false
 			default:
 				return true
 			}
 		},
+		// TODO write an IdentifierExtractor per authlib-injector spec "Limits should be placed on users, not client IPs"
 		Store: middleware.NewRateLimiterMemoryStore(requestsPerSecond),
 		DenyHandler: func(c echo.Context, identifier string, err error) error {
-			path := c.Path()
-			if GetPathType(path) == PathTypeYggdrasil {
-				return &echo.HTTPError{
-					Code:     http.StatusTooManyRequests,
-					Message:  "Too many requests. Try again later.",
-					Internal: err,
-				}
-			} else {
-				setErrorMessage(&c, "Too many requests. Try again later.")
-				return c.Redirect(http.StatusSeeOther, getReturnURL(app, &c))
-			}
+			return NewUserError(http.StatusTooManyRequests, "Too many requests. Try again later.")
 		},
 	})
 }
@@ -142,6 +150,14 @@ func (app *App) MakeServer() *echo.Echo {
 		limit := fmt.Sprintf("%dKIB", app.Config.BodyLimit.SizeLimitKiB)
 		e.Use(middleware.BodyLimit(limit))
 	}
+	if len(app.Config.CORSAllowOrigins) > 0 {
+		e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
+			AllowOrigins: app.Config.CORSAllowOrigins,
+			Skipper: func(c echo.Context) bool {
+				return GetPathType(c.Path()) != PathTypeAPI
+			},
+		}))
+	}
 
 	// Front
 	t := NewTemplate(app)
@@ -191,6 +207,7 @@ 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+"/players", app.APICreatePlayer())
 	e.POST(DRASL_API_PREFIX+"/users", app.APICreateUser())
diff --git a/swagger.json b/swagger.json
index d41d978..a535de5 100644
--- a/swagger.json
+++ b/swagger.json
@@ -162,6 +162,60 @@
                 }
             }
         },
+        "/drasl/api/v2/login": {
+            "post": {
+                "description": "Get a token for login credentials.",
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "users",
+                    "auth"
+                ],
+                "summary": "Get a token",
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/main.APILoginResponse"
+                        }
+                    },
+                    "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"
+                        }
+                    },
+                    "429": {
+                        "description": "Too Many Requests",
+                        "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.",
@@ -423,6 +477,12 @@
                             "$ref": "#/definitions/main.APIError"
                         }
                     },
+                    "429": {
+                        "description": "Too Many Requests",
+                        "schema": {
+                            "$ref": "#/definitions/main.APIError"
+                        }
+                    },
                     "500": {
                         "description": "Internal Server Error",
                         "schema": {
@@ -458,6 +518,12 @@
                             "$ref": "#/definitions/main.APIError"
                         }
                     },
+                    "429": {
+                        "description": "Too Many Requests",
+                        "schema": {
+                            "$ref": "#/definitions/main.APIError"
+                        }
+                    },
                     "500": {
                         "description": "Internal Server Error",
                         "schema": {
@@ -488,6 +554,12 @@
                             "$ref": "#/definitions/main.APIError"
                         }
                     },
+                    "429": {
+                        "description": "Too Many Requests",
+                        "schema": {
+                            "$ref": "#/definitions/main.APIError"
+                        }
+                    },
                     "500": {
                         "description": "Internal Server Error",
                         "schema": {
@@ -544,6 +616,12 @@
                             "$ref": "#/definitions/main.APIError"
                         }
                     },
+                    "429": {
+                        "description": "Too Many Requests",
+                        "schema": {
+                            "$ref": "#/definitions/main.APIError"
+                        }
+                    },
                     "500": {
                         "description": "Internal Server Error",
                         "schema": {
@@ -623,7 +701,7 @@
                     "200": {
                         "description": "OK",
                         "schema": {
-                            "$ref": "#/definitions/main.APIUser"
+                            "$ref": "#/definitions/main.APICreateUserResponse"
                         }
                     },
                     "400": {
@@ -644,6 +722,12 @@
                             "$ref": "#/definitions/main.APIError"
                         }
                     },
+                    "429": {
+                        "description": "Too Many Requests",
+                        "schema": {
+                            "$ref": "#/definitions/main.APIError"
+                        }
+                    },
                     "500": {
                         "description": "Internal Server Error",
                         "schema": {
@@ -963,6 +1047,11 @@
                     "type": "string",
                     "example": "en"
                 },
+                "requestApiToken": {
+                    "description": "Whether to include an API token for the user in the response",
+                    "type": "boolean",
+                    "example": true
+                },
                 "skinBase64": {
                     "description": "Optional. Base64-encoded skin PNG. Example value truncated for brevity. Do not specify both `skinBase64` and `skinUrl`.",
                     "type": "string",
@@ -985,6 +1074,24 @@
                 }
             }
         },
+        "main.APICreateUserResponse": {
+            "type": "object",
+            "properties": {
+                "apiToken": {
+                    "description": "An API token for the new user, if requested.",
+                    "type": "string",
+                    "example": "Bq608AtLeG7emJOdvXHYxL"
+                },
+                "user": {
+                    "description": "The new user.",
+                    "allOf": [
+                        {
+                            "$ref": "#/definitions/main.APIUser"
+                        }
+                    ]
+                }
+            }
+        },
         "main.APIError": {
             "type": "object",
             "properties": {
@@ -1014,6 +1121,24 @@
                 }
             }
         },
+        "main.APILoginResponse": {
+            "type": "object",
+            "properties": {
+                "token": {
+                    "description": "An API token for the user",
+                    "type": "string",
+                    "example": "Bq608AtLeG7emJOdvXHYxL"
+                },
+                "user": {
+                    "description": "The logged-in user",
+                    "allOf": [
+                        {
+                            "$ref": "#/definitions/main.APIUser"
+                        }
+                    ]
+                }
+            }
+        },
         "main.APIPlayer": {
             "type": "object",
             "properties": {
diff --git a/user.go b/user.go
index f887e02..e2ed5b9 100644
--- a/user.go
+++ b/user.go
@@ -1,11 +1,13 @@
 package main
 
 import (
+	"bytes"
 	"crypto/rand"
 	"errors"
 	"github.com/google/uuid"
 	"gorm.io/gorm"
 	"io"
+	"net/http"
 	"time"
 )
 
@@ -285,6 +287,32 @@ func (app *App) CreateUser(
 	return user, nil
 }
 
+func (app *App) Login(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
+	}
+
+	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.")
+	}
+
+	if user.IsLocked {
+		return User{}, NewForbiddenUserError("User is locked.")
+	}
+
+	return user, nil
+}
+
 func (app *App) UpdateUser(
 	db *gorm.DB,
 	caller *User,
@@ -389,6 +417,9 @@ func (app *App) SetIsLocked(db *gorm.DB, user *User, isLocked bool) error {
 			return err
 		}
 	}
+	if err := db.Save(user).Error; err != nil {
+		return err
+	}
 	return nil
 }
 


Test patch:
diff --git a/api_test.go b/api_test.go
index 67eac35..c94b10b 100644
--- a/api_test.go
+++ b/api_test.go
@@ -41,6 +41,20 @@ func TestAPI(t *testing.T) {
 		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)
+		t.Run("Test POST /drasl/api/vX/login", ts.testAPILogin)
+	}
+	{
+		ts := &TestSuite{}
+		config := testConfig()
+		config.RateLimit = rateLimitConfig{
+			Enable:            true,
+			RequestsPerSecond: 2,
+		}
+		config.DefaultAdmins = []string{"admin"}
+		ts.Setup(config)
+		defer ts.Teardown()
+
+		t.Run("Test API rate limiting", ts.testAPIRateLimit)
 	}
 }
 
@@ -226,8 +240,9 @@ func (ts *TestSuite) testAPICreateUser(t *testing.T) {
 
 		rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users", payload, nil, &admin.APIToken)
 		assert.Equal(t, http.StatusOK, rec.Code)
-		var createdAPIUser APIUser
-		assert.Nil(t, json.NewDecoder(rec.Body).Decode(&createdAPIUser))
+		var apiCreateUserResponse APICreateUserResponse
+		assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiCreateUserResponse))
+		createdAPIUser := apiCreateUserResponse.User
 		assert.Equal(t, createdUsername, createdAPIUser.Username)
 		assert.Equal(t, 1, len(createdAPIUser.Players))
 		assert.Nil(t, createdAPIUser.Players[0].SkinURL)
@@ -247,8 +262,9 @@ func (ts *TestSuite) testAPICreateUser(t *testing.T) {
 
 		rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/users", payload, nil, &admin.APIToken)
 		assert.Equal(t, http.StatusOK, rec.Code)
-		var createdAPIUser APIUser
-		assert.Nil(t, json.NewDecoder(rec.Body).Decode(&createdAPIUser))
+		var apiCreateUserResponse APICreateUserResponse
+		assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiCreateUserResponse))
+		createdAPIUser := apiCreateUserResponse.User
 		assert.Equal(t, createdUsername, createdAPIUser.Username)
 		assert.Equal(t, 1, len(createdAPIUser.Players))
 		assert.NotEqual(t, "", createdAPIUser.Players[0].SkinURL)
@@ -658,3 +674,87 @@ func (ts *TestSuite) testAPICreateInvite(t *testing.T) {
 		assert.Nil(t, ts.App.DB.Delete(invite).Error)
 	}
 }
+
+func (ts *TestSuite) testAPILogin(t *testing.T) {
+	username := "user"
+	user, _ := ts.CreateTestUser(t, ts.App, ts.Server, username)
+
+	{
+		// Correct credentials should get an HTTP 200 and an API token
+		rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/login", APILoginRequest{
+			Username: username,
+			Password: TEST_PASSWORD,
+		}, nil, nil)
+		assert.Equal(t, http.StatusOK, rec.Code)
+		var jsonRec APILoginResponse
+		assert.Nil(t, json.NewDecoder(rec.Body).Decode(&jsonRec))
+		assert.NotNil(t, jsonRec.APIToken)
+	}
+	{
+		// Username of nonexistent user should return HTTP 401 and "User not found." message
+		rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/login", APILoginRequest{
+			Username: "user1",
+			Password: TEST_PASSWORD,
+		}, nil, nil)
+		assert.Equal(t, http.StatusUnauthorized, rec.Code)
+		var apiErr APIError
+		assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiErr))
+		assert.Equal(t, "User not found.", apiErr.Message)
+	}
+	{
+		// Incorrect password should return HTTP 401 and "Incorrect password." message
+		rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/login", APILoginRequest{
+			Username: username,
+			Password: "password1",
+		}, nil, nil)
+		assert.Equal(t, http.StatusUnauthorized, rec.Code)
+		var apiErr APIError
+		assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiErr))
+		assert.Equal(t, "Incorrect password.", apiErr.Message)
+	}
+	{
+		// Locked user should return HTTP 403 and "User is locked." message
+		assert.Nil(t, ts.App.SetIsLocked(ts.App.DB, user, true))
+		rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/login", APILoginRequest{
+			Username: username,
+			Password: TEST_PASSWORD,
+		}, nil, nil)
+		assert.Equal(t, http.StatusForbidden, rec.Code)
+		var apiErr APIError
+		assert.Nil(t, json.NewDecoder(rec.Body).Decode(&apiErr))
+		assert.Equal(t, "User is locked.", apiErr.Message)
+	}
+
+	assert.Nil(t, ts.App.DeleteUser(&GOD, user))
+}
+
+func (ts *TestSuite) testAPIRateLimit(t *testing.T) {
+	payload := APILoginRequest{
+		Username: "nonexistent",
+		Password: "password",
+	}
+	// First two requests should get StatusUnauthorized
+	rec := ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/login", payload, nil, nil)
+	assert.Equal(t, http.StatusUnauthorized, rec.Code)
+	rec = ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/login", payload, nil, nil)
+	assert.Equal(t, http.StatusUnauthorized, rec.Code)
+	// After rate limit exceeded, unauthenticated request should get StatusTooManyRequests
+	rec = ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/login", payload, nil, nil)
+	assert.Equal(t, http.StatusTooManyRequests, rec.Code)
+
+	// We have to create the users down here since CreateTestUser hits the
+	// rate-limit counter...
+	admin, _ := ts.CreateTestUser(t, ts.App, ts.Server, "admin")
+	assert.True(t, admin.IsAdmin)
+	user, _ := ts.CreateTestUser(t, ts.App, ts.Server, "user")
+
+	// Admins should not be rate-limited
+	rec = ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/login", payload, nil, &admin.APIToken)
+	assert.Equal(t, http.StatusUnauthorized, rec.Code)
+	// Regular users should be rate-limited
+	rec = ts.PostJSON(t, ts.Server, DRASL_API_PREFIX+"/login", payload, nil, &user.APIToken)
+	assert.Equal(t, http.StatusTooManyRequests, rec.Code)
+
+	assert.Nil(t, ts.App.DeleteUser(&GOD, admin))
+	assert.Nil(t, ts.App.DeleteUser(&GOD, user))
+}
diff --git a/front_test.go b/front_test.go
index ff4c949..880908a 100644
--- a/front_test.go
+++ b/front_test.go
@@ -299,9 +299,9 @@ func (ts *TestSuite) testRateLimit(t *testing.T) {
 	// Login should fail the first time due to missing account, then
 	// soon get rate-limited
 	rec := ts.PostForm(t, ts.Server, "/web/login", form, nil, nil)
-	ts.loginShouldFail(t, rec, "User not found!")
+	ts.loginShouldFail(t, rec, "User not found.")
 	rec = ts.PostForm(t, ts.Server, "/web/login", form, nil, nil)
-	ts.loginShouldFail(t, rec, "User not found!")
+	ts.loginShouldFail(t, rec, "User not found.")
 	rec = ts.PostForm(t, ts.Server, "/web/login", form, nil, nil)
 	ts.loginShouldFail(t, rec, "Too many requests. Try again later.")
 
@@ -318,7 +318,7 @@ func (ts *TestSuite) testBodyLimit(t *testing.T) {
 	form := url.Values{}
 	form.Set("bogus", Unwrap(RandomHex(2048)))
 	rec := ts.PostForm(t, ts.Server, "/web/login", form, nil, nil)
-	assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
+	assert.Equal(t, "Request Entity Too Large", getErrorMessage(rec))
 }
 
 func (ts *TestSuite) testRegistrationNewPlayer(t *testing.T) {
@@ -777,7 +777,7 @@ func (ts *TestSuite) testLoginLogout(t *testing.T) {
 		form.Set("username", username)
 		form.Set("password", "wrong password")
 		rec := ts.PostForm(t, ts.Server, "/web/login", form, nil, nil)
-		ts.loginShouldFail(t, rec, "Incorrect password!")
+		ts.loginShouldFail(t, rec, "Incorrect password.")
 	}
 	{
 		// GET /web/user without valid BrowserToken should fail