mirror of
https://github.com/unmojang/drasl.git
synced 2025-08-03 10:56:06 -04:00
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:
parent
71c5ebf4bd
commit
fbc8f9d45a
142
api.go
142
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})
|
||||
})
|
||||
}
|
||||
|
108
api_test.go
108
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))
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -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: `[]`.
|
||||
|
55
front.go
55
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
|
||||
|
@ -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
59
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())
|
||||
|
127
swagger.json
127
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": {
|
||||
|
31
user.go
31
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
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user