Skip to content

Commit 3311ce7

Browse files
heiytorgustavosbarreto
authored andcommitted
feat(api): account lockout
We're implementing an "account lockout" feature in the login route to manage repeated failed login attempts due to incorrect passwords for the same user. Account lockout is the system's ability to automatically restrict source access based on the number of login attempts. The system permits sources two login attempts before initiating a lockout. Upon a third attempt with an incorrect password, the API will block further attempts. Consumers trying to log in must inspect the `X-Account-Lockout` header, which indicates the end of the lockout period in UTC seconds. When no lockout is active, the header value will be 0. Importantly, a lockout only affects login attempts from the same source. Logins from other sources using the same user credentials will proceed without encountering a lockout. The lockout duration is calculated based on the number of attempts made, increasing exponentially by a factor of 4 after the third attempt. Attempts must last for half of the double lockout duration. This means that a user who was locked out for 4 minutes must have the attempts stored for 10 minutes (or 6 minutes after the timeout). Any wrong attempt within this time will increase the lockout once again. After this, the attempts will be reset, and new wrong attempts will start the attempt counter from 0. The following equations are used to calculate both lockout and attempt duration, with 'x' representing the lockout duration and 'y' the attempt duration: ``` F(x) = min(4^(a - 3), M) F(y) = min((x) * 2.5, M) ``` Where: ``` x is the lockout duration in minutes. y is the attempt duration in minutes. a is the attempt number. M is the maximum duration value. ``` Examples for M = 32768 (15 days) and a = n: ``` n = 3 | 4 | 5 | 8 | 11 _________________________________ F(x) = 1 | 4 | 16 | 1024 | 32768 F(y) = 2.5 | 10 | 40 | 2560 | 32768 ``` The M value is controlled with the "MAXIMUM_ACCOUNT_LOCKOUT" environment variable. When it equals 0, this feature is disabled. The default value is 60, representing 1 hour.
1 parent 4ea88ac commit 3311ce7

File tree

13 files changed

+577
-54
lines changed

13 files changed

+577
-54
lines changed

.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,6 @@ SHELLHUB_NETWORK=shellhub_network
127127
# This variable controls the pool size of connections available for Redis cache.
128128
# If not specified or set to 0, it will use the default value defined by the Redis Client.
129129
SHELLHUB_REDIS_CACHE_POOL_SIZE=0
130+
131+
# Specifies the maximum duration in minutes for which a source can be blocked from login attempts. Set it to 0 to disable.
132+
SHELLHUB_MAXIMUM_ACCOUNT_LOCKOUT=60

api/routes/auth.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,13 @@ func (h *Handler) AuthUser(c gateway.Context) error {
207207
return err
208208
}
209209

210-
res, err := h.service.AuthUser(c.Ctx(), req)
210+
res, timeout, err := h.service.AuthUser(c.Ctx(), req, c.RealIP())
211+
c.Response().Header().Set("X-Account-Lockout", strconv.FormatInt(timeout, 10))
212+
213+
if timeout > 0 {
214+
return c.NoContent(http.StatusTooManyRequests)
215+
}
216+
211217
if err != nil {
212218
switch {
213219
case errors.Is(err, svc.ErrUserNotFound):

api/routes/auth_test.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,8 @@ func TestAuthUser(t *testing.T) {
218218
On("AuthUser", gomock.Anything, &requests.UserAuth{
219219
Identifier: "john_doe",
220220
Password: "wrong_password",
221-
}).
222-
Return(nil, svc.ErrUserNotFound).
221+
}, gomock.Anything).
222+
Return(nil, int64(0), svc.ErrUserNotFound).
223223
Once()
224224
},
225225
expected: Expected{
@@ -238,15 +238,35 @@ func TestAuthUser(t *testing.T) {
238238
On("AuthUser", gomock.Anything, &requests.UserAuth{
239239
Identifier: "john_doe",
240240
Password: "wrong_password",
241-
}).
242-
Return(nil, svc.ErrAuthUnathorized).
241+
}, gomock.Anything).
242+
Return(nil, int64(0), svc.ErrAuthUnathorized).
243243
Once()
244244
},
245245
expected: Expected{
246246
body: nil,
247247
status: http.StatusUnauthorized,
248248
},
249249
},
250+
{
251+
description: "fails when reaching the attempt limits",
252+
req: &requests.UserAuth{
253+
Identifier: "john_doe",
254+
Password: "wrong_password",
255+
},
256+
mocks: func() {
257+
mock.
258+
On("AuthUser", gomock.Anything, &requests.UserAuth{
259+
Identifier: "john_doe",
260+
Password: "wrong_password",
261+
}, gomock.Anything).
262+
Return(nil, int64(1711176851), svc.ErrAuthUnathorized).
263+
Once()
264+
},
265+
expected: Expected{
266+
body: nil,
267+
status: http.StatusTooManyRequests,
268+
},
269+
},
250270
{
251271
description: "success when try to auth a user",
252272
req: &requests.UserAuth{
@@ -258,7 +278,7 @@ func TestAuthUser(t *testing.T) {
258278
On("AuthUser", gomock.Anything, &requests.UserAuth{
259279
Identifier: "john_doe",
260280
Password: "secret",
261-
}).
281+
}, gomock.Anything).
262282
Return(&models.UserAuthResponse{
263283
ID: "65fdd16b5f62f93184ec8a39",
264284
Name: "john doe",
@@ -271,7 +291,7 @@ func TestAuthUser(t *testing.T) {
271291
Enable: false,
272292
Validate: false,
273293
},
274-
}, nil).
294+
}, int64(0), nil).
275295
Once()
276296
},
277297
expected: Expected{

api/routes/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ func NewRouter(service services.Service) *echo.Echo {
1313
e.Binder = handlers.NewBinder()
1414
e.Validator = handlers.NewValidator()
1515
e.HTTPErrorHandler = handlers.NewErrors(nil)
16+
e.IPExtractor = echo.ExtractIPFromRealIPHeader()
1617

1718
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
1819
return func(c echo.Context) error {

api/services/auth.go

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ type AuthService interface {
2828
AuthIsCacheToken(ctx context.Context, tenant, id string) (bool, error)
2929
AuthUncacheToken(ctx context.Context, tenant, id string) error
3030
AuthDevice(ctx context.Context, req requests.DeviceAuth, remoteAddr string) (*models.DeviceAuthResponse, error)
31-
AuthUser(ctx context.Context, req *requests.UserAuth) (*models.UserAuthResponse, error)
31+
32+
// AuthUser is responsible for authenticating a user and returning its credentials. It can block a sourceIP
33+
// from logging in a user for up to M minutes when exceeding the attempts limit.
34+
AuthUser(ctx context.Context, req *requests.UserAuth, sourceIP string) (*models.UserAuthResponse, int64, error)
35+
3236
AuthGetToken(ctx context.Context, id string, mfa bool) (*models.UserAuthResponse, error)
3337
AuthPublicKey(ctx context.Context, req requests.PublicKeyAuth) (*models.PublicKeyAuthResponse, error)
3438
AuthSwapToken(ctx context.Context, ID, tenant string) (*models.UserAuthResponse, error)
@@ -149,7 +153,7 @@ func (s *service) AuthDevice(ctx context.Context, req requests.DeviceAuth, remot
149153
}, nil
150154
}
151155

152-
func (s *service) AuthUser(ctx context.Context, req *requests.UserAuth) (*models.UserAuthResponse, error) {
156+
func (s *service) AuthUser(ctx context.Context, req *requests.UserAuth, sourceIP string) (*models.UserAuthResponse, int64, error) {
153157
var err error
154158
var user *models.User
155159

@@ -160,20 +164,50 @@ func (s *service) AuthUser(ctx context.Context, req *requests.UserAuth) (*models
160164
}
161165

162166
if err != nil {
163-
return nil, NewErrAuthUnathorized(nil)
167+
return nil, 0, NewErrAuthUnathorized(nil)
164168
}
165169

166170
if !user.Confirmed {
167-
return nil, NewErrUserNotConfirmed(nil)
171+
return nil, 0, NewErrUserNotConfirmed(nil)
172+
}
173+
174+
// Checks whether the user is currently blocked from new login attempts
175+
if lockout, attempt, _ := s.cache.HasAccountLockout(ctx, sourceIP, user.ID); lockout > 0 {
176+
log.
177+
WithFields(log.Fields{
178+
"lockout": lockout,
179+
"attempt": attempt,
180+
"source_ip": sourceIP,
181+
"user_id": user.ID,
182+
}).
183+
Warn("attempt to login blocked")
184+
185+
return nil, lockout, NewErrAuthUnathorized(nil)
168186
}
169187

170188
if !user.Password.Compare(req.Password) {
171-
return nil, NewErrAuthUnathorized(nil)
189+
lockout, _, err := s.cache.StoreLoginAttempt(ctx, sourceIP, user.ID)
190+
if err != nil {
191+
log.WithError(err).
192+
WithField("source_ip", sourceIP).
193+
WithField("user_id", user.ID).
194+
Warn("unable to store login attempt")
195+
}
196+
197+
return nil, lockout, NewErrAuthUnathorized(nil)
198+
}
199+
200+
// Reset the attempt and timeout values when succeeds
201+
if err := s.cache.ResetLoginAttempts(ctx, sourceIP, user.ID); err != nil {
202+
log.WithError(err).
203+
WithField("source_ip", sourceIP).
204+
WithField("user_id", user.ID).
205+
Warn("unable to reset authentication attempts")
172206
}
173207

174208
hasMFA, err := s.AuthMFA(ctx, user.ID)
175209
if err != nil {
176-
return nil, err // TODO: handle this error
210+
return nil, 0, err // TODO: handle this error
177211
}
178212

179213
claims := &models.UserAuthClaims{
@@ -205,12 +239,12 @@ func (s *service) AuthUser(ctx context.Context, req *requests.UserAuth) (*models
205239
WithPrivateKey(s.privKey).
206240
Sign()
207241
if err != nil {
208-
return nil, NewErrTokenSigned(err)
242+
return nil, 0, NewErrTokenSigned(err)
209243
}
210244

211245
user.LastLogin = clock.Now()
212246
if err := s.store.UserUpdateData(ctx, user.ID, *user); err != nil {
213-
return nil, NewErrUserUpdate(user, err)
247+
return nil, 0, NewErrUserUpdate(user, err)
214248
}
215249

216250
if err := s.AuthCacheToken(ctx, claims.Tenant, user.ID, token.String()); err != nil {
@@ -238,7 +272,7 @@ func (s *service) AuthUser(ctx context.Context, req *requests.UserAuth) (*models
238272
Enable: hasMFA,
239273
Validate: false,
240274
},
241-
}, nil
275+
}, 0, nil
242276
}
243277

244278
func (s *service) AuthGetToken(ctx context.Context, id string, mfa bool) (*models.UserAuthResponse, error) {

0 commit comments

Comments
 (0)