Initial commit - Migrated to Dev environment
This commit is contained in:
262
backend/app/api/v2/auth.py
Executable file
262
backend/app/api/v2/auth.py
Executable file
@@ -0,0 +1,262 @@
|
||||
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