Skip to content

Commit f773122

Browse files
committed
feat: реализовать регистрацию, вход и refresh для пользователей
Что сделано: - добавлены модель пользователя, схемы и репозиторий - реализована бизнес-логика аутентификации и проверки активного пользователя - подключены JWT, хеширование паролей и защита refresh-потока - добавлены роуты auth и лимитирование для чувствительных операций
1 parent c0dbfb6 commit f773122

18 files changed

Lines changed: 712 additions & 0 deletions

File tree

app/limits/__init__.py

Whitespace-only changes.

app/limits/dependencies.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from collections.abc import Callable
2+
3+
from fastapi import Depends, Request
4+
from redis.asyncio import Redis
5+
6+
from app.limits.service import enforce_rate_limit
7+
from app.redis import get_redis
8+
9+
10+
def rate_limit_by_ip(limit: int, window_seconds: int, scope: str) -> Callable:
11+
"""Фабрика зависимости: ограничивает запросы по IP для заданного scope."""
12+
13+
async def dependency(
14+
request: Request,
15+
redis: Redis = Depends(get_redis),
16+
) -> None:
17+
client_ip = request.client.host if request.client else "unknown"
18+
key = f"rl:{scope}:ip:{client_ip}"
19+
await enforce_rate_limit(
20+
redis,
21+
key=key,
22+
limit=limit,
23+
window_seconds=window_seconds,
24+
)
25+
26+
return dependency

app/limits/service.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from typing import Awaitable, cast
2+
3+
from fastapi import HTTPException, status
4+
from redis.asyncio import Redis
5+
6+
# Атомарный инкремент с установкой TTL через Lua.
7+
# INCR и EXPIRE как два отдельных вызова не атомарны:
8+
# если процесс упадёт между ними - ключ останется без TTL навсегда.
9+
# защита от брутфорса на 2 линии обороны в time_cost=3 у argon2
10+
_RATE_LIMIT_SCRIPT = """
11+
local current = redis.call('INCR', KEYS[1])
12+
if current == 1 then
13+
redis.call('EXPIRE', KEYS[1], ARGV[1])
14+
end
15+
return current
16+
"""
17+
18+
19+
async def enforce_rate_limit(
20+
redis: Redis,
21+
*,
22+
key: str,
23+
limit: int,
24+
window_seconds: int,
25+
) -> None:
26+
"""Проверяет лимит запросов для ключа. Поднимает 429 с Retry-After при превышении."""
27+
current = int(
28+
await cast(
29+
Awaitable[int], redis.eval(_RATE_LIMIT_SCRIPT, 1, key, window_seconds)
30+
)
31+
)
32+
33+
if current > limit:
34+
retry_after = await redis.ttl(key)
35+
raise HTTPException(
36+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
37+
detail=f"Слишком много запросов. Повторите через {retry_after} сек.",
38+
headers={"Retry-After": str(max(retry_after, 1))},
39+
)

app/models/__init__.py

Whitespace-only changes.

app/models/mixins.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from datetime import datetime
2+
3+
from sqlalchemy import DateTime, text
4+
from sqlalchemy.orm import Mapped, mapped_column
5+
6+
7+
UTC_NOW = text("timezone('utc', now())") # всегда UTC на стороне БД
8+
9+
10+
class CreatedAtMixin:
11+
created_at: Mapped[datetime] = mapped_column(
12+
DateTime(timezone=True),
13+
server_default=UTC_NOW,
14+
nullable=False,
15+
)
16+
17+
18+
class TimestampMixin(CreatedAtMixin):
19+
updated_at: Mapped[datetime] = mapped_column(
20+
DateTime(timezone=True),
21+
server_default=UTC_NOW,
22+
server_onupdate=UTC_NOW,
23+
nullable=False,
24+
)

app/models/user.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
import uuid
4+
5+
from sqlalchemy import Boolean, String, text
6+
from sqlalchemy.dialects.postgresql import UUID
7+
from sqlalchemy.orm import Mapped, mapped_column
8+
9+
from app.database import Base
10+
from app.models.mixins import TimestampMixin
11+
12+
13+
class User(TimestampMixin, Base):
14+
__tablename__ = "users"
15+
16+
id: Mapped[uuid.UUID] = mapped_column(
17+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
18+
)
19+
email: Mapped[str] = mapped_column(
20+
String(254), unique=True, nullable=False, index=True
21+
)
22+
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
23+
is_active: Mapped[bool] = mapped_column(Boolean(), server_default=text("true"))

app/repositories/__init__.py

Whitespace-only changes.

app/repositories/user_repo.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Репозиторий для работы с пользователями."""
2+
3+
from __future__ import annotations
4+
5+
import uuid
6+
7+
from sqlalchemy import select
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
10+
from app.models.user import User
11+
12+
13+
async def get_user_by_id(session: AsyncSession, user_id: uuid.UUID) -> User | None:
14+
"""Возвращает пользователя по id или None."""
15+
return await session.get(User, user_id)
16+
17+
18+
async def get_user_by_email(session: AsyncSession, email: str) -> User | None:
19+
"""Возвращает пользователя по email или None."""
20+
stmt = select(User).where(User.email == email)
21+
result = await session.execute(stmt)
22+
return result.scalar_one_or_none()
23+
24+
25+
async def create_user(
26+
session: AsyncSession, *, email: str, hashed_password: str
27+
) -> User:
28+
"""Создаёт и сохраняет нового пользователя, возвращает его с заполненным id."""
29+
new_user = User(email=email, hashed_password=hashed_password)
30+
session.add(new_user)
31+
await session.commit()
32+
await session.refresh(new_user)
33+
return new_user

app/routes/auth.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
from uuid import UUID
2+
3+
from fastapi import APIRouter, Depends, HTTPException, Response, status
4+
from fastapi.security import OAuth2PasswordRequestForm
5+
from redis.asyncio import Redis
6+
from sqlalchemy.ext.asyncio import AsyncSession
7+
8+
from app.config import settings
9+
from app.database import get_db
10+
from app.limits.dependencies import rate_limit_by_ip
11+
from app.models.user import User
12+
from app.redis import get_redis
13+
from app.schemas.auth import RefreshTokenRequest, RegisterCreate, TokenPair, UserRead
14+
from app.security.dependences import get_current_user
15+
from app.security.jwt import (
16+
TokenExpired,
17+
TokenInvalid,
18+
create_access_token,
19+
create_refresh_token,
20+
decode_refresh_token,
21+
)
22+
from app.security.refresh_guard import require_active_refresh_user
23+
from app.services.auth import (
24+
UserAlreadyExists,
25+
UserInactive,
26+
authenticate_active_user,
27+
register_user,
28+
)
29+
30+
router = APIRouter(prefix="/auth", tags=["auth"])
31+
32+
33+
def _refresh_ttl_seconds() -> int:
34+
"""TTL refresh-токена в секундах из настроек (неделя)"""
35+
return settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
36+
37+
38+
def _refresh_key(user_id: UUID, jti: str) -> str:
39+
"""Ключ для хранения refresh-токена в Redis: rt:{user_id}:{jti}."""
40+
return f"rt:{user_id}:{jti}"
41+
42+
43+
def _parse_refresh_token_or_401(refresh_token: str) -> tuple[UUID, str]:
44+
"""Декодирует refresh-токен и возвращает (user_id, jti). Поднимает 401 при ошибке."""
45+
try:
46+
token_data = decode_refresh_token(refresh_token)
47+
except (TokenExpired, TokenInvalid):
48+
raise HTTPException(
49+
status_code=status.HTTP_401_UNAUTHORIZED,
50+
detail="Недействительный или истёкший refresh токен",
51+
)
52+
53+
try:
54+
user_id = UUID(token_data.sub)
55+
except (ValueError, TypeError):
56+
raise HTTPException(
57+
status_code=status.HTTP_401_UNAUTHORIZED,
58+
detail="Некорректный идентификатор пользователя в refresh токене",
59+
)
60+
61+
jti = str(token_data.payload["jti"])
62+
return user_id, jti
63+
64+
65+
async def _issue_token_pair(user_id: UUID, redis: Redis) -> TokenPair:
66+
"""Создаёт новую пару токенов и сохраняет jti refresh-токена в Redis."""
67+
access_token = create_access_token(subject=str(user_id))
68+
refresh_token, jti = create_refresh_token(subject=str(user_id))
69+
70+
await redis.set(
71+
_refresh_key(user_id, jti),
72+
str(user_id),
73+
ex=_refresh_ttl_seconds(),
74+
)
75+
76+
return TokenPair(
77+
access_token=access_token,
78+
refresh_token=refresh_token,
79+
token_type="bearer",
80+
)
81+
82+
83+
@router.post(
84+
"/register",
85+
response_model=UserRead,
86+
status_code=status.HTTP_201_CREATED,
87+
summary="Регистрация пользователя",
88+
dependencies=[
89+
Depends(rate_limit_by_ip(limit=5, window_seconds=60, scope="auth_register"))
90+
],
91+
)
92+
async def create_user(
93+
payload: RegisterCreate,
94+
session: AsyncSession = Depends(get_db),
95+
) -> UserRead:
96+
try:
97+
new_user = await register_user(session, payload.email, payload.password)
98+
except UserAlreadyExists:
99+
raise HTTPException(
100+
status_code=status.HTTP_409_CONFLICT,
101+
detail="Пользователь с таким Email уже зарегистрирован",
102+
)
103+
return UserRead.model_validate(new_user)
104+
105+
106+
@router.post(
107+
"/login",
108+
response_model=TokenPair,
109+
summary="Вход в систему",
110+
dependencies=[
111+
Depends(rate_limit_by_ip(limit=10, window_seconds=60, scope="auth_login"))
112+
],
113+
)
114+
async def login_user(
115+
form_data: OAuth2PasswordRequestForm = Depends(),
116+
session: AsyncSession = Depends(get_db),
117+
redis: Redis = Depends(get_redis),
118+
) -> TokenPair:
119+
try:
120+
user = await authenticate_active_user(
121+
session,
122+
form_data.username,
123+
form_data.password,
124+
)
125+
except UserInactive:
126+
raise HTTPException(
127+
status_code=status.HTTP_403_FORBIDDEN,
128+
detail="Аккаунт заблокирован",
129+
)
130+
131+
if not user:
132+
raise HTTPException(
133+
status_code=status.HTTP_401_UNAUTHORIZED,
134+
detail="Неверный логин или пароль",
135+
headers={"WWW-Authenticate": "bearer"},
136+
)
137+
138+
return await _issue_token_pair(user.id, redis)
139+
140+
141+
@router.post(
142+
"/refresh",
143+
response_model=TokenPair,
144+
summary="Обновить access/refresh токены",
145+
dependencies=[
146+
Depends(rate_limit_by_ip(limit=10, window_seconds=60, scope="auth_refresh"))
147+
],
148+
)
149+
async def refresh_token_pair(
150+
payload: RefreshTokenRequest,
151+
session: AsyncSession = Depends(get_db),
152+
redis: Redis = Depends(get_redis),
153+
) -> TokenPair:
154+
user_id, jti = _parse_refresh_token_or_401(payload.refresh_token)
155+
156+
await require_active_refresh_user(session, user_id)
157+
158+
deleted = await redis.delete(_refresh_key(user_id, jti))
159+
if deleted != 1:
160+
raise HTTPException(
161+
status_code=status.HTTP_401_UNAUTHORIZED,
162+
detail="Refresh токен отозван или уже использован",
163+
)
164+
165+
return await _issue_token_pair(user_id, redis)
166+
167+
168+
@router.post(
169+
"/logout",
170+
status_code=status.HTTP_204_NO_CONTENT,
171+
summary="Выход из системы",
172+
)
173+
async def logout_user(
174+
payload: RefreshTokenRequest,
175+
session: AsyncSession = Depends(get_db),
176+
redis: Redis = Depends(get_redis),
177+
) -> Response:
178+
user_id, jti = _parse_refresh_token_or_401(payload.refresh_token)
179+
180+
await require_active_refresh_user(session, user_id)
181+
182+
await redis.delete(_refresh_key(user_id, jti))
183+
return Response(status_code=status.HTTP_204_NO_CONTENT)
184+
185+
186+
@router.get("/me", response_model=UserRead, summary="Получить текущего пользователя")
187+
async def current_user(current_user: User = Depends(get_current_user)) -> UserRead:
188+
return UserRead.model_validate(current_user)

app/schemas/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)