From 6ccc02544083c2a817bcced5b7729628a3d8fbbe Mon Sep 17 00:00:00 2001 From: Agzamus Date: Mon, 12 Oct 2026 16:45:16 +0300 Subject: [PATCH] =?UTF-8?q?feat:=207.=20REST-=D1=8D=D0=BD=D0=B4=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D0=BD=D1=82=D1=8B=20`/auth/*`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/__init__.py | 0 app/auth/__init__.py | 0 app/auth/router.py | 93 ++++++++++++++++++++++++++++++++++++++++++++ app/auth/schemas.py | 17 ++++++++ app/main.py | 8 ++++ 5 files changed, 118 insertions(+) create mode 100644 app/__init__.py create mode 100644 app/auth/__init__.py create mode 100644 app/auth/router.py create mode 100644 app/auth/schemas.py create mode 100644 app/main.py diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/router.py b/app/auth/router.py new file mode 100644 index 0000000..446b83d --- /dev/null +++ b/app/auth/router.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.audit.service import write_event +from app.auth.schemas import Credentials, TokenResponse, UserResponse +from app.core.deps import get_current_user +from app.core.jwt import create_access_token +from app.core.passwords import ( + WeakPasswordError, + hash_password, + validate_policy, + verify_password, +) +from app.db import get_db +from app.models import User + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _client_info(request: Request) -> tuple[str | None, str | None]: + ip = request.client.host if request.client else None + return ip, request.headers.get("user-agent") + + +# POST /auth/register – принимает JSON `{email, password}` +@router.post( + "/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED +) +def register( + payload: Credentials, request: Request, db: Session = Depends(get_db) +) -> TokenResponse: + # проверить, что email ещё не занят; + if db.scalar(select(User).where(User.email == payload.email)) is not None: + raise HTTPException(status_code=409, detail="Email already registered") + + # валидировать пароль через validate_policy + try: + validate_policy(payload.password, user_inputs=[payload.email]) + except WeakPasswordError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # создать пользователя с hashed_password + user = User(email=payload.email, hashed_password=hash_password(payload.password)) + db.add(user) + db.commit() + db.refresh(user) + + ip, user_agent = _client_info(request) + # записать событие аудита `user.created`; + write_event(db, "user.created", actor_id=user.id, ip=ip, user_agent=user_agent) + return TokenResponse(access_token=create_access_token(str(user.id))) + + +# POST /auth/login – принимает {email, password} +@router.post("/login", response_model=TokenResponse) +def login( + payload: Credentials, request: Request, db: Session = Depends(get_db) +) -> TokenResponse: + ip, user_agent = _client_info(request) + + # найти пользователя по email; + user = db.scalar(select(User).where(User.email == payload.email)) + + # проверить пароль через verify_password; + if user is None or not verify_password(payload.password, user.hashed_password): + # при неудаче: + # записать user.login.failure (с email в metadata, но без пароля!) + # и вернуть 401 с нейтральным сообщением «Invalid credentials» + # (не раскрывать, что именно неверно – email или пароль); + write_event( + db, + "user.login.failure", + actor_id=user.id if user else None, + ip=ip, + user_agent=user_agent, + metadata={"email": payload.email}, + ) + raise HTTPException(status_code=401, detail="Invalid credentials") + + # при успехе: записать user.login.success, вернуть токен. + write_event( + db, "user.login.success", actor_id=user.id, ip=ip, user_agent=user_agent + ) + return TokenResponse(access_token=create_access_token(str(user.id))) + + +# GET /auth/me – защищённый: +# принимает зависимость get_current_user; +# возвращает {id, email, is_admin}. +@router.get("/me", response_model=UserResponse) +def me(user: User = Depends(get_current_user)) -> UserResponse: + return UserResponse(id=user.id, email=user.email, is_admin=user.is_admin) diff --git a/app/auth/schemas.py b/app/auth/schemas.py new file mode 100644 index 0000000..bc688b8 --- /dev/null +++ b/app/auth/schemas.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, EmailStr + + +class Credentials(BaseModel): + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class UserResponse(BaseModel): + id: int + email: EmailStr + is_admin: bool diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..4d31700 --- /dev/null +++ b/app/main.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +from app.auth.router import router as auth_router +from app.config import settings + +app = FastAPI(title=settings.app_name) + +app.include_router(auth_router)