import hashlib import secrets from datetime import datetime, timedelta, timezone from fastapi import APIRouter, HTTPException, Depends, Request, status, Query from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel, EmailStr, Field from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text, select from app.core.config import settings from app.core.security import get_password_hash, verify_password, create_access_token from app.api.deps import get_db from app.models.user import User from app.models.company import Company from app.services.email_manager import email_manager router = APIRouter(prefix="", tags=["Authentication V2"]) # ----------------------- # Pydantic request models # ----------------------- class RegisterIn(BaseModel): email: EmailStr password: str = Field(min_length=1, max_length=200) # policy endpointben first_name: str = Field(min_length=1, max_length=80) last_name: str = Field(min_length=1, max_length=80) class ResetPasswordIn(BaseModel): token: str new_password: str = Field(min_length=1, max_length=200) # ----------------------- # Helpers # ----------------------- def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def _enforce_password_policy(password: str) -> None: # Most: teszt policy (min length), később bővítjük nagybetű/szám/special szabályokra min_len = int(getattr(settings, "PASSWORD_MIN_LENGTH", 4) or 4) if len(password) < min_len: raise HTTPException( status_code=400, detail={ "code": "password_policy_failed", "message": "A jelszó nem felel meg a biztonsági szabályoknak.", "rules": [f"Minimum hossz: {min_len} karakter"], }, ) async def _mark_token_used(db: AsyncSession, token_id: int) -> None: await db.execute( text( "UPDATE data.verification_tokens " "SET is_used = true, used_at = now() " "WHERE id = :id" ), {"id": token_id}, ) # ----------------------- # Endpoints # ----------------------- @router.post("/register") async def register(payload: RegisterIn, request: Request, db: AsyncSession = Depends(get_db)): _enforce_password_policy(payload.password) # email unique (később: soft-delete esetén engedjük az újra-reget a szabályaid szerint) res = await db.execute(select(User).where(User.email == payload.email)) if res.scalars().first(): raise HTTPException(status_code=400, detail="Ez az e-mail cím már foglalt.") # create inactive user new_user = User( email=payload.email, hashed_password=get_password_hash(payload.password), first_name=payload.first_name, last_name=payload.last_name, is_active=False, ) db.add(new_user) await db.flush() # create default private company new_company = Company(name=f"{payload.first_name} Privát Széfje", owner_id=new_user.id) db.add(new_company) await db.flush() # membership (enum miatt raw SQL) await db.execute( text( "INSERT INTO data.company_members (company_id, user_id, role, is_active) " "VALUES (:c, :u, 'owner'::data.companyrole, true)" ), {"c": new_company.id, "u": new_user.id}, ) # verification token (store hash only) token = secrets.token_urlsafe(48) token_hash = _hash_token(token) expires_at = datetime.now(timezone.utc) + timedelta(hours=48) await db.execute( text( "INSERT INTO data.verification_tokens (user_id, token_hash, token_type, expires_at, is_used) " "VALUES (:u, :t, 'email_verify'::data.tokentype, :e, false)" ), {"u": new_user.id, "t": token_hash, "e": expires_at}, ) await db.commit() # Send email (best-effort) try: link = f"{settings.FRONTEND_BASE_URL}/verify?token={token}" await email_manager.send_email( payload.email, "registration", {"first_name": payload.first_name, "link": link}, user_id=new_user.id, ) except Exception: # tesztben nem állítjuk meg a regisztrációt email hiba miatt pass return {"message": "Sikeres regisztráció! Kérlek aktiváld az emailedet."} @router.get("/verify") async def verify_email(token: str, db: AsyncSession = Depends(get_db)): token_hash = _hash_token(token) res = await db.execute( text( "SELECT id, user_id, expires_at, is_used " "FROM data.verification_tokens " "WHERE token_hash = :h " " AND token_type = 'email_verify'::data.tokentype " " AND is_used = false " "LIMIT 1" ), {"h": token_hash}, ) row = res.fetchone() if not row: raise HTTPException(status_code=400, detail="Érvénytelen vagy már felhasznált token.") # expired? -> mark used (audit) and reject if row.expires_at is not None and row.expires_at < datetime.now(timezone.utc): await _mark_token_used(db, row.id) await db.commit() raise HTTPException(status_code=400, detail="A token lejárt. Kérj újat.") # activate user await db.execute( text("UPDATE data.users SET is_active = true WHERE id = :u"), {"u": row.user_id}, ) # mark used (one-time) await _mark_token_used(db, row.id) await db.commit() return {"message": "Fiók aktiválva. Most már be tudsz jelentkezni."} @router.post("/login") async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)): res = await db.execute( text("SELECT id, hashed_password, is_active, is_superuser FROM data.users WHERE email = :e"), {"e": form_data.username}, ) u = res.fetchone() if not u or not verify_password(form_data.password, u.hashed_password): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Hibás hitelesítés.") if not u.is_active: raise HTTPException(status_code=400, detail="Fiók nem aktív.") token = create_access_token({"sub": str(u.id), "is_admin": bool(u.is_superuser)}) return {"access_token": token, "token_type": "bearer"} @router.post("/forgot-password") async def forgot_password(email: EmailStr = Query(...), db: AsyncSession = Depends(get_db)): # Anti-enumeration: mindig ugyanazt válaszoljuk res = await db.execute(select(User).where(User.email == email)) user = res.scalars().first() if user: token = secrets.token_urlsafe(48) token_hash = _hash_token(token) expires_at = datetime.now(timezone.utc) + timedelta(hours=2) await db.execute( text( "INSERT INTO data.verification_tokens (user_id, token_hash, token_type, expires_at, is_used) " "VALUES (:u, :t, 'password_reset'::data.tokentype, :e, false)" ), {"u": user.id, "t": token_hash, "e": expires_at}, ) await db.commit() link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token}" try: await email_manager.send_email( email, "password_reset", {"first_name": user.first_name or "", "link": link}, user_id=user.id, ) except Exception: pass return {"message": "Ha a cím létezik, a helyreállító levelet elküldtük."} @router.post("/reset-password-confirm") async def reset_password_confirm(payload: ResetPasswordIn, db: AsyncSession = Depends(get_db)): _enforce_password_policy(payload.new_password) token_hash = _hash_token(payload.token) res = await db.execute( text( "SELECT id, user_id, expires_at, is_used " "FROM data.verification_tokens " "WHERE token_hash = :h " " AND token_type = 'password_reset'::data.tokentype " " AND is_used = false " "LIMIT 1" ), {"h": token_hash}, ) row = res.fetchone() if not row: raise HTTPException(status_code=400, detail="Érvénytelen vagy már felhasznált token.") if row.expires_at is not None and row.expires_at < datetime.now(timezone.utc): await _mark_token_used(db, row.id) await db.commit() raise HTTPException(status_code=400, detail="A token lejárt. Kérj újat.") new_hash = get_password_hash(payload.new_password) await db.execute( text("UPDATE data.users SET hashed_password = :p WHERE id = :u"), {"p": new_hash, "u": row.user_id}, ) await _mark_token_used(db, row.id) await db.commit() return {"message": "Jelszó sikeresen megváltoztatva."}