# /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" ) # ── Update last activity timestamp ── user.last_activity_at = datetime.now(timezone.utc) # ── 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")) # ── Update last activity timestamp ── user.last_activity_at = datetime.now(timezone.utc) # ── 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." ) # ── Update last activity timestamp ── user.last_activity_at = datetime.now(timezone.utc) # 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)}" )