implement account lockout after 3 failed login attempts with 5-minute cooldown period
Some checks failed
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled

This commit is contained in:
Egor Pozharov
2026-04-29 17:00:37 +06:00
parent 459b60c9aa
commit a3929bec8d
11 changed files with 150 additions and 12 deletions

View File

@@ -1,6 +1,9 @@
package auth
import "errors"
import (
"errors"
"time"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
@@ -10,3 +13,11 @@ var (
ErrCredentialsEmpty = errors.New("username and password cannot be empty")
ErrPasswordTooShort = errors.New("password must be at least 6 characters long")
)
type AccountLockedError struct {
LockedUntil time.Time
}
func (e AccountLockedError) Error() string {
return "account temporarily locked"
}

View File

@@ -1,6 +1,7 @@
package auth
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
@@ -35,6 +36,16 @@ func (h *Handler) Login(c *gin.Context) {
token, err := h.authService.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
var lockErr AccountLockedError
if errors.As(err, &lockErr) {
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Слишком много неверных попыток. Попробуйте через 5 минут",
"code": "account_temporarily_locked",
"locked_until": lockErr.LockedUntil.Format("2006-01-02T15:04:05Z07:00"),
})
return
}
switch err {
case ErrUserNotFound, ErrInvalidCredentials:
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})

View File

@@ -71,10 +71,33 @@ func (s *Service) Login(ctx context.Context, username, password string) (string,
return "", err
}
now := time.Now()
if user.LockedUntil.Valid {
if user.LockedUntil.Time.After(now) {
return "", AccountLockedError{LockedUntil: user.LockedUntil.Time}
}
if err := s.queries.ResetLoginFailures(ctx, username); err != nil {
return "", err
}
}
if !s.VerifyPassword(user.PasswordHash, password) {
failedLogin, err := s.queries.RecordFailedLogin(ctx, username)
if err != nil {
return "", err
}
if failedLogin.LockedUntil.Valid && failedLogin.LockedUntil.Time.After(now) {
return "", AccountLockedError{LockedUntil: failedLogin.LockedUntil.Time}
}
return "", ErrInvalidCredentials
}
if user.FailedLoginAttempts > 0 || user.LockedUntil.Valid {
if err := s.queries.ResetLoginFailures(ctx, username); err != nil {
return "", err
}
}
token, err := GenerateToken(uuid.UUID(user.ID.Bytes), s.secret, s.expiry)
if err != nil {
return "", err

View File

@@ -0,0 +1,3 @@
ALTER TABLE users
DROP COLUMN locked_until,
DROP COLUMN failed_login_attempts;

View File

@@ -0,0 +1,3 @@
ALTER TABLE users
ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN locked_until TIMESTAMPTZ;

View File

@@ -6,6 +6,25 @@ RETURNING *;
-- name: GetUserByUsername :one
SELECT * FROM users WHERE username = $1;
-- name: ResetLoginFailures :exec
UPDATE users
SET failed_login_attempts = 0,
locked_until = NULL
WHERE username = $1;
-- name: RecordFailedLogin :one
UPDATE users
SET failed_login_attempts = CASE
WHEN failed_login_attempts + 1 >= 3 THEN 3
ELSE failed_login_attempts + 1
END,
locked_until = CASE
WHEN failed_login_attempts + 1 >= 3 THEN NOW() + INTERVAL '5 minutes'
ELSE locked_until
END
WHERE username = $1
RETURNING failed_login_attempts, locked_until;
-- name: GetDeliveriesByDate :many
SELECT * FROM deliveries WHERE date = $1;

View File

@@ -33,8 +33,10 @@ type Delivery struct {
}
type User struct {
ID pgtype.UUID `db:"id" json:"id"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"`
ID pgtype.UUID `db:"id" json:"id"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"`
FailedLoginAttempts int32 `db:"failed_login_attempts" json:"failed_login_attempts"`
LockedUntil pgtype.Timestamptz `db:"locked_until" json:"locked_until"`
}

View File

@@ -18,6 +18,8 @@ type Querier interface {
GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error)
GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error)
GetUserByUsername(ctx context.Context, username string) (User, error)
RecordFailedLogin(ctx context.Context, username string) (RecordFailedLoginRow, error)
ResetLoginFailures(ctx context.Context, username string) error
UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error
UpdateDeliveryStatus(ctx context.Context, arg UpdateDeliveryStatusParams) error
}

View File

@@ -91,7 +91,7 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams)
const createUser = `-- name: CreateUser :one
INSERT INTO users (username, password_hash)
VALUES ($1, $2)
RETURNING id, username, password_hash, created_at
RETURNING id, username, password_hash, created_at, failed_login_attempts, locked_until
`
type CreateUserParams struct {
@@ -107,6 +107,8 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.FailedLoginAttempts,
&i.LockedUntil,
)
return i, err
}
@@ -231,7 +233,7 @@ func (q *Queries) GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow,
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, created_at FROM users WHERE username = $1
SELECT id, username, password_hash, created_at, failed_login_attempts, locked_until FROM users WHERE username = $1
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
@@ -242,10 +244,50 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.FailedLoginAttempts,
&i.LockedUntil,
)
return i, err
}
const recordFailedLogin = `-- name: RecordFailedLogin :one
UPDATE users
SET failed_login_attempts = CASE
WHEN failed_login_attempts + 1 >= 3 THEN 3
ELSE failed_login_attempts + 1
END,
locked_until = CASE
WHEN failed_login_attempts + 1 >= 3 THEN NOW() + INTERVAL '5 minutes'
ELSE locked_until
END
WHERE username = $1
RETURNING failed_login_attempts, locked_until
`
type RecordFailedLoginRow struct {
FailedLoginAttempts int32 `db:"failed_login_attempts" json:"failed_login_attempts"`
LockedUntil pgtype.Timestamptz `db:"locked_until" json:"locked_until"`
}
func (q *Queries) RecordFailedLogin(ctx context.Context, username string) (RecordFailedLoginRow, error) {
row := q.db.QueryRow(ctx, recordFailedLogin, username)
var i RecordFailedLoginRow
err := row.Scan(&i.FailedLoginAttempts, &i.LockedUntil)
return i, err
}
const resetLoginFailures = `-- name: ResetLoginFailures :exec
UPDATE users
SET failed_login_attempts = 0,
locked_until = NULL
WHERE username = $1
`
func (q *Queries) ResetLoginFailures(ctx context.Context, username string) error {
_, err := q.db.Exec(ctx, resetLoginFailures, username)
return err
}
const updateDelivery = `-- name: UpdateDelivery :exec
UPDATE deliveries SET
date = $1,

View File

@@ -76,16 +76,16 @@ async function fetchApi<T>(
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
// Handle 401 unauthorized
if (response.status === 401) {
if (response.status === 401 && endpoint !== '/api/auth/login') {
handleUnauthorized();
throw new ApiError('Unauthorized', 401);
}
const errorData = await response.json().catch(() => null);
throw new ApiError(
errorData?.error || `HTTP ${response.status}`,
response.status,
errorData?.details
errorData
);
}

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand';
import { authApi } from '../api/auth';
import { ApiError } from '../api/client';
import { useToastStore } from './toastStore';
import type { User, LoginRequest } from '../types';
@@ -16,6 +17,28 @@ interface AuthState {
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';
const LOCKED_ACCOUNT_CODE = 'account_temporarily_locked';
function getLoginErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
const details = error.details as { code?: string; locked_until?: string } | null;
if (error.status === 429 && details?.code === LOCKED_ACCOUNT_CODE) {
if (details.locked_until) {
const lockedUntil = new Date(details.locked_until);
const secondsLeft = Math.ceil((lockedUntil.getTime() - Date.now()) / 1000);
if (Number.isFinite(secondsLeft) && secondsLeft > 0) {
const minutesLeft = Math.ceil(secondsLeft / 60);
return `Слишком много неверных попыток. Попробуйте через ${minutesLeft} мин.`;
}
}
return 'Слишком много неверных попыток. Попробуйте через 5 минут';
}
}
return error instanceof Error ? error.message : 'Ошибка входа';
}
export const useAuthStore = create<AuthState>((set) => ({
token: null,
@@ -45,8 +68,7 @@ export const useAuthStore = create<AuthState>((set) => ({
useToastStore.getState().addToast('Вход выполнен успешно', 'success');
} catch (error) {
set({ isLoading: false });
const message = error instanceof Error ? error.message : 'Ошибка входа';
useToastStore.getState().addToast(message, 'error');
useToastStore.getState().addToast(getLoginErrorMessage(error), 'error');
throw error;
}
},