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:
Vitalii 2022-03-31 14:51:07 +03:00 committed by Vitalii Shpital
parent ca6a0fc844
commit dedccbd2e4
6 changed files with 255 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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