diff --git a/backend/internal/auth/errors.go b/backend/internal/auth/errors.go index 9d4246e..96dbdd0 100644 --- a/backend/internal/auth/errors.go +++ b/backend/internal/auth/errors.go @@ -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" +} diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index 8d11ad3..dd36f3e 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -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"}) diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index 69889b1..188ef8e 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -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 diff --git a/backend/internal/db/migrations/000004_add_login_lockout_fields.down.sql b/backend/internal/db/migrations/000004_add_login_lockout_fields.down.sql new file mode 100644 index 0000000..93750da --- /dev/null +++ b/backend/internal/db/migrations/000004_add_login_lockout_fields.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users +DROP COLUMN locked_until, +DROP COLUMN failed_login_attempts; diff --git a/backend/internal/db/migrations/000004_add_login_lockout_fields.up.sql b/backend/internal/db/migrations/000004_add_login_lockout_fields.up.sql new file mode 100644 index 0000000..af2f8f6 --- /dev/null +++ b/backend/internal/db/migrations/000004_add_login_lockout_fields.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE users +ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0, +ADD COLUMN locked_until TIMESTAMPTZ; diff --git a/backend/internal/db/queries/query.sql b/backend/internal/db/queries/query.sql index d95b3b3..7754678 100644 --- a/backend/internal/db/queries/query.sql +++ b/backend/internal/db/queries/query.sql @@ -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; diff --git a/backend/internal/db/sqlc/models.go b/backend/internal/db/sqlc/models.go index ecd3eae..aed54ee 100644 --- a/backend/internal/db/sqlc/models.go +++ b/backend/internal/db/sqlc/models.go @@ -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"` } diff --git a/backend/internal/db/sqlc/querier.go b/backend/internal/db/sqlc/querier.go index 60fc508..d4b8a87 100644 --- a/backend/internal/db/sqlc/querier.go +++ b/backend/internal/db/sqlc/querier.go @@ -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 } diff --git a/backend/internal/db/sqlc/query.sql.go b/backend/internal/db/sqlc/query.sql.go index dcf453f..72d8ae4 100644 --- a/backend/internal/db/sqlc/query.sql.go +++ b/backend/internal/db/sqlc/query.sql.go @@ -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, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3396ca2..84d822d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -76,16 +76,16 @@ async function fetchApi( }); 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 ); } diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 843c243..d23a4ec 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -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((set) => ({ token: null, @@ -45,8 +68,7 @@ export const useAuthStore = create((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; } },