diff --git a/backend/app/api/v1/endpoints/__pycache__/auth.cpython-312.pyc b/backend/app/api/v1/endpoints/__pycache__/auth.cpython-312.pyc index fbd38e7..3c2ff8b 100644 Binary files a/backend/app/api/v1/endpoints/__pycache__/auth.cpython-312.pyc and b/backend/app/api/v1/endpoints/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index b75b894..c8e27c9 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -35,4 +35,12 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSessi @router.post("/forgot-password") async def forgot_password(req: PasswordResetRequest, db: AsyncSession = Depends(get_db)): await AuthService.initiate_password_reset(db, req.email) - return {"message": "Helyreállítási folyamat elindítva."} \ No newline at end of file + return {"message": "Helyreállítási folyamat elindítva."} + +@router.get("/verify-email") +async def verify_email(token: str, db: AsyncSession = Depends(get_db)): + """Ezt hívja meg a frontend, amikor a user a levélben a gombra kattint.""" + 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! Most már elvégezheti a KYC regisztrációt (Step 2)."} \ No newline at end of file diff --git a/backend/app/core/__pycache__/i18n.cpython-312.pyc b/backend/app/core/__pycache__/i18n.cpython-312.pyc new file mode 100644 index 0000000..18b715d Binary files /dev/null and b/backend/app/core/__pycache__/i18n.cpython-312.pyc differ diff --git a/backend/app/core/i18n.py b/backend/app/core/i18n.py new file mode 100644 index 0000000..b1fc0ef --- /dev/null +++ b/backend/app/core/i18n.py @@ -0,0 +1,29 @@ +import json +import os + +class LocaleManager: + _locales = {} + + def get(self, key: str, lang: str = "hu", **kwargs) -> str: + if not self._locales: + self._load() + + data = self._locales.get(lang, self._locales.get("hu", {})) + for k in key.split("."): + data = data.get(k, {}) + + if isinstance(data, str): + return data.format(**kwargs) + return key + + def _load(self): + path = "backend/app/locales" # Konténeren belül: "/app/app/locales" + if not os.path.exists(path): path = "app/locales" + + for file in os.listdir(path): + if file.endswith(".json"): + lang = file.split(".")[0] + with open(os.path.join(path, file), "r", encoding="utf-8") as f: + self._locales[lang] = json.load(f) + +locale_manager = LocaleManager() \ No newline at end of file diff --git a/backend/app/locales/hu.json b/backend/app/locales/hu.json new file mode 100644 index 0000000..b1d1eee --- /dev/null +++ b/backend/app/locales/hu.json @@ -0,0 +1,14 @@ +{ + "email": { + "registration_subject": "Regisztráció - Service Finder", + "password_reset_subject": "Jelszó visszaállítás - Service Finder", + "reg_greeting": "Szia {first_name}!", + "reg_body": "A regisztrációd befejezéséhez és a 'Privát Széfed' aktiválásához kattints az alábbi gombra:", + "reg_button": "Fiók Aktiválása", + "reg_footer": "Ez a link 48 óráig érvényes. Ha nem te regisztráltál, kérjük hagyd figyelmen kívül ezt a levelet.", + "pwd_reset_greeting": "Szia!", + "pwd_reset_body": "Jelszó-visszaállítási kérelem érkezett. Kattints a gombra az új jelszó megadásához:", + "pwd_reset_button": "Jelszó visszaállítása", + "pwd_reset_footer": "A link 1 óráig érvényes." + } +} \ No newline at end of file diff --git a/backend/app/models/__pycache__/identity.cpython-312.pyc b/backend/app/models/__pycache__/identity.cpython-312.pyc index d9cdbf9..eb6a60e 100644 Binary files a/backend/app/models/__pycache__/identity.cpython-312.pyc and b/backend/app/models/__pycache__/identity.cpython-312.pyc differ diff --git a/backend/app/models/identity.py b/backend/app/models/identity.py index 9396fda..1abbebd 100644 --- a/backend/app/models/identity.py +++ b/backend/app/models/identity.py @@ -69,4 +69,16 @@ class Wallet(Base): coin_balance = Column(Numeric(18, 2), default=0.00) xp_balance = Column(Integer, default=0) - user = relationship("User", back_populates="wallet") \ No newline at end of file + user = relationship("User", back_populates="wallet") + +class VerificationToken(Base): + __tablename__ = "verification_tokens" + __table_args__ = {"schema": "data"} + + id = Column(Integer, primary_key=True, index=True) + token = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False) + user_id = Column(Integer, ForeignKey("data.users.id", ondelete="CASCADE"), nullable=False) + token_type = Column(String(20), nullable=False) # 'registration' or 'password_reset' + created_at = Column(DateTime(timezone=True), server_default=func.now()) + expires_at = Column(DateTime(timezone=True), nullable=False) + is_used = Column(Boolean, default=False) \ No newline at end of file diff --git a/backend/app/services/__pycache__/auth_service.cpython-312.pyc b/backend/app/services/__pycache__/auth_service.cpython-312.pyc index 8215d4c..40fcca9 100644 Binary files a/backend/app/services/__pycache__/auth_service.cpython-312.pyc and b/backend/app/services/__pycache__/auth_service.cpython-312.pyc differ diff --git a/backend/app/services/__pycache__/email_manager.cpython-312.pyc b/backend/app/services/__pycache__/email_manager.cpython-312.pyc old mode 100755 new mode 100644 index baee4b8..0405ec3 Binary files a/backend/app/services/__pycache__/email_manager.cpython-312.pyc and b/backend/app/services/__pycache__/email_manager.cpython-312.pyc differ diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index ef75408..64098ff 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -1,15 +1,18 @@ +from datetime import datetime, timedelta, timezone +import uuid from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, text -from app.models.identity import User, Person, UserRole +from app.models.identity import User, Person, UserRole, VerificationToken from app.models.organization import Organization from app.schemas.auth import UserLiteRegister from app.core.security import get_password_hash, verify_password -from app.services.email_manager import email_manager # Importálva! +from app.services.email_manager import email_manager +from app.core.config import settings class AuthService: @staticmethod async def register_lite(db: AsyncSession, user_in: UserLiteRegister): - """Step 1: Lite regisztráció + Email küldés.""" + """Step 1: Lite regisztráció kormányozható token élettartammal.""" try: # 1. Person shell new_person = Person( @@ -32,20 +35,34 @@ class AuthService: db.add(new_user) await db.flush() - # 3. Email kiküldése (Mester Könyv v1.4 szerint) + # 3. Biztonsági Token (Beállítható élettartam) + # Default: 48 óra, ha nincs megadva a settingsben + expire_hours = getattr(settings, "REGISTRATION_TOKEN_EXPIRE_HOURS", 48) + + token_val = uuid.uuid4() + new_token = VerificationToken( + token=token_val, + user_id=new_user.id, + token_type="registration", + expires_at=datetime.now(timezone.utc) + timedelta(hours=expire_hours) + ) + db.add(new_token) + await db.flush() + + # 4. Email küldés + verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}" + try: await email_manager.send_email( recipient=user_in.email, - template_key="registration", # 'registration.html' sablon használata + template_key="registration", variables={ "first_name": user_in.first_name, - "login_url": "http://192.168.100.10:3000/login" - }, - user_id=new_user.id + "link": verification_link + } ) except Exception as email_err: - # Az email hiba nem állítja meg a regisztrációt, csak logoljuk - print(f"Email hiba regisztrációkor: {str(email_err)}") + print(f"CRITICAL: Email sending failed: {str(email_err)}") await db.commit() await db.refresh(new_user) @@ -54,6 +71,43 @@ class AuthService: await db.rollback() raise e + @staticmethod + async def verify_email(db: AsyncSession, token_str: str): + """Token ellenőrzése és regisztráció megerősítése.""" + try: + # Token UUID-vá alakítása az összehasonlításhoz + token_uuid = uuid.UUID(token_str) + + stmt = select(VerificationToken).where( + VerificationToken.token == token_uuid, + VerificationToken.is_used == False, + VerificationToken.expires_at > datetime.now(timezone.utc) + ) + result = await db.execute(stmt) + token_obj = result.scalar_one_or_none() + + if not token_obj: + return False + + # Token elhasználása + token_obj.is_used = True + + # User keresése és aktiválása (Email megerősítve) + user_stmt = select(User).where(User.id == token_obj.user_id) + user_res = await db.execute(user_stmt) + user = user_res.scalar_one_or_none() + if user: + # Figyelem: A Master Book szerint ez még nem teljes aktiválás (is_active: false) + # de jelölhetjük, hogy az e-mail már OK. + pass + + await db.commit() + return True + except Exception as e: + print(f"Verify error: {e}") + await db.rollback() + return False + @staticmethod async def authenticate(db: AsyncSession, email: str, password: str): stmt = select(User).where(User.email == email, User.is_deleted == False) @@ -66,17 +120,30 @@ class AuthService: @staticmethod async def initiate_password_reset(db: AsyncSession, email: str): - """Jelszó-emlékeztető email küldése.""" + """Jelszó-emlékeztető kormányozható élettartammal.""" stmt = select(User).where(User.email == email, User.is_deleted == False) res = await db.execute(stmt) user = res.scalar_one_or_none() if user: + expire_hours = getattr(settings, "PASSWORD_RESET_TOKEN_EXPIRE_HOURS", 1) + token_val = uuid.uuid4() + new_token = VerificationToken( + token=token_val, + user_id=user.id, + token_type="password_reset", + expires_at=datetime.now(timezone.utc) + timedelta(hours=expire_hours) + ) + db.add(new_token) + + reset_link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}" + await email_manager.send_email( recipient=email, template_key="password_reset", - variables={"reset_token": "IDE_JÖN_MAJD_A_TOKEN"}, + variables={"link": reset_link}, user_id=user.id ) + await db.commit() return True return False \ No newline at end of file diff --git a/backend/app/services/email_manager.py b/backend/app/services/email_manager.py index 775a96c..65bb641 100755 --- a/backend/app/services/email_manager.py +++ b/backend/app/services/email_manager.py @@ -2,64 +2,65 @@ import os import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart - from app.core.config import settings +from app.core.i18n import locale_manager # Feltételezve, hogy létrehoztad az i18n.py-t class EmailManager: @staticmethod - def _render_template(template_key: str, variables: dict, lang: str = "hu") -> str: - base_dir = "/app/app/templates/emails" - file_path = f"{base_dir}/{lang}/{template_key}.html" - if not os.path.exists(file_path): - return "" - with open(file_path, "r", encoding="utf-8") as f: - body_html = f.read() - for k, v in variables.items(): - body_html = body_html.replace(f"{{{{{k}}}}}", str(v)) - body_html = body_html.replace(f"{{{k}}}", str(v)) - return body_html + def _get_html_template(template_key: str, variables: dict, lang: str = "hu") -> str: + # A JSON-ból vesszük a szövegeket + greeting = locale_manager.get(f"email.{template_key}_greeting", lang=lang, **variables) + body = locale_manager.get(f"email.{template_key}_body", lang=lang, **variables) + button_text = locale_manager.get(f"email.{template_key}_button", lang=lang) + footer = locale_manager.get(f"email.{template_key}_footer", lang=lang) + + # Egységes HTML váz gombbal + return f""" + +
+ + + + """ @staticmethod - def _subject(template_key: str) -> str: - subjects = { - "registration": "Regisztráció - Service Finder", - "password_reset": "Jelszó visszaállítás - Service Finder", - "notification": "Értesítés - Service Finder", - } - return subjects.get(template_key, "Értesítés - Service Finder") + async def send_email(recipient: str, template_key: str, variables: dict, lang: str = "hu"): + if settings.EMAIL_PROVIDER == "disabled": return + + html = EmailManager._get_html_template(template_key, variables, lang) + subject = locale_manager.get(f"email.{template_key}_subject", lang=lang) - @staticmethod - async def send_email(recipient: str, template_key: str, variables: dict, user_id: int = None, lang: str = "hu"): - if settings.EMAIL_PROVIDER == "disabled": - return {"status": "disabled"} - - html = EmailManager._render_template(template_key, variables, lang=lang) - subject = EmailManager._subject(template_key) - - provider = settings.EMAIL_PROVIDER - if provider == "auto": - provider = "sendgrid" if settings.SENDGRID_API_KEY else "smtp" - - # 1) SendGrid API (stabil) - if provider == "sendgrid" and settings.SENDGRID_API_KEY: + # SendGrid küldés + if settings.EMAIL_PROVIDER == "sendgrid" and settings.SENDGRID_API_KEY: try: from sendgrid import SendGridAPIClient from sendgrid.helpers.mail import Mail - message = Mail( from_email=(settings.EMAILS_FROM_EMAIL, settings.EMAILS_FROM_NAME), to_emails=recipient, subject=subject, - html_content=html or "Üzenet
", + html_content=html ) sg = SendGridAPIClient(settings.SENDGRID_API_KEY) sg.send(message) - return {"status": "success", "provider": "sendgrid"} + return {"status": "success"} except Exception as e: - # ha auto módban vagyunk, esünk vissza smtp-re - if settings.EMAIL_PROVIDER != "auto": - return {"status": "error", "provider": "sendgrid", "message": str(e)} + print(f"SendGrid Error: {e}") + # SMTP Fallback + # ... (az eredeti SMTP kódod ide jön változatlanul) # 2) SMTP fallback if not settings.SMTP_HOST or not settings.SMTP_USER or not settings.SMTP_PASSWORD: return {"status": "error", "provider": "smtp", "message": "SMTP not configured"} diff --git a/docs/V01_gemini/07_API_Guide.md b/docs/V01_gemini/07_API_Guide.md index 9225cbc..804b5ce 100644 --- a/docs/V01_gemini/07_API_Guide.md +++ b/docs/V01_gemini/07_API_Guide.md @@ -14,4 +14,43 @@ ## Hiba Kezelés - **401:** Token lejárt -> Frontend dobjon Loginra. - **403:** Jogosultság hiba -> "Nincs jogod ehhez a funkcióhoz" (Tier limit). -- **404:** Resource not found OR Soft Deleted. \ No newline at end of file +- **404:** Resource not found OR Soft Deleted. + +## 🌐 8. Nemzetköziesítés (i18n) és Lokalizáció + +A rendszer a "Global-Local" elv alapján működik. Tilos a programkódban (hard-coded) szöveges üzeneteket elhelyezni. + +### 8.1. Nyelvi fájlok struktúrája +Minden nyelvi fájl a `backend/app/locales/` mappában található, szabványos JSON formátumban. +Példa: `hu.json`, `en.json`, `de.json`. + +### 8.2. Kezelési szabályok +- **Backend:** A rendszerüzeneteket, hibaüzeneteket és az e-mail sablonok tartalmát a `LocaleManager` szolgáltatáson keresztül kéri le. +- **Paraméterezés:** A szövegekben használható változók formátuma: `{variable_name}`. +- **Sablonkezelés:** Az e-mailek HTML vázát és a JSON-ban tárolt szöveges blokkokat a rendszer a küldés előtt fűzi össze. + +### 8.3. Nyelvválasztás logikája +1. A kérés fejlécében érkező `Accept-Language` alapján. +2. Bejelentkezett felhasználó esetén a `User.region_code` alapján. +3. Alapértelmezett: `hu`. + +# 🛡️ 9. Unified Registration & Security Protocol + +A rendszer a "Minimal Friction, Maximum Security" elvét követi. + +### 9.1. Regisztrációs Életciklus +1. **Step 1 (Lite):** `Email`, `Jelszó`, `Név` megadása. Létrejön a `User` és `Person` rekord. Állapot: `is_active: false`. +2. **Verifikáció:** A rendszer UUID alapú tokent generál (48 órás élettartam). A felhasználó e-mailben kap egy gombot/linket. +3. **Step 2 (KYC):** Sikeres verifikáció után a felhasználó megadja az okmányait (rugalmas választó: Személyi/Jogsi/Hajó). +4. **Aktiválás:** Létrejön a **Privát Flotta (Privát Széf)** és a hozzá tartozó `Wallet`. Állapot: `is_active: true`. + +### 9.2. Token Biztonsági Előírások +- **Regisztrációs Token:** 48 óra élettartam. +- **Jelszó-visszaállítási Token:** 1 óra élettartam. + +### 9.3. Rate Limiting (Robotvédelem és Költségkontroll) +Az e-mail küldési folyamatokra az alábbi korlátok vonatkoznak: +- **Retry Cooldown:** Újraigénylés (pl. "Nem kaptam meg a kódot") legkorábban 60 másodperc után lehetséges. +- **Óránkénti Limit:** Maximum 3 kérelem / e-mail cím. +- **Napi Limit:** Maximum 10 kérelem / e-mail cím. +- **Zárolás:** A napi limit túllépése esetén a fiók biztonsági okokból 24 órára zárolja a küldési funkciót az adott címre. \ No newline at end of file diff --git a/docs/V01_gemini/07_REGISTRATION_INVITATION_AND_API.md b/docs/V01_gemini/07_REGISTRATION_INVITATION_AND_API.md index c91adad..a9ea2b0 100644 --- a/docs/V01_gemini/07_REGISTRATION_INVITATION_AND_API.md +++ b/docs/V01_gemini/07_REGISTRATION_INVITATION_AND_API.md @@ -134,4 +134,28 @@ A rendszer két szintű helyreállítást biztosít: - **Kötelező adatok:** Vezetéknév, Keresztnév, Anyja neve, Személyi igazolvány száma. - **Logika:** 1. A rendszer azonosítja a `Person` rekordot. 2. Ha sikeres, a rendszer kiküld egy visszaállító linket a Person-höz tartozó **elsődleges telefonszámra (SMS)** vagy a **legutolsó aktív Email címre**. - 3. Sikeres helyreállítás után a felhasználónak kötelezően jelszót kell cserélnie. \ No newline at end of file + 3. Sikeres helyreállítás után a felhasználónak kötelezően jelszót kell cserélnie. + + ## 🛡️ 10. Kormányozhatóság és Biztonsági Beállítások + +A rendszer biztonsági paraméterei központilag, a környezeti változókon keresztül szabályozhatók. Ez lehetővé teszi a biztonsági szint gyors módosítását (pl. támadás esetén szigorítás) a kód módosítása nélkül. + +### 10.1. Token Élettartam Szabályok +A `.env` fájlban (vagy a rendszer beállításaiban) az alábbi paraméterekkel szabályozható a hozzáférés: + +| Paraméter | Leírás | Alapértelmezett | +| :--- | :--- | :--- | +| `REGISTRATION_TOKEN_EXPIRE_HOURS` | Regisztráció megerősítésére álló idő | 48 óra | +| `PASSWORD_RESET_TOKEN_EXPIRE_HOURS` | Jelszó visszaállítására álló idő | 1 óra | + +### 10.2. Ideiglenes .env Konfiguráció (Példa) +```env +# SECURITY SETTINGS +REGISTRATION_TOKEN_EXPIRE_HOURS=48 +PASSWORD_RESET_TOKEN_EXPIRE_HOURS=1 + +# EMAIL SYSTEM +EMAIL_PROVIDER=sendgrid +EMAILS_FROM_EMAIL=info@profibot.hu +EMAILS_FROM_NAME='Profibot Service Finder' +SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx \ No newline at end of file