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 <mail@evangoo.de>
This commit is contained in:
хлифи 2025-02-16 12:43:02 +10:00 committed by GitHub
parent 71c5ebf4bd
commit fbc8f9d45a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 442 additions and 98 deletions

142
api.go
View File

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

View File

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

View File

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

View File

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

View File

@ -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: `[]`.

View File

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

View File

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

59
main.go
View File

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

View File

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

31
user.go
View File

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