# /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 from app.models import UserStats 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="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} Széfe", 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, role="OWNER")) 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 await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION") # 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" ) 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 person_stmt = select(Person).where(Person.user_id == user.id) person = (await db.execute(person_stmt)).scalar_one_or_none() if person: person.is_active = True 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}" logger.info(f"Email küldés indítása (password reset) ide: {email}...") 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}") # Ha az email küldés hibát dobott, ne titkoljuk el if result.get("status") == "error": logger.error(f"Password reset email FAILED for {email}: {result.get('message')}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Email küldési hiba: {result.get('message')}" ) 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: 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. """ 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 old_email = user.email user.email = f"deleted_{user.id}_{datetime.now().strftime('%Y%m%d')}_{old_email}" user.is_deleted = True user.is_active = False await security_service.log_event( db, user_id=actor_id, action="USER_SOFT_DELETE", severity="WARNING", target_type="User", target_id=str(user_id), new_data={"reason": reason} ) await db.commit() return True