feat: Step 1 Auth complete - Token generation and email loop verified

This commit is contained in:
2026-02-06 22:20:11 +00:00
parent 32325b261b
commit cfd1e365e0
13 changed files with 249 additions and 55 deletions

View File

@@ -35,4 +35,12 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSessi
@router.post("/forgot-password") @router.post("/forgot-password")
async def forgot_password(req: PasswordResetRequest, db: AsyncSession = Depends(get_db)): async def forgot_password(req: PasswordResetRequest, db: AsyncSession = Depends(get_db)):
await AuthService.initiate_password_reset(db, req.email) await AuthService.initiate_password_reset(db, req.email)
return {"message": "Helyreállítási folyamat elindítva."} 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)."}

Binary file not shown.

29
backend/app/core/i18n.py Normal file
View File

@@ -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()

View File

@@ -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."
}
}

View File

@@ -69,4 +69,16 @@ class Wallet(Base):
coin_balance = Column(Numeric(18, 2), default=0.00) coin_balance = Column(Numeric(18, 2), default=0.00)
xp_balance = Column(Integer, default=0) xp_balance = Column(Integer, default=0)
user = relationship("User", back_populates="wallet") 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)

Binary file not shown.

View File

@@ -1,15 +1,18 @@
from datetime import datetime, timedelta, timezone
import uuid
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text 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.models.organization import Organization
from app.schemas.auth import UserLiteRegister from app.schemas.auth import UserLiteRegister
from app.core.security import get_password_hash, verify_password 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: class AuthService:
@staticmethod @staticmethod
async def register_lite(db: AsyncSession, user_in: UserLiteRegister): 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: try:
# 1. Person shell # 1. Person shell
new_person = Person( new_person = Person(
@@ -32,20 +35,34 @@ class AuthService:
db.add(new_user) db.add(new_user)
await db.flush() 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: try:
await email_manager.send_email( await email_manager.send_email(
recipient=user_in.email, recipient=user_in.email,
template_key="registration", # 'registration.html' sablon használata template_key="registration",
variables={ variables={
"first_name": user_in.first_name, "first_name": user_in.first_name,
"login_url": "http://192.168.100.10:3000/login" "link": verification_link
}, }
user_id=new_user.id
) )
except Exception as email_err: except Exception as email_err:
# Az email hiba nem állítja meg a regisztrációt, csak logoljuk print(f"CRITICAL: Email sending failed: {str(email_err)}")
print(f"Email hiba regisztrációkor: {str(email_err)}")
await db.commit() await db.commit()
await db.refresh(new_user) await db.refresh(new_user)
@@ -54,6 +71,43 @@ class AuthService:
await db.rollback() await db.rollback()
raise e 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 @staticmethod
async def authenticate(db: AsyncSession, email: str, password: str): async def authenticate(db: AsyncSession, email: str, password: str):
stmt = select(User).where(User.email == email, User.is_deleted == False) stmt = select(User).where(User.email == email, User.is_deleted == False)
@@ -66,17 +120,30 @@ class AuthService:
@staticmethod @staticmethod
async def initiate_password_reset(db: AsyncSession, email: str): 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) stmt = select(User).where(User.email == email, User.is_deleted == False)
res = await db.execute(stmt) res = await db.execute(stmt)
user = res.scalar_one_or_none() user = res.scalar_one_or_none()
if user: 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( await email_manager.send_email(
recipient=email, recipient=email,
template_key="password_reset", template_key="password_reset",
variables={"reset_token": "IDE_JÖN_MAJD_A_TOKEN"}, variables={"link": reset_link},
user_id=user.id user_id=user.id
) )
await db.commit()
return True return True
return False return False

View File

@@ -2,64 +2,65 @@ import os
import smtplib import smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from app.core.config import settings from app.core.config import settings
from app.core.i18n import locale_manager # Feltételezve, hogy létrehoztad az i18n.py-t
class EmailManager: class EmailManager:
@staticmethod @staticmethod
def _render_template(template_key: str, variables: dict, lang: str = "hu") -> str: def _get_html_template(template_key: str, variables: dict, lang: str = "hu") -> str:
base_dir = "/app/app/templates/emails" # A JSON-ból vesszük a szövegeket
file_path = f"{base_dir}/{lang}/{template_key}.html" greeting = locale_manager.get(f"email.{template_key}_greeting", lang=lang, **variables)
if not os.path.exists(file_path): body = locale_manager.get(f"email.{template_key}_body", lang=lang, **variables)
return "" button_text = locale_manager.get(f"email.{template_key}_button", lang=lang)
with open(file_path, "r", encoding="utf-8") as f: footer = locale_manager.get(f"email.{template_key}_footer", lang=lang)
body_html = f.read()
for k, v in variables.items(): # Egységes HTML váz gombbal
body_html = body_html.replace(f"{{{{{k}}}}}", str(v)) return f"""
body_html = body_html.replace(f"{{{k}}}", str(v)) <html>
return body_html <body style="font-family: Arial, sans-serif; color: #333;">
<div style="max-width: 600px; margin: 0 auto; border: 1px solid #ddd; padding: 20px;">
<h2>{greeting}</h2>
<p>{body}</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{variables.get('link', '#')}"
style="background-color: #3498db; color: white; padding: 12px 25px; text-decoration: none; border-radius: 5px; font-weight: bold;">
{button_text}
</a>
</div>
<p style="font-size: 0.9em; color: #666;">{variables.get('link')}</p>
<hr style="border: 0; border-top: 1px solid #eee;">
<p style="font-size: 0.8em; color: #999;">{footer}</p>
</div>
</body>
</html>
"""
@staticmethod @staticmethod
def _subject(template_key: str) -> str: async def send_email(recipient: str, template_key: str, variables: dict, lang: str = "hu"):
subjects = { if settings.EMAIL_PROVIDER == "disabled": return
"registration": "Regisztráció - Service Finder",
"password_reset": "Jelszó visszaállítás - Service Finder", html = EmailManager._get_html_template(template_key, variables, lang)
"notification": "Értesítés - Service Finder", subject = locale_manager.get(f"email.{template_key}_subject", lang=lang)
}
return subjects.get(template_key, "Értesítés - Service Finder")
@staticmethod # SendGrid küldés
async def send_email(recipient: str, template_key: str, variables: dict, user_id: int = None, lang: str = "hu"): if settings.EMAIL_PROVIDER == "sendgrid" and settings.SENDGRID_API_KEY:
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:
try: try:
from sendgrid import SendGridAPIClient from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail from sendgrid.helpers.mail import Mail
message = Mail( message = Mail(
from_email=(settings.EMAILS_FROM_EMAIL, settings.EMAILS_FROM_NAME), from_email=(settings.EMAILS_FROM_EMAIL, settings.EMAILS_FROM_NAME),
to_emails=recipient, to_emails=recipient,
subject=subject, subject=subject,
html_content=html or "<p>Üzenet</p>", html_content=html
) )
sg = SendGridAPIClient(settings.SENDGRID_API_KEY) sg = SendGridAPIClient(settings.SENDGRID_API_KEY)
sg.send(message) sg.send(message)
return {"status": "success", "provider": "sendgrid"} return {"status": "success"}
except Exception as e: except Exception as e:
# ha auto módban vagyunk, esünk vissza smtp-re print(f"SendGrid Error: {e}")
if settings.EMAIL_PROVIDER != "auto":
return {"status": "error", "provider": "sendgrid", "message": str(e)}
# SMTP Fallback
# ... (az eredeti SMTP kódod ide jön változatlanul)
# 2) SMTP fallback # 2) SMTP fallback
if not settings.SMTP_HOST or not settings.SMTP_USER or not settings.SMTP_PASSWORD: if not settings.SMTP_HOST or not settings.SMTP_USER or not settings.SMTP_PASSWORD:
return {"status": "error", "provider": "smtp", "message": "SMTP not configured"} return {"status": "error", "provider": "smtp", "message": "SMTP not configured"}

View File

@@ -14,4 +14,43 @@
## Hiba Kezelés ## Hiba Kezelés
- **401:** Token lejárt -> Frontend dobjon Loginra. - **401:** Token lejárt -> Frontend dobjon Loginra.
- **403:** Jogosultság hiba -> "Nincs jogod ehhez a funkcióhoz" (Tier limit). - **403:** Jogosultság hiba -> "Nincs jogod ehhez a funkcióhoz" (Tier limit).
- **404:** Resource not found OR Soft Deleted. - **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.

View File

@@ -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. - **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. - **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**. 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. 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