709 lines
31 KiB
Python
Executable File
709 lines
31 KiB
Python
Executable File
# /opt/docker/dev/service_finder/backend/app/services/auth_service.py
|
|
import logging
|
|
import uuid
|
|
import re
|
|
from datetime import datetime, timedelta, timezone
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, and_, update
|
|
from sqlalchemy.orm import joinedload
|
|
from fastapi.encoders import jsonable_encoder
|
|
from fastapi import HTTPException, status
|
|
|
|
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet, OneTimePassword
|
|
from app.models import UserStats, LogSeverity
|
|
from app.models.marketplace import Organization, OrganizationMember, OrgType, Branch
|
|
from app.schemas.auth import UserLiteRegister, UserKYCComplete
|
|
from app.core.security import get_password_hash, verify_password, generate_secure_slug
|
|
from app.services.email_manager import email_manager
|
|
from app.core.config import settings
|
|
from app.services.config_service import config
|
|
from app.services.geo_service import GeoService
|
|
from app.services.security_service import security_service
|
|
from app.services.gamification_service import GamificationService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class AuthService:
|
|
@staticmethod
|
|
async def _validate_password_complexity(db: AsyncSession, password: str, region_code: str = None):
|
|
"""
|
|
Dinamikus jelszó komplexitás ellenőrzése az admin beállítások alapján.
|
|
"""
|
|
# Alap beállítások lekérése
|
|
min_pass = await config.get_setting(db, "auth_min_password_length", default=8)
|
|
password_strict = await config.get_setting(db, "auth_password_strict", default=False)
|
|
|
|
# Minimum hossz ellenőrzése
|
|
if len(password) < int(min_pass):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"A jelszónak legalább {min_pass} karakter hosszúnak kell lennie."
|
|
)
|
|
|
|
# Ha a strict mód be van kapcsolva, komplexitás ellenőrzés
|
|
if password_strict and str(password_strict).lower() in ("true", "1", "yes"):
|
|
# Ellenőrizzük: legalább 1 nagybetű, 1 kisbetű, 1 szám vagy speciális karakter
|
|
if not re.search(r'[A-Z]', password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="A jelszónak tartalmaznia kell legalább egy nagybetűt."
|
|
)
|
|
if not re.search(r'[a-z]', password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="A jelszónak tartalmaznia kell legalább egy kisbetűt."
|
|
)
|
|
if not re.search(r'[0-9!@#$%^&*()_+\-=\[\]{};:"\\|,.<>/?]', password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="A jelszónak tartalmaznia kell legalább egy számot vagy speciális karaktert."
|
|
)
|
|
|
|
@staticmethod
|
|
async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
|
|
""" 1. FÁZIS: Lite regisztráció dinamikus korlátokkal és Sentinel naplózással. """
|
|
try:
|
|
# Paraméterek lekérése az admin felületről
|
|
min_pass = await config.get_setting(db, "auth_min_password_length", default=8)
|
|
default_role_name = await config.get_setting(db, "auth_default_role", default="user")
|
|
reg_token_hours = await config.get_setting(db, "auth_registration_hours", region_code=user_in.region_code, default=48)
|
|
|
|
# Jelszó komplexitás ellenőrzése
|
|
await AuthService._validate_password_complexity(db, user_in.password, user_in.region_code)
|
|
|
|
# Check if email already exists
|
|
existing_user = await db.execute(select(User).where(User.email == user_in.email))
|
|
if existing_user.scalar_one_or_none():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Ez az email cím már regisztrálva van."
|
|
)
|
|
|
|
# Meghívó keresése referral kód alapján
|
|
referred_by_id = None
|
|
if user_in.referred_by_code:
|
|
referrer_stmt = select(User).where(User.referral_code == user_in.referred_by_code)
|
|
referrer = (await db.execute(referrer_stmt)).scalar_one_or_none()
|
|
if referrer:
|
|
referred_by_id = referrer.id
|
|
logger.info(f"User {user_in.email} referred by {referrer.email} (ID: {referrer.id})")
|
|
else:
|
|
logger.warning(f"Referral code '{user_in.referred_by_code}' not found, ignoring.")
|
|
|
|
new_person = Person(
|
|
first_name=user_in.first_name,
|
|
last_name=user_in.last_name,
|
|
is_active=False, # Email verification required before activation
|
|
identity_docs={}, # EXPLICIT BEÁLLÍTÁS A DB HIBA ELKERÜLÉSÉRE
|
|
ice_contact={}, # EXPLICIT BEÁLLÍTÁS A DB HIBA ELKERÜLÉSÉRE
|
|
lifetime_xp=0, # default -1, de explicit 0
|
|
penalty_points=0, # default -1, de explicit 0
|
|
social_reputation=1.0, # default 0.0, de explicit 1.0
|
|
is_sales_agent=False, # default True, de explicit False
|
|
is_ghost=False,
|
|
created_at=datetime.now(timezone.utc)
|
|
)
|
|
db.add(new_person)
|
|
await db.flush()
|
|
|
|
# Szerepkör dinamikus feloldása
|
|
assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.USER
|
|
|
|
# Referral kód generálása
|
|
referral_code = generate_secure_slug(8).upper()
|
|
|
|
new_user = User(
|
|
email=user_in.email,
|
|
hashed_password=get_password_hash(user_in.password),
|
|
person_id=new_person.id,
|
|
role=assigned_role,
|
|
is_active=False, # Email verification required before activation
|
|
is_deleted=False,
|
|
region_code=user_in.region_code,
|
|
preferred_language=user_in.lang,
|
|
subscription_plan='FREE',
|
|
# --- EXPLICIT DEFAULT ÉRTÉKEK A DB HIBA ELKERÜLÉSÉRE ---
|
|
is_vip=True, # Changed to True to force inclusion in INSERT
|
|
preferred_currency="HUF",
|
|
scope_level="individual",
|
|
custom_permissions={},
|
|
referral_code=referral_code,
|
|
referred_by_id=referred_by_id,
|
|
created_at=datetime.now(timezone.utc)
|
|
)
|
|
db.add(new_user)
|
|
await db.flush()
|
|
|
|
# Verifikációs token
|
|
token_val = uuid.uuid4()
|
|
db.add(VerificationToken(
|
|
token=token_val,
|
|
user_id=new_user.id,
|
|
token_type="registration",
|
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_token_hours))
|
|
))
|
|
|
|
# Sentinel Audit Log
|
|
await security_service.log_event(
|
|
db, user_id=new_user.id, action="USER_REGISTER_LITE",
|
|
severity=LogSeverity.info, target_type="User", target_id=str(new_user.id),
|
|
new_data={"email": user_in.email}
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
# Email küldés a beállított template alapján
|
|
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
|
|
email_result = await email_manager.send_email(
|
|
recipient=user_in.email,
|
|
template_key="reg",
|
|
variables={"first_name": user_in.first_name, "link": verification_link},
|
|
lang=user_in.lang
|
|
)
|
|
# Check if email sending failed - LOG but don't crash registration
|
|
if email_result and email_result.get("status") == "error":
|
|
logger.error(f"Email sending failed for {user_in.email}: {email_result.get('message')}")
|
|
# Don't raise exception - registration should succeed even if email fails
|
|
# Just log the error and continue
|
|
else:
|
|
logger.info(f"Email sent successfully to {user_in.email}")
|
|
|
|
return new_user
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Lite Reg Error: {e}")
|
|
raise e
|
|
|
|
@staticmethod
|
|
async def complete_kyc(db: AsyncSession, user_id: int, kyc_in: UserKYCComplete):
|
|
""" 2. FÁZIS: Teljes profil és Gamification inicializálás. """
|
|
try:
|
|
stmt = select(User).options(joinedload(User.person)).where(User.id == user_id)
|
|
user = (await db.execute(stmt)).scalar_one_or_none()
|
|
if not user: return None
|
|
|
|
# Dinamikus beállítások (Soha ne legyen kódba vésve!)
|
|
org_tpl = await config.get_setting(db, "org_naming_template", default="{last_name} Flotta")
|
|
base_cur = await config.get_setting(db, "finance_default_currency", region_code=user.region_code, default="HUF")
|
|
kyc_reward = await config.get_setting(db, "gamification_kyc_bonus", default=500)
|
|
|
|
# Címkezelés (GeoService hívás)
|
|
addr_id = await GeoService.get_or_create_full_address(
|
|
db, zip_code=kyc_in.address_zip, city=kyc_in.address_city,
|
|
street_name=kyc_in.address_street_name, street_type=kyc_in.address_street_type,
|
|
house_number=kyc_in.address_house_number, parcel_id=kyc_in.address_hrsz
|
|
)
|
|
|
|
|
|
# Person adatok dúsítása
|
|
p = user.person
|
|
|
|
# --- SHADOW IDENTITY CHECK ---
|
|
if kyc_in.birth_date:
|
|
shadow_stmt = select(Person).where(
|
|
and_(
|
|
Person.last_name == p.last_name,
|
|
Person.first_name == p.first_name,
|
|
Person.birth_date == kyc_in.birth_date,
|
|
Person.id != p.id # Ne a jelenlegit találja meg
|
|
)
|
|
)
|
|
shadow_person = (await db.execute(shadow_stmt)).scalar_one_or_none()
|
|
else:
|
|
shadow_person = None
|
|
|
|
if shadow_person:
|
|
logger.info(f"Shadow Identity megtalálva a {user.id} userhez: Person ID {shadow_person.id}")
|
|
user.person_id = shadow_person.id
|
|
|
|
# Frissítjük a megtalált Person rekordot a KYC adatokkal
|
|
if kyc_in.first_name: shadow_person.first_name = kyc_in.first_name
|
|
if kyc_in.last_name: shadow_person.last_name = kyc_in.last_name
|
|
shadow_person.mothers_last_name = kyc_in.mothers_last_name
|
|
shadow_person.mothers_first_name = kyc_in.mothers_first_name
|
|
shadow_person.birth_place = kyc_in.birth_place
|
|
shadow_person.phone = kyc_in.phone_number
|
|
shadow_person.address_id = addr_id
|
|
shadow_person.identity_docs = jsonable_encoder(kyc_in.identity_docs)
|
|
shadow_person.is_active = True
|
|
|
|
# Inaktiváljuk/töröljük a megárvult eredeti Person-t
|
|
p.is_active = False
|
|
p.is_ghost = True
|
|
|
|
p = shadow_person
|
|
else:
|
|
if kyc_in.first_name: p.first_name = kyc_in.first_name
|
|
if kyc_in.last_name: p.last_name = kyc_in.last_name
|
|
p.mothers_last_name = kyc_in.mothers_last_name
|
|
p.mothers_first_name = kyc_in.mothers_first_name
|
|
p.birth_place = kyc_in.birth_place
|
|
p.birth_date = kyc_in.birth_date
|
|
p.phone = kyc_in.phone_number
|
|
p.address_id = addr_id
|
|
p.identity_docs = jsonable_encoder(kyc_in.identity_docs)
|
|
p.is_active = True
|
|
# --- SHADOW CHECK VÉGE ---
|
|
|
|
|
|
# Dinamikus szervezet generálás
|
|
org_full_name = org_tpl.format(last_name=p.last_name, first_name=p.first_name)
|
|
new_org = Organization(
|
|
full_name=org_full_name,
|
|
name=f"{p.last_name} Garázsa",
|
|
folder_slug=generate_secure_slug(12),
|
|
org_type=OrgType.individual,
|
|
owner_id=user.id,
|
|
is_active=True,
|
|
status="verified",
|
|
country_code=user.region_code,
|
|
# --- EXPLICIT IDŐBÉLYEGEK A DB HIBA ELKERÜLÉSÉRE ---
|
|
first_registered_at=datetime.now(timezone.utc),
|
|
current_lifecycle_started_at=datetime.now(timezone.utc),
|
|
created_at=datetime.now(timezone.utc),
|
|
subscription_plan="FREE",
|
|
base_asset_limit=1,
|
|
purchased_extra_slots=0,
|
|
notification_settings={},
|
|
external_integration_config={},
|
|
is_ownership_transferable=True
|
|
)
|
|
db.add(new_org)
|
|
await db.flush()
|
|
|
|
# Infrastruktúra elemek
|
|
db.add(Branch(organization_id=new_org.id, address_id=addr_id, name="Home Base", is_main=True))
|
|
db.add(OrganizationMember(
|
|
organization_id=new_org.id,
|
|
user_id=user.id,
|
|
person_id=user.person_id,
|
|
role="OWNER",
|
|
is_permanent=True,
|
|
is_verified=True,
|
|
status="active"
|
|
))
|
|
db.add(Wallet(user_id=user.id, currency=kyc_in.preferred_currency or base_cur))
|
|
# db.add(UserStats(user_id=user.id)) # GamificationService kezeli
|
|
|
|
user.is_active = True
|
|
user.folder_slug = generate_secure_slug(12)
|
|
# Set user's scope_id to the new personal organization ID
|
|
user.scope_id = str(new_org.id)
|
|
# Update region_code from KYC form (EU country selection)
|
|
if kyc_in.region_code:
|
|
user.region_code = kyc_in.region_code
|
|
|
|
# Gamification XP jóváírás (commit=False, mert a külső metódus kezeli a tranzakciót)
|
|
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION", commit=False)
|
|
|
|
# P2P Referral XP a meghívónak (ha van referred_by_id)
|
|
if user.referred_by_id:
|
|
p2p_xp = await config.get_setting(db, "gamification_p2p_invite_xp", default=50)
|
|
await GamificationService.award_points(
|
|
db,
|
|
user_id=user.referred_by_id,
|
|
amount=int(p2p_xp),
|
|
reason="P2P_REFERRAL_SUCCESS",
|
|
commit=False
|
|
)
|
|
logger.info(f"P2P XP ({p2p_xp}) awarded to referrer ID {user.referred_by_id} for user {user.id}")
|
|
|
|
await db.commit()
|
|
return user
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"KYC Error: {e}")
|
|
raise e
|
|
|
|
@staticmethod
|
|
async def authenticate(db: AsyncSession, email: str, password: str):
|
|
""" Felhasználó hitelesítése. """
|
|
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
|
|
res = await db.execute(stmt)
|
|
user = res.scalar_one_or_none()
|
|
if user and verify_password(password, user.hashed_password):
|
|
return user
|
|
return None
|
|
|
|
@staticmethod
|
|
async def verify_email(db: AsyncSession, token_str: str):
|
|
"""
|
|
Email megerősítés.
|
|
Visszaadja az aktivált User objektumot sikeres verifikáció után (Magic Link támogatás).
|
|
"""
|
|
try:
|
|
token_uuid = uuid.UUID(token_str)
|
|
stmt = select(VerificationToken).where(and_(
|
|
VerificationToken.token == token_uuid,
|
|
VerificationToken.is_used == False,
|
|
VerificationToken.expires_at > datetime.now(timezone.utc)
|
|
))
|
|
token = (await db.execute(stmt)).scalar_one_or_none()
|
|
if not token: return None
|
|
|
|
token.is_used = True
|
|
|
|
# Activate user
|
|
user_stmt = select(User).where(User.id == token.user_id)
|
|
user = (await db.execute(user_stmt)).scalar_one_or_none()
|
|
if not user: return None
|
|
|
|
user.is_active = True
|
|
|
|
# Activate person - FIX: Use User.person_id instead of Person.user_id
|
|
# Person.user_id is a separate FK that may be NULL
|
|
if user.person_id:
|
|
person_stmt = select(Person).where(Person.id == user.person_id)
|
|
person = (await db.execute(person_stmt)).scalar_one_or_none()
|
|
if person:
|
|
person.is_active = True
|
|
# Also set the back-reference so Person.user_id is consistent
|
|
person.user_id = user.id
|
|
|
|
await db.commit()
|
|
return user # Return the activated User object
|
|
except Exception as e:
|
|
logger.error(f"Email verification error: {e}")
|
|
return None
|
|
|
|
@staticmethod
|
|
async def initiate_password_reset(db: AsyncSession, email: str):
|
|
""" Elfelejtett jelszó folyamat indítása. """
|
|
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
|
|
user = (await db.execute(stmt)).scalar_one_or_none()
|
|
|
|
if not user:
|
|
return "not_found"
|
|
|
|
# Ha a felhasználó létezik, de inaktív, dobjunk hibát
|
|
if not user.is_active:
|
|
logger.warning(f"Password reset requested for inactive user: {email}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="AUTH.USER_INACTIVE"
|
|
)
|
|
|
|
# Dinamikus lejárat az adminból
|
|
reset_h = await config.get_setting(db, "auth_password_reset_hours", region_code=user.region_code, default=2)
|
|
token_val = uuid.uuid4()
|
|
db.add(VerificationToken(
|
|
token=token_val, user_id=user.id, token_type="password_reset",
|
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_h))
|
|
))
|
|
|
|
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
|
|
|
|
# Mock email sending: log the reset link to console instead of sending real email
|
|
logger.info(f"*** MOCK EMAIL *** Password reset for {email}")
|
|
logger.info(f"*** MOCK EMAIL *** Reset link: {link}")
|
|
logger.info(f"*** MOCK EMAIL *** Token: {token_val}")
|
|
|
|
# Try to send real email, but don't crash if SMTP is unavailable
|
|
try:
|
|
result = await email_manager.send_email(
|
|
recipient=email, template_key="pwd_reset",
|
|
variables={"link": link}, lang=user.preferred_language
|
|
)
|
|
logger.info(f"Email küldés eredménye (password reset): {result}")
|
|
if result and result.get("status") == "error":
|
|
logger.warning(f"Password reset email sending failed (non-critical): {result.get('message')}")
|
|
except Exception as e:
|
|
logger.warning(f"Password reset email sending exception (non-critical): {e}")
|
|
|
|
await db.commit()
|
|
return "success"
|
|
|
|
@staticmethod
|
|
async def reset_password(db: AsyncSession, email: str, token_str: str, new_password: str):
|
|
""" Jelszó tényleges megváltoztatása token alapján. """
|
|
try:
|
|
token_uuid = uuid.UUID(token_str)
|
|
stmt = select(VerificationToken).join(User).where(and_(
|
|
User.email == email,
|
|
VerificationToken.token == token_uuid,
|
|
VerificationToken.token_type == "password_reset",
|
|
VerificationToken.is_used == False,
|
|
VerificationToken.expires_at > datetime.now(timezone.utc)
|
|
))
|
|
token_rec = (await db.execute(stmt)).scalar_one_or_none()
|
|
if not token_rec: return False
|
|
|
|
# Jelszó komplexitás ellenőrzése
|
|
user_stmt = select(User).where(User.id == token_rec.user_id)
|
|
user = (await db.execute(user_stmt)).scalar_one()
|
|
await AuthService._validate_password_complexity(db, new_password, user.region_code)
|
|
|
|
user.hashed_password = get_password_hash(new_password)
|
|
token_rec.is_used = True
|
|
|
|
await db.commit()
|
|
return True
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Password reset error: {e}")
|
|
return False
|
|
|
|
@staticmethod
|
|
async def _generate_otp_code() -> str:
|
|
"""Generate a random 6-digit OTP code."""
|
|
import random
|
|
return f"{random.randint(0, 999999):06d}"
|
|
|
|
@staticmethod
|
|
async def request_account_restore(db: AsyncSession, email: str):
|
|
"""
|
|
30 napos fiókvisszaállítás: OTP kérés.
|
|
Megkeresi a törölt felhasználót az eredeti email alapján.
|
|
Ha 30 napon belül van a törlés, generál 6-jegyű kódot.
|
|
Ha 30 napon TÚL van, jelzi, hogy új regisztráció szükséges.
|
|
"""
|
|
try:
|
|
# Search for deleted user by checking if email exists in deleted prefix pattern
|
|
stmt = select(User).where(
|
|
User.email.like(f"%{email}"),
|
|
User.is_deleted == True
|
|
)
|
|
result = await db.execute(stmt)
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user:
|
|
return {"status": "not_found", "message": "Nem található törölt fiók ezzel az email címmel."}
|
|
|
|
# Check if within 30-day window
|
|
if user.deleted_at:
|
|
days_since_deletion = (datetime.now(timezone.utc) - user.deleted_at).days
|
|
else:
|
|
days_since_deletion = 0
|
|
|
|
if days_since_deletion > 30:
|
|
return {
|
|
"status": "expired",
|
|
"message": "A fiók véglegesen megsemmisült. Kérjük, regisztráljon újra ugyanazzal az email címmel.",
|
|
"can_re_register": True,
|
|
"original_email": email
|
|
}
|
|
|
|
# Generate 6-digit OTP code
|
|
code = await AuthService._generate_otp_code()
|
|
expires_at = datetime.now(timezone.utc) + timedelta(minutes=15)
|
|
|
|
# Store OTP
|
|
otp = OneTimePassword(
|
|
email=email,
|
|
code=code,
|
|
otp_type="restore",
|
|
extra_data={"user_id": user.id},
|
|
expires_at=expires_at
|
|
)
|
|
db.add(otp)
|
|
await db.commit()
|
|
|
|
# Mock email sending: log the OTP code to console instead of sending real email
|
|
logger.info(f"*** MOCK EMAIL *** OTP Code for {email}: {code}")
|
|
logger.info(f"*** MOCK EMAIL *** Expires at: {expires_at.isoformat()}")
|
|
|
|
return {
|
|
"status": "otp_sent",
|
|
"message": "Egy 6-jegyű visszaállítási kódot küldtünk az email címére.",
|
|
"expires_at": expires_at.isoformat()
|
|
}
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Account restore request error for {email}: {e}")
|
|
return {"status": "error", "message": "Hiba történt a visszaállítási kód generálása során."}
|
|
|
|
@staticmethod
|
|
async def verify_account_restore(db: AsyncSession, email: str, code: str):
|
|
"""
|
|
OTP kód ellenőrzése és fiók visszaállítása.
|
|
"""
|
|
now = datetime.now(timezone.utc)
|
|
|
|
# Find valid OTP
|
|
stmt = select(OneTimePassword).where(
|
|
OneTimePassword.email == email,
|
|
OneTimePassword.code == code,
|
|
OneTimePassword.otp_type == "restore",
|
|
OneTimePassword.is_used == False,
|
|
OneTimePassword.expires_at > now
|
|
).order_by(OneTimePassword.created_at.desc()).limit(1)
|
|
|
|
result = await db.execute(stmt)
|
|
otp = result.scalar_one_or_none()
|
|
|
|
if not otp:
|
|
return {"status": "invalid", "message": "Érvénytelen vagy lejárt kód."}
|
|
|
|
# Mark OTP as used
|
|
otp.is_used = True
|
|
|
|
# Find the deleted user
|
|
user_id = otp.extra_data.get("user_id") if otp.extra_data else None
|
|
if not user_id:
|
|
return {"status": "error", "message": "Hiba a fiók azonosításában."}
|
|
|
|
stmt = select(User).where(User.id == user_id)
|
|
result = await db.execute(stmt)
|
|
user = result.scalar_one_or_none()
|
|
|
|
if not user:
|
|
return {"status": "error", "message": "Felhasználó nem található."}
|
|
|
|
# Restore the account
|
|
user.is_deleted = False
|
|
user.is_active = True
|
|
user.deleted_at = None
|
|
|
|
# Restore original email (remove deleted_ prefix)
|
|
# The email format is: deleted_{id}_{timestamp}_{original_email}
|
|
if user.email.startswith("deleted_"):
|
|
parts = user.email.split("_", 3)
|
|
if len(parts) == 4:
|
|
user.email = parts[3]
|
|
|
|
await security_service.log_event(
|
|
db, user_id=user.id, action="USER_ACCOUNT_RESTORE",
|
|
severity=LogSeverity.info, target_type="User", target_id=str(user.id),
|
|
new_data={"restored_via": "otp", "email": user.email}
|
|
)
|
|
|
|
await db.commit()
|
|
return {"status": "success", "message": "Fiók sikeresen visszaállítva."}
|
|
|
|
@staticmethod
|
|
async def check_is_last_admin(db: AsyncSession, user_id: int) -> bool:
|
|
"""
|
|
Ellenőrzi, hogy a felhasználó az egyetlen aktív admin bármelyik szervezetében.
|
|
Visszaadja: is_last_admin: bool
|
|
"""
|
|
try:
|
|
from app.models.marketplace.organization import OrganizationMember, OrgUserRole
|
|
|
|
# Find organizations where user is ADMIN or OWNER
|
|
stmt = select(OrganizationMember.organization_id).where(
|
|
OrganizationMember.user_id == user_id,
|
|
OrganizationMember.role.in_([OrgUserRole.ADMIN, OrgUserRole.OWNER])
|
|
)
|
|
result = await db.execute(stmt)
|
|
org_ids = [row[0] for row in result.all()]
|
|
|
|
if not org_ids:
|
|
return False
|
|
|
|
# For each org, check if there are other active admins/owners
|
|
for org_id in org_ids:
|
|
stmt = select(OrganizationMember).where(
|
|
OrganizationMember.organization_id == org_id,
|
|
OrganizationMember.role.in_([OrgUserRole.ADMIN, OrgUserRole.OWNER]),
|
|
OrganizationMember.user_id != user_id
|
|
)
|
|
result = await db.execute(stmt)
|
|
other_admins = result.scalars().all()
|
|
|
|
if not other_admins:
|
|
# This org has no other admin - user is last admin here
|
|
return True
|
|
|
|
return False
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"check_is_last_admin error: {e}")
|
|
return False
|
|
|
|
@staticmethod
|
|
async def resend_verification(db: AsyncSession, email: str):
|
|
"""
|
|
Aktiváló e-mail újraküldése.
|
|
Ellenőrzi, hogy a felhasználó létezik, de még nem aktív (is_active == False),
|
|
generál egy új verifikációs tokent, és kiküldi az e-mailt.
|
|
"""
|
|
# Eager load person relationship to avoid async lazy-load issue
|
|
stmt = select(User).options(joinedload(User.person)).where(
|
|
and_(User.email == email, User.is_deleted == False)
|
|
)
|
|
user = (await db.execute(stmt)).unique().scalar_one_or_none()
|
|
|
|
if not user:
|
|
# Ne áruljuk el, hogy létezik-e az email — biztonsági okokból mindig sikert adunk
|
|
logger.info(f"Resend verification requested for non-existent email: {email}")
|
|
return "not_found"
|
|
|
|
if user.is_active:
|
|
logger.info(f"Resend verification requested for already active user: {email}")
|
|
return "already_active"
|
|
|
|
# Dinamikus lejárat az adminból
|
|
reg_token_hours = await config.get_setting(db, "auth_registration_hours", region_code=user.region_code, default=48)
|
|
token_val = uuid.uuid4()
|
|
|
|
db.add(VerificationToken(
|
|
token=token_val,
|
|
user_id=user.id,
|
|
token_type="registration",
|
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_token_hours))
|
|
))
|
|
|
|
# Email küldés — user.person már eager-loaded, így nem okoz MissingGreenlet hibát
|
|
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
|
|
person = user.person
|
|
first_name = person.first_name if person else user.email
|
|
|
|
logger.info(f"Email küldés indítása (resend verification) ide: {email}...")
|
|
result = await email_manager.send_email(
|
|
recipient=email,
|
|
template_key="reg",
|
|
variables={"first_name": first_name, "link": verification_link},
|
|
lang=user.preferred_language
|
|
)
|
|
logger.info(f"Email küldés eredménye (resend verification): {result}")
|
|
|
|
await db.commit()
|
|
logger.info(f"Resend verification email sent to {email}")
|
|
return "success"
|
|
|
|
@staticmethod
|
|
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int):
|
|
"""
|
|
Felhasználó törlése (Soft-Delete) auditálással, Person-preserving logikával.
|
|
|
|
Üzleti szabályok:
|
|
1. User: is_active=False, is_deleted=True, deleted_at=utcnow
|
|
2. Email átírás: deleted_{id}_{timestamp}_{original_email}
|
|
3. Person rekordot NEM bántja (megőrizzük a jövőbeli újraaktiváláshoz)
|
|
4. Wallet és Gamification adatok megőrzése (inaktív user miatt freeze)
|
|
5. Refresh token-ek érvénytelenítése (JWT-alapú, így a deleted_at middleware ellenőrzi)
|
|
"""
|
|
stmt = select(User).where(User.id == user_id)
|
|
user = (await db.execute(stmt)).scalar_one_or_none()
|
|
if not user or user.is_deleted:
|
|
return False
|
|
|
|
# 1. Anonymize email with timestamp
|
|
old_email = user.email
|
|
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
|
|
user.email = f"deleted_{user.id}_{timestamp}_{old_email}"
|
|
|
|
# 2. Set soft-delete flags
|
|
user.is_deleted = True
|
|
user.is_active = False
|
|
user.deleted_at = datetime.now(timezone.utc)
|
|
|
|
# 3. CRITICAL: Do NOT touch Person record
|
|
# Person.is_active stays as-is, Person data stays intact
|
|
# This allows future re-activation with the same identity
|
|
|
|
# 4. Security audit log with deleted_at info
|
|
await security_service.log_event(
|
|
db, user_id=actor_id, action="USER_SOFT_DELETE",
|
|
severity=LogSeverity.warning, target_type="User", target_id=str(user_id),
|
|
new_data={
|
|
"reason": reason,
|
|
"deleted_at": str(user.deleted_at),
|
|
"person_preserved": True
|
|
}
|
|
)
|
|
await db.commit()
|
|
return True
|