implement account lockout after 3 failed login attempts with 5-minute cooldown period
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN locked_until,
|
||||||
|
DROP COLUMN failed_login_attempts;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN locked_until TIMESTAMPTZ;
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -37,4 +37,6 @@ type User struct {
|
|||||||
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user