From fbc8f9d45a078eb27d3c2dd7c46b6df77639bb0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D1=85=D0=BB=D0=B8=D1=84=D0=B8?= <70767436+xllifi@users.noreply.github.com> Date: Sun, 16 Feb 2025 12:43:02 +1000 Subject: [PATCH] APIs for login and register (#136) * APIs for login and register * return 403 instead of 423 if account is locked * add login API route to ratelimiter * APILogin remove browser token gen & return, give API token instead * generalize login logic * remove transient user handling * remove APIRegisterChallenge due to unnecessary * remove honeypot from APIRegister * APIRegister remove browser token gen & return, give API token instead * add register API route to ratelimiter * add missing API godoc * Clean up app.Login error handling * Fix rate-limit errors for API routes * Deduplicate APICreateUser and APIRegister * Rate-limit all non-admin unsafe API requests * APILogin test * Make SetIsLocked write to the tx * Add CORSAllowOrigins option * Assert SetIsLocked without err variable * Fix and test API rate limiting --------- Co-authored-by: Evan Goode --- api.go | 142 ++++++++++++++++++++++++++++++++----------- api_test.go | 108 ++++++++++++++++++++++++++++++-- common.go | 7 +++ config.go | 2 + doc/configuration.md | 1 + front.go | 55 +++++++---------- front_test.go | 8 +-- main.go | 59 +++++++++++------- swagger.json | 127 +++++++++++++++++++++++++++++++++++++- user.go | 31 ++++++++++ 10 files changed, 442 insertions(+), 98 deletions(-) diff --git a/api.go b/api.go index 223c505..7c3ae5a 100644 --- a/api.go +++ b/api.go @@ -109,34 +109,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 (.*)$") + tokenMatch := bearerExp.FindStringSubmatch(authorizationHeader) + if tokenMatch == nil || len(tokenMatch) < 2 { + return mo.None[User](), NewUserError(http.StatusUnauthorized, "Malformed Authorization header") + } + token := tokenMatch[1] + + 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") + } + return mo.None[User](), err + } + + 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 { - 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 echo.NewHTTPError(http.StatusUnauthorized, "Malformed Authorization header") - } - token := tokenMatch[1] - - 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") - } + 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()) } } @@ -144,7 +155,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) } @@ -280,10 +291,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 @@ -336,6 +348,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`. @@ -350,6 +363,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 @@ -358,14 +376,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 @@ -403,7 +422,6 @@ func (app *App) APICreateUser() func(c echo.Context) error { capeReader, req.CapeURL, ) - if err != nil { return err } @@ -412,7 +430,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) }) } @@ -496,10 +519,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 @@ -573,10 +597,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 @@ -602,7 +627,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 { @@ -691,7 +716,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 @@ -766,10 +791,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 @@ -840,7 +866,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 { @@ -966,7 +992,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() @@ -986,3 +1012,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/api_test.go b/api_test.go index 3fe5b0f..ba80322 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) } } @@ -232,8 +246,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) @@ -253,8 +268,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) @@ -664,3 +680,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/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 d99959d..42b118a 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 @@ -117,6 +118,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 a586d72..1364414 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -102,3 +102,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/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 diff --git a/main.go b/main.go index 45b3e7b..44894b0 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 if app.Config.EnableWebFrontEnd { @@ -193,6 +209,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 2e25e45..5b68bf1 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, @@ -392,6 +420,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 }