Files
service-finder/backend/app/api/v2/auth.py

263 lines
8.7 KiB
Python
Executable File

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."}