263 lines
8.7 KiB
Python
Executable File
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."}
|