satellite/console, web/satellite: limit failed login attempts
Added account locking on 3 or more login attempts. Includes both password and MFA failed attempts on login. Unlock account on successful password reset. Change-Id: If4899b40ab4a77d531c1f18bfe22cee2cffa72e0
This commit is contained in:
parent
ca6a0fc844
commit
dedccbd2e4
@ -93,7 +93,7 @@ func (a *Auth) Token(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := a.service.Token(ctx, tokenRequest)
|
||||
if err != nil {
|
||||
if console.ErrMFAMissing.Has(err) {
|
||||
serveCustomJSONError(a.log, w, 200, err, a.getUserErrorMessage(err))
|
||||
serveCustomJSONError(a.log, w, http.StatusOK, err, a.getUserErrorMessage(err))
|
||||
} else {
|
||||
a.log.Info("Error authenticating token request", zap.String("email", tokenRequest.Email), zap.Error(ErrAuthAPI.Wrap(err)))
|
||||
a.serveJSONError(w, err)
|
||||
@ -759,7 +759,7 @@ func (a *Auth) getStatusCode(err error) int {
|
||||
switch {
|
||||
case console.ErrValidation.Has(err), console.ErrRecaptcha.Has(err), console.ErrMFAMissing.Has(err):
|
||||
return http.StatusBadRequest
|
||||
case console.ErrUnauthorized.Has(err), console.ErrRecoveryToken.Has(err), console.ErrLoginCredentials.Has(err):
|
||||
case console.ErrUnauthorized.Has(err), console.ErrRecoveryToken.Has(err), console.ErrLoginCredentials.Has(err), console.ErrLoginPassword.Has(err), console.ErrLockedAccount.Has(err):
|
||||
return http.StatusUnauthorized
|
||||
case console.ErrEmailUsed.Has(err), console.ErrMFAConflict.Has(err):
|
||||
return http.StatusConflict
|
||||
@ -791,11 +791,15 @@ func (a *Auth) getUserErrorMessage(err error) string {
|
||||
case console.ErrMFAConflict.Has(err):
|
||||
return "Expected either passcode or recovery code, but got both"
|
||||
case console.ErrMFAPasscode.Has(err):
|
||||
return "The MFA passcode is not valid or has expired"
|
||||
return "The MFA passcode is not valid or has expired. You have just used up one of your login attempts"
|
||||
case console.ErrMFARecoveryCode.Has(err):
|
||||
return "The MFA recovery code is not valid or has been previously used"
|
||||
return "The MFA recovery code is not valid or has been previously used. You have just used up one of your login attempts"
|
||||
case console.ErrLoginCredentials.Has(err):
|
||||
return "Your login credentials are incorrect, please try again"
|
||||
case console.ErrLoginPassword.Has(err):
|
||||
return "Your login credentials are incorrect. You have just used up one of your login attempts"
|
||||
case console.ErrLockedAccount.Has(err):
|
||||
return err.Error()
|
||||
case errors.Is(err, errNotImplemented):
|
||||
return "The server is incapable of fulfilling the request"
|
||||
default:
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"sort"
|
||||
@ -50,6 +51,8 @@ const (
|
||||
emailNotFoundErrMsg = "There are no users with the specified email"
|
||||
passwordRecoveryTokenIsExpiredErrMsg = "Your password recovery link has expired, please request another one"
|
||||
credentialsErrMsg = "Your login credentials are incorrect, please try again"
|
||||
lockedAccountErrMsg = "Your account is locked, please try again later"
|
||||
lockedAccountWithResultErrMsg = "Your login credentials are incorrect, your account is locked again"
|
||||
passwordIncorrectErrMsg = "Your password needs at least %d characters long"
|
||||
projectOwnerDeletionForbiddenErrMsg = "%s is a project owner and can not be deleted"
|
||||
apiKeyWithNameExistsErrMsg = "An API Key with this name already exists in this project, please use a different name"
|
||||
@ -80,6 +83,12 @@ var (
|
||||
// ErrLoginCredentials occurs when provided invalid login credentials.
|
||||
ErrLoginCredentials = errs.Class("login credentials")
|
||||
|
||||
// ErrLoginPassword occurs when provided invalid login password.
|
||||
ErrLoginPassword = errs.Class("login password")
|
||||
|
||||
// ErrLockedAccount occurs when user's account is locked.
|
||||
ErrLockedAccount = errs.Class("locked")
|
||||
|
||||
// ErrEmailUsed is error type that occurs on repeating auth attempts with email.
|
||||
ErrEmailUsed = errs.Class("email used")
|
||||
|
||||
@ -133,13 +142,15 @@ func init() {
|
||||
|
||||
// Config keeps track of core console service configuration parameters.
|
||||
type Config struct {
|
||||
PasswordCost int `help:"password hashing cost (0=automatic)" testDefault:"4" default:"0"`
|
||||
OpenRegistrationEnabled bool `help:"enable open registration" default:"false" testDefault:"true"`
|
||||
DefaultProjectLimit int `help:"default project limits for users" default:"1" testDefault:"5"`
|
||||
TokenExpirationTime time.Duration `help:"expiration time for auth tokens, account recovery tokens, and activation tokens" default:"24h"`
|
||||
AsOfSystemTimeDuration time.Duration `help:"default duration for AS OF SYSTEM TIME" devDefault:"-5m" releaseDefault:"-5m" testDefault:"0"`
|
||||
UsageLimits UsageLimitsConfig
|
||||
Recaptcha RecaptchaConfig
|
||||
PasswordCost int `help:"password hashing cost (0=automatic)" testDefault:"4" default:"0"`
|
||||
OpenRegistrationEnabled bool `help:"enable open registration" default:"false" testDefault:"true"`
|
||||
DefaultProjectLimit int `help:"default project limits for users" default:"1" testDefault:"5"`
|
||||
TokenExpirationTime time.Duration `help:"expiration time for auth tokens, account recovery tokens, and activation tokens" default:"24h"`
|
||||
AsOfSystemTimeDuration time.Duration `help:"default duration for AS OF SYSTEM TIME" devDefault:"-5m" releaseDefault:"-5m" testDefault:"0"`
|
||||
LoginAttemptsWithoutPenalty int `help:"number of times user can try to login without penalty" default:"3"`
|
||||
FailedLoginPenalty float64 `help:"incremental duration of penalty for failed login attempts in minutes" default:"2.0"`
|
||||
UsageLimits UsageLimitsConfig
|
||||
Recaptcha RecaptchaConfig
|
||||
}
|
||||
|
||||
// RecaptchaConfig contains configurations for the reCAPTCHA system.
|
||||
@ -836,6 +847,10 @@ func (s *Service) ResetPassword(ctx context.Context, resetPasswordToken, passwor
|
||||
}
|
||||
|
||||
user.PasswordHash = hash
|
||||
if user.FailedLoginCount != 0 {
|
||||
user.FailedLoginCount = 0
|
||||
user.LoginLockoutExpiration = time.Time{}
|
||||
}
|
||||
|
||||
err = s.store.Users().Update(ctx, user)
|
||||
if err != nil {
|
||||
@ -871,9 +886,38 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token string, er
|
||||
return "", ErrLoginCredentials.New(credentialsErrMsg)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if user.LoginLockoutExpiration.After(now) {
|
||||
return "", ErrLockedAccount.New(lockedAccountErrMsg)
|
||||
}
|
||||
|
||||
lockoutExpDate := now.Add(time.Duration(math.Pow(s.config.FailedLoginPenalty, float64(user.FailedLoginCount-1))) * time.Minute)
|
||||
handleLockAccount := func() error {
|
||||
err = s.UpdateUsersFailedLoginState(ctx, user, lockoutExpDate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user.FailedLoginCount == s.config.LoginAttemptsWithoutPenalty {
|
||||
return ErrLockedAccount.New(lockedAccountErrMsg)
|
||||
}
|
||||
|
||||
if user.FailedLoginCount > s.config.LoginAttemptsWithoutPenalty {
|
||||
return ErrLockedAccount.New(lockedAccountWithResultErrMsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(request.Password))
|
||||
if err != nil {
|
||||
return "", ErrLoginCredentials.New(credentialsErrMsg)
|
||||
err = handleLockAccount()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "", ErrLoginPassword.New(credentialsErrMsg)
|
||||
}
|
||||
|
||||
if user.MFAEnabled {
|
||||
@ -892,6 +936,11 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token string, er
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
err = handleLockAccount()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "", ErrMFARecoveryCode.New(mfaRecoveryInvalidErrMsg)
|
||||
}
|
||||
|
||||
@ -904,9 +953,19 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token string, er
|
||||
} else if request.MFAPasscode != "" {
|
||||
valid, err := ValidateMFAPasscode(request.MFAPasscode, user.MFASecretKey, time.Now())
|
||||
if err != nil {
|
||||
err = handleLockAccount()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "", ErrMFAPasscode.Wrap(err)
|
||||
}
|
||||
if !valid {
|
||||
err = handleLockAccount()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "", ErrMFAPasscode.New(mfaPasscodeInvalidErrMsg)
|
||||
}
|
||||
} else {
|
||||
@ -914,6 +973,15 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token string, er
|
||||
}
|
||||
}
|
||||
|
||||
if user.FailedLoginCount != 0 {
|
||||
user.FailedLoginCount = 0
|
||||
user.LoginLockoutExpiration = time.Time{}
|
||||
err = s.store.Users().Update(ctx, user)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
claims := consoleauth.Claims{
|
||||
ID: user.ID,
|
||||
Expiration: time.Now().Add(s.config.TokenExpirationTime),
|
||||
@ -930,6 +998,16 @@ func (s *Service) Token(ctx context.Context, request AuthUser) (token string, er
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// UpdateUsersFailedLoginState updates User's failed login state.
|
||||
func (s *Service) UpdateUsersFailedLoginState(ctx context.Context, user *User, lockoutExpDate time.Time) error {
|
||||
if user.FailedLoginCount >= s.config.LoginAttemptsWithoutPenalty-1 {
|
||||
user.LoginLockoutExpiration = lockoutExpDate
|
||||
}
|
||||
user.FailedLoginCount++
|
||||
|
||||
return s.store.Users().Update(ctx, user)
|
||||
}
|
||||
|
||||
// GetUser returns User by id.
|
||||
func (s *Service) GetUser(ctx context.Context, id uuid.UUID) (u *User, err error) {
|
||||
defer mon.Task()(&ctx)(&err)
|
||||
|
@ -5,6 +5,7 @@ package console_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -725,3 +726,143 @@ func TestRESTKeys(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLockAccount ensures user's gets locked when incorrect credentials are provided.
|
||||
func TestLockAccount(t *testing.T) {
|
||||
testplanet.Run(t, testplanet.Config{
|
||||
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
|
||||
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
||||
sat := planet.Satellites[0]
|
||||
service := sat.API.Console.Service
|
||||
usersDB := sat.DB.Console().Users()
|
||||
consoleConfig := sat.Config.Console
|
||||
|
||||
newUser := console.CreateUser{
|
||||
FullName: "token test",
|
||||
Email: "token_test@mail.test",
|
||||
}
|
||||
|
||||
user, err := sat.AddUser(ctx, newUser, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
getNewAuthorization := func() (context.Context, console.Authorization) {
|
||||
authCtx, err := sat.AuthenticatedContext(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
auth, err := console.GetAuth(authCtx)
|
||||
require.NoError(t, err)
|
||||
return authCtx, auth
|
||||
}
|
||||
|
||||
authCtx, _ := getNewAuthorization()
|
||||
secret, err := service.ResetMFASecretKey(authCtx)
|
||||
require.NoError(t, err)
|
||||
|
||||
goodCode0, err := console.NewMFAPasscode(secret, time.Time{})
|
||||
require.NoError(t, err)
|
||||
|
||||
authCtx, _ = getNewAuthorization()
|
||||
err = service.EnableUserMFA(authCtx, goodCode0, time.Time{})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
goodCode1, err := console.NewMFAPasscode(secret, now)
|
||||
require.NoError(t, err)
|
||||
|
||||
authUser := console.AuthUser{
|
||||
Email: newUser.Email,
|
||||
Password: newUser.FullName,
|
||||
MFAPasscode: goodCode1,
|
||||
}
|
||||
|
||||
// successful login.
|
||||
token, err := service.Token(ctx, authUser)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token)
|
||||
|
||||
// check if user's account gets locked because of providing wrong password.
|
||||
authUser.Password = "qweQWE1@"
|
||||
for i := 1; i <= consoleConfig.LoginAttemptsWithoutPenalty; i++ {
|
||||
token, err = service.Token(ctx, authUser)
|
||||
require.Empty(t, token)
|
||||
if i < consoleConfig.LoginAttemptsWithoutPenalty {
|
||||
require.True(t, console.ErrLoginPassword.Has(err))
|
||||
} else {
|
||||
require.True(t, console.ErrLockedAccount.Has(err))
|
||||
}
|
||||
}
|
||||
|
||||
lockedUser, err := service.GetUser(authCtx, user.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, lockedUser.FailedLoginCount == consoleConfig.LoginAttemptsWithoutPenalty)
|
||||
require.True(t, lockedUser.LoginLockoutExpiration.After(now))
|
||||
|
||||
// lock account once again and check if lockout expiration time increased.
|
||||
expDuration := time.Duration(math.Pow(consoleConfig.FailedLoginPenalty, float64(lockedUser.FailedLoginCount-1))) * time.Minute
|
||||
lockoutExpDate := now.Add(expDuration)
|
||||
err = service.UpdateUsersFailedLoginState(authCtx, lockedUser, lockoutExpDate)
|
||||
require.NoError(t, err)
|
||||
|
||||
lockedUser, err = service.GetUser(authCtx, user.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, lockedUser.FailedLoginCount == consoleConfig.LoginAttemptsWithoutPenalty+1)
|
||||
|
||||
diff := lockedUser.LoginLockoutExpiration.Sub(now)
|
||||
require.Greater(t, diff, time.Duration(consoleConfig.FailedLoginPenalty)*time.Minute)
|
||||
|
||||
// unlock account by successful login
|
||||
lockedUser.LoginLockoutExpiration = now.Add(-time.Second)
|
||||
err = usersDB.Update(authCtx, lockedUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
authUser.Password = newUser.FullName
|
||||
token, err = service.Token(ctx, authUser)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, token)
|
||||
|
||||
unlockedUser, err := service.GetUser(authCtx, user.ID)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, unlockedUser.FailedLoginCount)
|
||||
|
||||
// check if user's account gets locked because of providing wrong mfa passcode.
|
||||
authUser.MFAPasscode = "000000"
|
||||
for i := 1; i <= consoleConfig.LoginAttemptsWithoutPenalty; i++ {
|
||||
token, err = service.Token(ctx, authUser)
|
||||
require.Empty(t, token)
|
||||
if i < consoleConfig.LoginAttemptsWithoutPenalty {
|
||||
require.True(t, console.ErrMFAPasscode.Has(err))
|
||||
} else {
|
||||
require.True(t, console.ErrLockedAccount.Has(err))
|
||||
}
|
||||
}
|
||||
|
||||
lockedUser, err = service.GetUser(authCtx, user.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, lockedUser.FailedLoginCount == consoleConfig.LoginAttemptsWithoutPenalty)
|
||||
require.True(t, lockedUser.LoginLockoutExpiration.After(now))
|
||||
|
||||
// unlock account
|
||||
lockedUser.LoginLockoutExpiration = time.Time{}
|
||||
lockedUser.FailedLoginCount = 0
|
||||
err = usersDB.Update(authCtx, lockedUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
// check if user's account gets locked because of providing wrong mfa recovery code.
|
||||
authUser.MFAPasscode = ""
|
||||
authUser.MFARecoveryCode = "000000"
|
||||
for i := 1; i <= consoleConfig.LoginAttemptsWithoutPenalty; i++ {
|
||||
token, err = service.Token(ctx, authUser)
|
||||
require.Empty(t, token)
|
||||
if i < consoleConfig.LoginAttemptsWithoutPenalty {
|
||||
require.True(t, console.ErrMFARecoveryCode.Has(err))
|
||||
} else {
|
||||
require.True(t, console.ErrLockedAccount.Has(err))
|
||||
}
|
||||
}
|
||||
|
||||
lockedUser, err = service.GetUser(authCtx, user.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, lockedUser.FailedLoginCount == consoleConfig.LoginAttemptsWithoutPenalty)
|
||||
require.True(t, lockedUser.LoginLockoutExpiration.After(now))
|
||||
})
|
||||
}
|
||||
|
12
scripts/testdata/satellite-config.yaml.lock
vendored
12
scripts/testdata/satellite-config.yaml.lock
vendored
@ -7,6 +7,12 @@
|
||||
# default project limits for users
|
||||
# admin.console-config.default-project-limit: 1
|
||||
|
||||
# incremental duration of penalty for failed login attempts in minutes
|
||||
# admin.console-config.failed-login-penalty: 2
|
||||
|
||||
# number of times user can try to login without penalty
|
||||
# admin.console-config.login-attempts-without-penalty: 3
|
||||
|
||||
# enable open registration
|
||||
# admin.console-config.open-registration-enabled: false
|
||||
|
||||
@ -172,6 +178,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
|
||||
# external endpoint of the satellite if hosted
|
||||
# console.external-address: ""
|
||||
|
||||
# incremental duration of penalty for failed login attempts in minutes
|
||||
# console.failed-login-penalty: 2
|
||||
|
||||
# indicates if file browser flow is disabled
|
||||
# console.file-browser-flow-disabled: false
|
||||
|
||||
@ -202,6 +211,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0
|
||||
# url link for linksharing requests
|
||||
# console.linksharing-url: https://link.us1.storjshare.io
|
||||
|
||||
# number of times user can try to login without penalty
|
||||
# console.login-attempts-without-penalty: 3
|
||||
|
||||
# indicates if new access grant flow should be used
|
||||
# console.new-access-grant-flow: false
|
||||
|
||||
|
@ -68,6 +68,8 @@ export class AuthHttpApi implements UsersApi {
|
||||
const result = await response.json();
|
||||
const errMsg = result.error || 'Failed to receive authentication token';
|
||||
switch (response.status) {
|
||||
case 400:
|
||||
throw new ErrorBadRequest(errMsg);
|
||||
case 401:
|
||||
throw new ErrorUnauthorized(errMsg);
|
||||
case 429:
|
||||
|
@ -118,6 +118,7 @@ import { APP_STATE_ACTIONS } from '@/utils/constants/actionNames';
|
||||
import { AppState } from '@/utils/constants/appStateEnum';
|
||||
import { Validator } from '@/utils/validation';
|
||||
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
|
||||
import { ErrorBadRequest } from "@/api/errors/ErrorBadRequest";
|
||||
|
||||
interface ClearInput {
|
||||
clearInput(): void;
|
||||
@ -289,17 +290,21 @@ export default class Login extends Vue {
|
||||
|
||||
this.isMFARequired = true;
|
||||
this.isLoading = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isMFARequired) {
|
||||
if (error instanceof ErrorBadRequest || error instanceof ErrorUnauthorized) {
|
||||
await this.$notify.error(error.message);
|
||||
}
|
||||
|
||||
this.isMFAError = true;
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ErrorUnauthorized) {
|
||||
await this.$notify.error(error.message);
|
||||
this.isBadLoginMessageShown = true;
|
||||
this.isLoading = false;
|
||||
return;
|
||||
|
Loading…
Reference in New Issue
Block a user