Files

272 lines
8.8 KiB
Python

"""Auth API routes for VoIdea."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.core.limiter import limiter
from app.core.security import (
create_access_token,
create_refresh_token,
verify_password,
get_password_hash,
)
from app.integrations.oauth.yandex import exchange_code, get_authorize_url, get_user_info
from app.models.user import User
from app.schemas.auth import (
ForgotPasswordRequest,
LoginRequest,
OAuthCallbackRequest,
OAuthUrlResponse,
RefreshRequest,
RegisterRequest,
ResetPasswordRequest,
TokenResponse,
TwoFactorLoginRequest,
TwoFactorLoginResponse,
TwoFactorSetupResponse,
TwoFactorVerifyRequest,
)
from app.schemas.user import ChangePasswordRequest
from app.services.auth_service import AuthService
from app.services.password_reset_service import reset_password, send_reset_email
from app.services.two_factor_service import (
generate_qr_base64,
generate_totp_secret,
get_totp_uri,
get_user_secret,
is_2fa_enabled,
is_2fa_globally_enabled,
set_2fa_enabled,
set_user_secret,
verify_totp,
)
router = APIRouter()
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("5/minute")
async def register(request: Request, body: RegisterRequest, db: AsyncSession = Depends(get_db)):
service = AuthService(db)
try:
return await service.register(
body.email, body.password, body.display_name,
accepted_terms=body.accepted_terms,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
@router.post("/login")
@limiter.limit("10/minute")
async def login(request: Request, body: LoginRequest, db: AsyncSession = Depends(get_db)):
from sqlalchemy import select
result = await db.execute(select(User).where(User.email == body.email))
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный email или пароль")
globally_enabled = await is_2fa_globally_enabled(db)
if globally_enabled and is_2fa_enabled(user):
from datetime import timedelta
temp_token = create_access_token(
data={"sub": str(user.id), "purpose": "2fa"},
expires_delta=timedelta(minutes=5),
)
return {"temp_token": temp_token, "message": "Требуется 2FA код"}
service = AuthService(db)
return await service.create_token_response(user)
@router.post("/2fa/verify-login", response_model=TwoFactorLoginResponse)
@limiter.limit("10/minute")
async def verify_2fa_login(
request: Request,
body: TwoFactorLoginRequest,
db: AsyncSession = Depends(get_db),
):
from jose import jwt, JWTError
from app.core.config import get_settings
settings = get_settings()
try:
payload = jwt.decode(
body.temp_token, settings.jwt_secret_key,
algorithms=[settings.jwt_algorithm],
)
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid temp token")
if payload.get("purpose") != "2fa":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token purpose")
from sqlalchemy import select
result = await db.execute(select(User).where(User.id == payload.get("sub")))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
secret = get_user_secret(user)
if not secret or not verify_totp(secret, body.totp_code):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный 2FA код")
service = AuthService(db)
return await service.create_token_response(user)
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
async def setup_2fa(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if is_2fa_enabled(user):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA уже включена")
secret = generate_totp_secret()
set_user_secret(user, secret)
await db.commit()
uri = get_totp_uri(secret, user.email)
qr = generate_qr_base64(uri)
return TwoFactorSetupResponse(secret=secret, uri=uri, qr_base64=qr)
@router.post("/2fa/verify")
async def verify_2fa(
body: TwoFactorVerifyRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
secret = get_user_secret(user)
if not secret:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA не настроена")
if not verify_totp(secret, body.token):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
set_2fa_enabled(user, True)
await db.commit()
return {"message": "2FA успешно включена"}
@router.post("/2fa/disable")
async def disable_2fa(
body: TwoFactorVerifyRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
secret = get_user_secret(user)
if not secret or not verify_totp(secret, body.token):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
set_2fa_enabled(user, False)
await db.commit()
return {"message": "2FA отключена"}
@router.post("/refresh", response_model=TokenResponse)
@limiter.limit("10/minute")
async def refresh(request: Request, body: RefreshRequest, db: AsyncSession = Depends(get_db)):
service = AuthService(db)
try:
return await service.refresh(body.refresh_token)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
@router.get("/oauth/yandex", response_model=OAuthUrlResponse)
@limiter.limit("10/minute")
async def oauth_yandex_url(request: Request):
url = await get_authorize_url()
return OAuthUrlResponse(url=url, provider="yandex")
@router.post("/oauth/yandex/callback", response_model=TokenResponse)
@limiter.limit("10/minute")
async def oauth_yandex_callback(
request: Request,
body: OAuthCallbackRequest,
db: AsyncSession = Depends(get_db),
):
token_result = await exchange_code(body.code)
if not token_result:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code",
)
user_info = await get_user_info(token_result.access_token)
if not user_info:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to get user info from Yandex",
)
service = AuthService(db)
try:
return await service.oauth_or_register_login(
email=user_info.email,
oauth_provider="yandex",
oauth_id=user_info.id,
display_name=user_info.display_name,
avatar_url=user_info.avatar_url,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
@router.post("/forgot-password")
@limiter.limit("3/minute")
async def forgot_password(
request: Request,
body: ForgotPasswordRequest,
db: AsyncSession = Depends(get_db),
):
success, message = await send_reset_email(db, body.email)
if not success:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=message,
)
return {"message": message}
@router.post("/reset-password")
@limiter.limit("5/minute")
async def reset_password_endpoint(
request: Request,
body: ResetPasswordRequest,
db: AsyncSession = Depends(get_db),
):
success, message = await reset_password(db, body.token, body.new_password)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
return {"message": message}
@router.post("/change-password")
@limiter.limit("5/minute")
async def change_password(
request: Request,
body: ChangePasswordRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Неверный текущий пароль",
)
user.password_hash = get_password_hash(body.new_password)
await db.commit()
return {"message": "Пароль успешно изменён"}