feat: implement hybrid address system and premium search logic
- Added centralized, self-learning GeoService (ZIP, City, Street) - Implemented Hybrid Address Management (Centralized table + Denormalized fields) - Fixed Gamification logic (PointsLedger field names & filtering) - Added address autocomplete and two-tier (Free/Premium) search API - Synchronized UserStats and PointsLedger schemas
This commit is contained in:
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
from typing import AsyncGenerator
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -6,46 +6,50 @@ from sqlalchemy import select
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.core.security import decode_token
|
||||
from app.models.identity import User # Javítva identity-re
|
||||
from app.models.identity import User
|
||||
|
||||
# Javítva v1-re
|
||||
# Az OAuth2 séma definiálása, ami a tokent keresi a Headerben
|
||||
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
async def get_current_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token: str = Depends(reusable_oauth2),
|
||||
) -> User:
|
||||
"""
|
||||
Dependency, amely visszaadja az aktuálisan bejelentkezett felhasználót.
|
||||
Ha a token érvénytelen vagy a felhasználó nem létezik, hibát dob.
|
||||
"""
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Érvénytelen vagy lejárt token."
|
||||
detail="Érvénytelen vagy lejárt munkamenet."
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
user_id: str = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token azonosítási hiba."
|
||||
)
|
||||
|
||||
# Felhasználó keresése az adatbázisban
|
||||
res = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = res.scalar_one_or_none()
|
||||
# Felhasználó lekérése az adatbázisból
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Felhasználó nem található."
|
||||
detail="A felhasználó nem található."
|
||||
)
|
||||
|
||||
if user.is_deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Ez a fiók törölve lett."
|
||||
detail="Ez a fiók korábban törlésre került."
|
||||
)
|
||||
|
||||
# FONTOS: Itt NEM dobunk hibát, ha user.is_active == False,
|
||||
# mert a Step 2 (KYC) kitöltéséhez be kell tudnia lépni inaktívként is!
|
||||
# Megjegyzés: is_active ellenőrzést szándékosan nem teszünk itt,
|
||||
# hogy a KYC folyamatot (Step 2) be tudja fejezni a még nem aktív user is.
|
||||
|
||||
return user
|
||||
Binary file not shown.
@@ -1,11 +1,14 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1.endpoints import auth, catalog, assets, organizations, documents # <--- Ide bekerült a documents!
|
||||
from app.api.v1.endpoints import auth, catalog, assets, organizations, documents, services
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Hitelesítés
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
||||
|
||||
# Szolgáltatások és Vadászat (Ez az új rész!)
|
||||
api_router.include_router(services.router, prefix="/services", tags=["Service Hunt & Discovery"])
|
||||
|
||||
# Katalógus
|
||||
api_router.include_router(catalog.router, prefix="/catalog", tags=["Vehicle Catalog"])
|
||||
|
||||
@@ -15,5 +18,5 @@ api_router.include_router(assets.router, prefix="/assets", tags=["Assets"])
|
||||
# Szervezetek
|
||||
api_router.include_router(organizations.router, prefix="/organizations", tags=["Organizations"])
|
||||
|
||||
# DOKUMENTUMOK (Ez az új rész, ami hiányzik neked)
|
||||
# Dokumentumok
|
||||
api_router.include_router(documents.router, prefix="/documents", tags=["Documents"])
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,48 +1,75 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.services.auth_service import AuthService
|
||||
from app.core.security import create_access_token
|
||||
from app.schemas.auth import UserLiteRegister, Token, PasswordResetRequest, UserKYCComplete
|
||||
from app.api.deps import get_current_user # Ez kezeli a belépett felhasználót
|
||||
from app.schemas.auth import (
|
||||
UserLiteRegister, Token, PasswordResetRequest,
|
||||
UserKYCComplete, PasswordResetConfirm
|
||||
)
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.identity import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/register-lite", response_model=Token, status_code=201)
|
||||
@router.post("/register-lite", response_model=Token, status_code=status.HTTP_201_CREATED)
|
||||
async def register_lite(user_in: UserLiteRegister, db: AsyncSession = Depends(get_db)):
|
||||
"""Step 1: Alapszintű regisztráció és aktiváló e-mail küldése."""
|
||||
check = await db.execute(text("SELECT id FROM data.users WHERE email = :e"), {"e": user_in.email})
|
||||
if check.fetchone():
|
||||
raise HTTPException(status_code=400, detail="Ez az email cím már foglalt.")
|
||||
"""Step 1: Alapszintű regisztráció (Email + Jelszó)."""
|
||||
stmt = select(User).where(User.email == user_in.email)
|
||||
result = await db.execute(stmt)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ez az e-mail cím már regisztrálva van."
|
||||
)
|
||||
|
||||
try:
|
||||
user = await AuthService.register_lite(db, user_in)
|
||||
token = create_access_token(data={"sub": str(user.id)})
|
||||
return {"access_token": token, "token_type": "bearer", "is_active": user.is_active}
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"is_active": user.is_active
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Szerver hiba: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Sikertelen regisztráció: {str(e)}"
|
||||
)
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||
"""Bejelentkezés az access_token megszerzéséhez."""
|
||||
async def login(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
form_data: OAuth2PasswordRequestForm = Depends()
|
||||
):
|
||||
"""Bejelentkezés és Access Token generálása."""
|
||||
user = await AuthService.authenticate(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Hibás e-mail vagy jelszó.")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Hibás e-mail cím vagy jelszó."
|
||||
)
|
||||
|
||||
token = create_access_token(data={"sub": str(user.id)})
|
||||
return {"access_token": token, "token_type": "bearer", "is_active": user.is_active}
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"is_active": user.is_active
|
||||
}
|
||||
|
||||
@router.get("/verify-email")
|
||||
async def verify_email(token: str, db: AsyncSession = Depends(get_db)):
|
||||
"""E-mail megerősítése a kiküldött token alapján."""
|
||||
"""E-mail megerősítése a kiküldött link alapján."""
|
||||
success = await AuthService.verify_email(db, token)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Érvénytelen vagy lejárt token.")
|
||||
return {"message": "Email sikeresen megerősítve! Jöhet a Step 2 (KYC)."}
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Érvénytelen vagy lejárt megerősítő token."
|
||||
)
|
||||
return {"message": "Email sikeresen megerősítve! Jöhet a profil kitöltése (KYC)."}
|
||||
|
||||
@router.post("/complete-kyc")
|
||||
async def complete_kyc(
|
||||
@@ -50,14 +77,38 @@ async def complete_kyc(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Step 2: Okmányok rögzítése, Privát Széf és Wallet aktiválása."""
|
||||
"""Step 2: Személyes adatok és okmányok rögzítése."""
|
||||
user = await AuthService.complete_kyc(db, current_user.id, kyc_in)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Felhasználó nem található.")
|
||||
return {"status": "success", "message": "Gratulálunk! A Privát Széf és a Pénztárca aktiválva lett."}
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Felhasználó nem található.")
|
||||
return {"status": "success", "message": "A profil adatok rögzítve, a rendszer aktiválva."}
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password(req: PasswordResetRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Jelszó-visszaállító link küldése."""
|
||||
await AuthService.initiate_password_reset(db, req.email)
|
||||
return {"message": "Ha a cím létezik, elküldtük a helyreállítási linket."}
|
||||
"""Elfelejtett jelszó folyamat indítása biztonsági korlátokkal."""
|
||||
result = await AuthService.initiate_password_reset(db, req.email)
|
||||
|
||||
if result == "cooldown":
|
||||
raise HTTPException(status_code=429, detail="Kérjük várjon 2 percet az újabb kérés előtt.")
|
||||
if result in ["hourly_limit", "daily_limit"]:
|
||||
raise HTTPException(status_code=429, detail="Túllépte a napi/óránkénti próbálkozások számát.")
|
||||
|
||||
return {"message": "Amennyiben a megadott e-mail cím szerepel a rendszerünkben, kiküldtük a linket."}
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password(req: PasswordResetConfirm, db: AsyncSession = Depends(get_db)):
|
||||
"""Új jelszó beállítása. Backend ellenőrzi az egyezőséget és a tokent."""
|
||||
if req.password != req.password_confirm:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="A két jelszó nem egyezik meg."
|
||||
)
|
||||
|
||||
success = await AuthService.reset_password(db, req.email, req.token, req.password)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Érvénytelen adatok vagy lejárt token."
|
||||
)
|
||||
|
||||
return {"message": "A jelszó sikeresen frissítve! Most már bejelentkezhet."}
|
||||
86
backend/app/api/v1/endpoints/services.py
Normal file
86
backend/app/api/v1/endpoints/services.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from fastapi import APIRouter, Depends, Form, Query, UploadFile, File
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional, List
|
||||
from app.db.session import get_db
|
||||
from app.services.geo_service import GeoService
|
||||
from app.services.gamification_service import GamificationService
|
||||
from app.services.config_service import config
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/suggest-street")
|
||||
async def suggest_street(zip_code: str, q: str, db: AsyncSession = Depends(get_db)):
|
||||
"""Azonnali utca javaslatok gépelés közben."""
|
||||
return await GeoService.get_street_suggestions(db, zip_code, q)
|
||||
|
||||
@router.post("/hunt")
|
||||
async def register_service_hunt(
|
||||
name: str = Form(...),
|
||||
zip_code: str = Form(...),
|
||||
city: str = Form(...),
|
||||
street_name: str = Form(...),
|
||||
street_type: str = Form(...),
|
||||
house_number: str = Form(...),
|
||||
parcel_id: Optional[str] = Form(None),
|
||||
latitude: float = Form(...),
|
||||
longitude: float = Form(...),
|
||||
user_latitude: float = Form(...),
|
||||
user_longitude: float = Form(...),
|
||||
current_user_id: int = 1,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
# 1. Hibrid címrögzítés
|
||||
addr_id = await GeoService.get_or_create_full_address(
|
||||
db, zip_code, city, street_name, street_type, house_number, parcel_id
|
||||
)
|
||||
|
||||
# 2. Távolságmérés
|
||||
dist_query = text("""
|
||||
SELECT ST_Distance(
|
||||
ST_SetSRID(ST_MakePoint(:u_lon, :u_lat), 4326)::geography,
|
||||
ST_SetSRID(ST_MakePoint(:s_lon, :s_lat), 4326)::geography
|
||||
)
|
||||
""")
|
||||
distance = (await db.execute(dist_query, {
|
||||
"u_lon": user_longitude, "u_lat": user_latitude,
|
||||
"s_lon": longitude, "s_lat": latitude
|
||||
})).scalar() or 0.0
|
||||
|
||||
# 3. Mentés (Denormalizált adatokkal a sebességért)
|
||||
await db.execute(text("""
|
||||
INSERT INTO data.organization_locations
|
||||
(name, address_id, coordinates, proposed_by, zip_code, city, street, house_number, sources, confidence_score)
|
||||
VALUES (:n, :aid, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, :uid, :z, :c, :s, :hn, jsonb_build_array(CAST('user_hunt' AS TEXT)), 1)
|
||||
"""), {
|
||||
"n": name, "aid": addr_id, "lon": longitude, "lat": latitude,
|
||||
"uid": current_user_id, "z": zip_code, "c": city, "s": f"{street_name} {street_type}", "hn": house_number
|
||||
})
|
||||
|
||||
# 4. Jutalmazás
|
||||
await GamificationService.award_points(db, current_user_id, 50, f"Service Hunt: {city}")
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "address_id": str(addr_id), "distance_meters": round(distance, 2)}
|
||||
|
||||
@router.get("/search")
|
||||
async def search_services(
|
||||
lat: float, lng: float,
|
||||
is_premium: bool = False,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Kétlépcsős keresés: Free (Légvonal) vs Premium (Útvonal/Idő)"""
|
||||
query = text("""
|
||||
SELECT name, city, ST_Distance(coordinates, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography) as dist
|
||||
FROM data.organization_locations WHERE is_verified = TRUE ORDER BY dist LIMIT 10
|
||||
""")
|
||||
res = (await db.execute(query, {"lat": lat, "lng": lng})).fetchall()
|
||||
|
||||
results = []
|
||||
for row in res:
|
||||
item = {"name": row[0], "city": row[1], "distance_km": round(row[2]/1000, 2)}
|
||||
if is_premium:
|
||||
# PRÉMIUM: Itt jönne az útvonaltervező API integráció
|
||||
item["estimated_travel_time_min"] = round(row[2] / 700) # Becsült
|
||||
results.append(item)
|
||||
return results
|
||||
Binary file not shown.
@@ -1,262 +0,0 @@
|
||||
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."}
|
||||
Reference in New Issue
Block a user