Files
service-finder/backend/app/api/v1/endpoints/auth.py
2026-07-24 09:56:21 +00:00

536 lines
20 KiB
Python
Executable File

# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/auth.py
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, Response
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.services.auth_service import AuthService
from app.services.social_auth_service import SocialAuthService
from app.core.security import create_tokens, DEFAULT_RANK_MAP
from app.core.config import settings
from app.services.config_service import config
from app.schemas.auth import UserLiteRegister, Token, UserKYCComplete
from app.api.deps import get_current_user
from app.models.identity import User, Device, UserDeviceLink # JAVÍTVA: Device-Hub modellek
from app.core.translation_helper import t # Translation helper
from pydantic import BaseModel, Field, EmailStr
from datetime import datetime, timezone
import httpx
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(user_in: UserLiteRegister, db: AsyncSession = Depends(get_db)):
"""
Regisztráció (Lite fázis) - új felhasználó létrehozása.
"""
user = await AuthService.register_lite(db, user_in)
return {
"status": "success",
"message": t("AUTH.REGISTRATION_SUCCESS"),
"user_id": user.id,
"email": user.email
}
@router.post("/login", response_model=Token)
async def login(
response: Response,
db: AsyncSession = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends(),
remember_me: bool = Form(False),
device_fingerprint: Optional[str] = Form(None)
):
"""
Bejelentkezés remember_me opcióval és Device Fingerprinting támogatással.
A remember_me paramétert explicit Form mezőként kell elküldeni, mivel az
OAuth2PasswordRequestForm nem támogatja alapból.
A device_fingerprint opcionális, a frontend FingerprintJS által generált hash.
"""
user = await AuthService.authenticate(db, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=401, detail=t("AUTH.INVALID_CREDENTIALS"))
# DEBUG: Inaktív fiók explicit ellenőrzése
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="AUTH.USER_INACTIVE"
)
# ── Device Fingerprint Tracking ──
if device_fingerprint:
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
existing_device = await db.execute(
select(Device).where(Device.fingerprint_hash == device_fingerprint)
)
device = existing_device.scalar_one_or_none()
if not device:
# Új eszköz létrehozása
device = Device(
fingerprint_hash=device_fingerprint,
risk_score=100,
is_banned=False
)
db.add(device)
# 2. Ellenőrizzük a UserDeviceLink-et
existing_link = await db.execute(
select(UserDeviceLink).where(
UserDeviceLink.user_id == user.id,
UserDeviceLink.device_hash == device_fingerprint
)
)
link = existing_link.scalar_one_or_none()
now = datetime.now(timezone.utc)
if link:
# Létező kapcsolat frissítése
link.last_seen_at = now
link.login_count = link.login_count + 1
else:
# Új kapcsolat létrehozása
link = UserDeviceLink(
user_id=user.id,
device_hash=device_fingerprint,
first_seen_at=now,
last_seen_at=now,
login_count=1
)
db.add(link)
await db.commit()
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
role_key = role_name.upper() # A DEFAULT_RANK_MAP nagybetűs kulcsokat vár
token_data = {
"sub": str(user.id),
"role": role_name,
"rank": ranks.get(role_key, 10),
"scope_level": user.scope_level or "individual",
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
}
access, refresh = create_tokens(data=token_data, remember_me=remember_me)
# Kinyerjük a beállításokból, hogy meddig él a refresh token (itt is, vagy a create_tokens is ezt csinálja)
# A SSoT szerint auth_remember_me_days = 30
if remember_me:
max_age_days = await config.get_setting(db, "auth_remember_me_days", default=30)
else:
max_age_days = await config.get_setting(db, "auth_refresh_default_days", default=1)
max_age_sec = int(max_age_days) * 24 * 60 * 60
response.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=True, # Use Secure
samesite="lax",
domain=settings.COOKIE_DOMAIN, # Cross-subdomain SSO
max_age=max_age_sec
)
return {"access_token": access, "refresh_token": refresh, "token_type": "bearer", "is_active": user.is_active}
class VerifyEmailRequest(BaseModel):
token: str = Field(..., description="Email verification token (UUID)")
device_fingerprint: Optional[str] = Field(None, description="Device fingerprint hash from FingerprintJS")
@router.post("/verify-email", response_model=Token)
async def verify_email(
request: VerifyEmailRequest,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""
Email megerősítés token alapján + automatikus bejelentkezés (Magic Link).
Sikeres verifikáció után azonnal JWT tokent ad vissza, így a felhasználó
bejelentkezik anélkül, hogy külön be kellene írnia a jelszavát.
"""
user = await AuthService.verify_email(db, request.token)
if not user:
raise HTTPException(status_code=400, detail=t("AUTH.INVALID_OR_EXPIRED_TOKEN"))
# ── Device Fingerprint Tracking (Magic Link) ──
if request.device_fingerprint:
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
existing_device = await db.execute(
select(Device).where(Device.fingerprint_hash == request.device_fingerprint)
)
device = existing_device.scalar_one_or_none()
if not device:
# Új eszköz létrehozása
device = Device(
fingerprint_hash=request.device_fingerprint,
risk_score=100,
is_banned=False
)
db.add(device)
# 2. Ellenőrizzük a UserDeviceLink-et
existing_link = await db.execute(
select(UserDeviceLink).where(
UserDeviceLink.user_id == user.id,
UserDeviceLink.device_hash == request.device_fingerprint
)
)
link = existing_link.scalar_one_or_none()
now = datetime.now(timezone.utc)
if link:
# Létező kapcsolat frissítése
link.last_seen_at = now
link.login_count = link.login_count + 1
else:
# Új kapcsolat létrehozása
link = UserDeviceLink(
user_id=user.id,
device_hash=request.device_fingerprint,
first_seen_at=now,
last_seen_at=now,
login_count=1
)
db.add(link)
await db.commit()
# Generálj JWT tokent a most aktivált felhasználóhoz (ugyanaz a logika, mint login-nál)
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
role_key = role_name.upper()
token_data = {
"sub": str(user.id),
"role": role_name,
"rank": ranks.get(role_key, 10),
"scope_level": user.scope_level or "individual",
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
}
# Magic Link esetén ne használjunk remember_me-t (alapértelmezett 1 napos token)
access, refresh = create_tokens(data=token_data, remember_me=False)
# Refresh token lejárat
max_age_days = await config.get_setting(db, "auth_refresh_default_days", default=1)
max_age_sec = int(max_age_days) * 24 * 60 * 60
response.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=True,
samesite="lax",
domain=settings.COOKIE_DOMAIN, # Cross-subdomain SSO
max_age=max_age_sec
)
return {"access_token": access, "refresh_token": refresh, "token_type": "bearer", "is_active": user.is_active}
class ResendVerificationRequest(BaseModel):
email: EmailStr = Field(..., description="Email cím az aktiváló levél újraküldéséhez")
@router.post("/resend-verification")
async def resend_verification(request: ResendVerificationRequest, db: AsyncSession = Depends(get_db)):
"""
Aktiváló e-mail újraküldése.
Mindig sikeres választ ad, hogy megelőzzük az email enumerációt.
"""
result = await AuthService.resend_verification(db, request.email)
return {
"status": "success",
"message": "Ha ez az email cím regisztrálva van és még nem aktív, akkor elküldtük az aktiváló e-mailt."
}
class ForgotPasswordRequest(BaseModel):
email: EmailStr = Field(..., description="Email cím a jelszó visszaállításhoz")
@router.post("/forgot-password")
async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
"""
Elfelejtett jelszó folyamat indítása.
DEBUG MODE: Explicit hibákat dob az email küldési problémák diagnosztizálásához.
"""
result = await AuthService.initiate_password_reset(db, request.email)
# DEBUG: Ha nem található a user, dobjunk explicit hibát
if result == "not_found":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A felhasználó nem található ezzel az email címmel."
)
# Ha success, adjuk vissza a sikeres választ
return {
"status": "success",
"message": "Ha ez az email cím regisztrálva van, akkor elküldtünk egy jelszó-visszaállítási linket."
}
class ResetPasswordRequest(BaseModel):
email: EmailStr = Field(..., description="Email cím")
token: str = Field(..., description="Jelszó visszaállítási token")
new_password: str = Field(..., description="Új jelszó")
@router.post("/reset-password")
async def reset_password(request: ResetPasswordRequest, db: AsyncSession = Depends(get_db)):
"""
Jelszó visszaállítása token alapján.
"""
success = await AuthService.reset_password(db, request.email, request.token, request.new_password)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Érvénytelen token, lejárt token vagy nem létező felhasználó."
)
return {"status": "success", "message": "Jelszó sikeresen megváltoztatva."}
@router.post("/complete-kyc")
async def complete_kyc(kyc_in: UserKYCComplete, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
user = await AuthService.complete_kyc(db, current_user.id, kyc_in)
if not user:
raise HTTPException(status_code=404, detail="User nem található.")
return {"status": "success", "message": "Fiók aktiválva."}
@router.get("/me")
async def get_current_user_profile(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Return current user profile (alias for /users/me).
Uses _build_user_response() to ensure proper address field mapping.
"""
from app.schemas.user import UserResponse
from app.api.v1.endpoints.users import _build_user_response
response_data = await _build_user_response(current_user, db=db)
return UserResponse.model_validate(response_data)
class LanguageUpdateRequest(BaseModel):
preferred_language: str = Field(
..., min_length=2, max_length=10,
description="BCP 47 language code, e.g. 'hu', 'en'"
)
@router.patch("/me/language")
async def update_user_language(
request: LanguageUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
PATCH /auth/me/language
Update the authenticated user's preferred language.
Saves the language preference to the user's profile so it persists
across sessions and devices.
"""
current_user.preferred_language = request.preferred_language
db.add(current_user)
await db.commit()
await db.refresh(current_user)
return {
"status": "success",
"preferred_language": current_user.preferred_language,
}
# ── 30 NAPOS FIÓKVISSZAÁLLÍTÁS (RESTORE) ──
class RestoreRequestIn(BaseModel):
email: EmailStr = Field(..., description="A törölt fiók email címe")
class RestoreVerifyIn(BaseModel):
email: EmailStr = Field(..., description="A törölt fiók email címe")
code: str = Field(..., min_length=6, max_length=6, description="6-jegyű visszaállítási kód")
@router.post("/restore/request")
async def restore_account_request(
request: RestoreRequestIn,
db: AsyncSession = Depends(get_db),
):
"""
Fiók visszaállítási kód kérése.
Ha a fiók 30 napon belül lett törölve, 6-jegyű OTP kódot küld.
Ha 30 napon túl van, új regisztrációt engedélyez.
"""
result = await AuthService.request_account_restore(db, request.email)
return result
@router.post("/restore/verify")
async def restore_account_verify(
request: RestoreVerifyIn,
db: AsyncSession = Depends(get_db),
):
"""
Fiók visszaállítási kód ellenőrzése.
Sikeres ellenőrzés után visszaállítja a fiókot (is_active=True, is_deleted=False, deleted_at=None).
"""
result = await AuthService.verify_account_restore(db, request.email, request.code)
if result.get("status") != "success":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result.get("message", "Érvénytelen vagy lejárt kód.")
)
return result
# ── GOOGLE OAUTH ──
class GoogleAuthRequest(BaseModel):
"""Google token from the frontend after successful Google Sign-In.
The frontend sends either an id_token (from Google Sign-In / One Tap)
or an access_token (from Google OAuth2 token client).
"""
id_token: Optional[str] = Field(None, description="Google ID token (JWT) from Google Sign-In")
access_token: Optional[str] = Field(None, description="Google OAuth2 access token from token client")
referred_by_code: Optional[str] = Field(None, description="Optional referral code from registration form")
@router.post("/google", response_model=Token)
async def google_auth(
request: GoogleAuthRequest,
response: Response,
db: AsyncSession = Depends(get_db),
):
"""
Google OAuth bejelentkezés / regisztráció.
A frontend Google Sign-In gombjára kattintva a felhasználó Google tokent kap.
Ezt a tokent küldi el a backendnek, amely:
1. Ellenőrzi a token érvényességét a Google nyilvános kulcsaival
2. Kinyeri az email-t, nevet és Google ID-t
3. Ha a felhasználó már létezik (Login): visszaadja a JWT tokent
4. Ha nem létezik (Register): létrehozza az új felhasználót a Google adataival
5. Opcionális referral_code tárolása, ha a regisztrációs űrlapon megadták
Supports both id_token (from Google Sign-In) and access_token (from OAuth2 token client).
"""
try:
# 1. Determine which token was provided and build verification URL
if request.id_token:
google_verify_url = f"https://oauth2.googleapis.com/tokeninfo?id_token={request.id_token}"
elif request.access_token:
google_verify_url = f"https://oauth2.googleapis.com/tokeninfo?access_token={request.access_token}"
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Hiányzó Google token. Adjon meg id_token vagy access_token értéket."
)
# 2. Verify Google token
async with httpx.AsyncClient() as client:
google_resp = await client.get(google_verify_url)
if google_resp.status_code != 200:
logger.error(f"Google token verification failed: {google_resp.text}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen Google token."
)
google_data = google_resp.json()
# 3. Validate audience (client ID) — only for id_token
# For access_token, the 'aud' field may not be present; validate by 'azp' or skip
if request.id_token:
if google_data.get("aud") != settings.GOOGLE_CLIENT_ID:
logger.error(f"Google token audience mismatch: {google_data.get('aud')}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen Google token (audience mismatch)."
)
else:
# For access_token, check azp (authorized party) matches our client ID
if google_data.get("azp") != settings.GOOGLE_CLIENT_ID:
logger.error(f"Google token azp mismatch: {google_data.get('azp')}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen Google token (azp mismatch)."
)
# 4. Extract user info
google_id = google_data.get("sub")
email = google_data.get("email", "")
first_name = google_data.get("given_name", "")
last_name = google_data.get("family_name", "")
if not google_id or not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Hiányzó Google felhasználói adatok."
)
# 5. Get or create user via SocialAuthService
user = await SocialAuthService.get_or_create_social_user(
db=db,
provider="google",
social_id=google_id,
email=email,
first_name=first_name,
last_name=last_name,
referred_by_code=request.referred_by_code
)
if not user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Hiba a felhasználó létrehozása során."
)
# 6. Generate JWT tokens (same logic as login)
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
role_key = role_name.upper()
token_data = {
"sub": str(user.id),
"role": role_name,
"rank": ranks.get(role_key, 10),
"scope_level": user.scope_level or "individual",
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
}
access, refresh = create_tokens(data=token_data, remember_me=False)
max_age_days = await config.get_setting(db, "auth_refresh_default_days", default=1)
max_age_sec = int(max_age_days) * 24 * 60 * 60
response.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=True,
samesite="lax",
domain=settings.COOKIE_DOMAIN,
max_age=max_age_sec
)
return {
"access_token": access,
"refresh_token": refresh,
"token_type": "bearer",
"is_active": user.is_active
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Google auth error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Google hitelesítési hiba: {str(e)}"
)