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 package auth
import "errors" import (
"errors"
"time"
)
var ( var (
ErrInvalidCredentials = errors.New("invalid credentials") ErrInvalidCredentials = errors.New("invalid credentials")
@@ -10,3 +13,11 @@ var (
ErrCredentialsEmpty = errors.New("username and password cannot be empty") ErrCredentialsEmpty = errors.New("username and password cannot be empty")
ErrPasswordTooShort = errors.New("password must be at least 6 characters long") 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 package auth
import ( import (
"errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "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) token, err := h.authService.Login(c.Request.Context(), req.Username, req.Password)
if err != nil { 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 { switch err {
case ErrUserNotFound, ErrInvalidCredentials: case ErrUserNotFound, ErrInvalidCredentials:
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) 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 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) { 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 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) token, err := GenerateToken(uuid.UUID(user.ID.Bytes), s.secret, s.expiry)
if err != nil { if err != nil {
return "", err 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 -- name: GetUserByUsername :one
SELECT * FROM users WHERE username = $1; 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 -- name: GetDeliveriesByDate :many
SELECT * FROM deliveries WHERE date = $1; SELECT * FROM deliveries WHERE date = $1;

View File

@@ -33,8 +33,10 @@ type Delivery struct {
} }
type User struct { type User struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
Username string `db:"username" json:"username"` Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"` PasswordHash string `db:"password_hash" json:"password_hash"`
CreatedAt pgtype.Timestamp `db:"created_at" json:"created_at"` 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) GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error)
GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error) GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error)
GetUserByUsername(ctx context.Context, username string) (User, 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 UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error
UpdateDeliveryStatus(ctx context.Context, arg UpdateDeliveryStatusParams) 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 const createUser = `-- name: CreateUser :one
INSERT INTO users (username, password_hash) INSERT INTO users (username, password_hash)
VALUES ($1, $2) 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 { type CreateUserParams struct {
@@ -107,6 +107,8 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.CreatedAt, &i.CreatedAt,
&i.FailedLoginAttempts,
&i.LockedUntil,
) )
return i, err return i, err
} }
@@ -231,7 +233,7 @@ func (q *Queries) GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow,
} }
const getUserByUsername = `-- name: GetUserByUsername :one 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) { 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.Username,
&i.PasswordHash, &i.PasswordHash,
&i.CreatedAt, &i.CreatedAt,
&i.FailedLoginAttempts,
&i.LockedUntil,
) )
return i, err 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 const updateDelivery = `-- name: UpdateDelivery :exec
UPDATE deliveries SET UPDATE deliveries SET
date = $1, date = $1,

View File

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

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { ApiError } from '../api/client';
import { useToastStore } from './toastStore'; import { useToastStore } from './toastStore';
import type { User, LoginRequest } from '../types'; import type { User, LoginRequest } from '../types';
@@ -16,6 +17,28 @@ interface AuthState {
const TOKEN_KEY = 'auth_token'; const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user'; 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) => ({ export const useAuthStore = create<AuthState>((set) => ({
token: null, token: null,
@@ -45,8 +68,7 @@ export const useAuthStore = create<AuthState>((set) => ({
useToastStore.getState().addToast('Вход выполнен успешно', 'success'); useToastStore.getState().addToast('Вход выполнен успешно', 'success');
} catch (error) { } catch (error) {
set({ isLoading: false }); set({ isLoading: false });
const message = error instanceof Error ? error.message : 'Ошибка входа'; useToastStore.getState().addToast(getLoginErrorMessage(error), 'error');
useToastStore.getState().addToast(message, 'error');
throw error; throw error;
} }
}, },