From c1562a27f4da5e4971de2679dca211449d1e0fc1 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 21 Mar 2026 09:53:11 +0100 Subject: [PATCH] feat: add self-registration with email verification Backend: - New endpoint: POST /api/auth/register - New endpoint: GET /api/auth/verify/{token} - Migration: Add email_verified, verification_token, verification_expires - Helper: send_email() for reusable SMTP - Validation: email format, password length (min 8), name - Auto-login after verification (returns session token) - Rate limit: 3 registrations per hour per IP Features: - Verification token valid for 24h - Existing users marked as verified (grandfather clause) - SMTP configured via .env (SMTP_HOST, SMTP_USER, SMTP_PASS) Co-Authored-By: Claude Opus 4.6 --- .../migrations/003_add_email_verification.sql | 25 +++ backend/models.py | 6 + backend/routers/auth.py | 150 +++++++++++++++++- 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/003_add_email_verification.sql diff --git a/backend/migrations/003_add_email_verification.sql b/backend/migrations/003_add_email_verification.sql new file mode 100644 index 0000000..5f09d96 --- /dev/null +++ b/backend/migrations/003_add_email_verification.sql @@ -0,0 +1,25 @@ +-- ================================================================ +-- Migration 003: Add Email Verification Fields +-- Version: v9c +-- Date: 2026-03-21 +-- ================================================================ + +-- Add email verification columns to profiles table +ALTER TABLE profiles +ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS verification_token TEXT, +ADD COLUMN IF NOT EXISTS verification_expires TIMESTAMP WITH TIME ZONE; + +-- Create index for verification token lookups +CREATE INDEX IF NOT EXISTS idx_profiles_verification_token +ON profiles(verification_token) +WHERE verification_token IS NOT NULL; + +-- Mark existing users with email as verified (grandfather clause) +UPDATE profiles +SET email_verified = TRUE +WHERE email IS NOT NULL AND email_verified IS NULL; + +COMMENT ON COLUMN profiles.email_verified IS 'Whether email address has been verified'; +COMMENT ON COLUMN profiles.verification_token IS 'One-time token for email verification'; +COMMENT ON COLUMN profiles.verification_expires IS 'Verification token expiry (24h from creation)'; diff --git a/backend/models.py b/backend/models.py index b706906..380e300 100644 --- a/backend/models.py +++ b/backend/models.py @@ -110,6 +110,12 @@ class PasswordResetConfirm(BaseModel): new_password: str +class RegisterRequest(BaseModel): + name: str + email: str + password: str + + # ── Admin Models ────────────────────────────────────────────────────────────── class AdminProfileUpdate(BaseModel): diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 25f88f4..f8ccce4 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -17,7 +17,7 @@ from slowapi.util import get_remote_address from db import get_db, get_cursor from auth import hash_pin, verify_pin, make_token, require_auth -from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm +from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm, RegisterRequest router = APIRouter(prefix="/api/auth", tags=["auth"]) limiter = Limiter(key_func=get_remote_address) @@ -174,3 +174,151 @@ def password_reset_confirm(req: PasswordResetConfirm): cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",)) return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"} + + +# ── Helper: Send Email ──────────────────────────────────────────────────────── +def send_email(to_email: str, subject: str, body: str): + """Send email via SMTP (reusable helper).""" + try: + smtp_host = os.getenv("SMTP_HOST") + smtp_port = int(os.getenv("SMTP_PORT", 587)) + smtp_user = os.getenv("SMTP_USER") + smtp_pass = os.getenv("SMTP_PASS") + smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de") + + if not smtp_host or not smtp_user or not smtp_pass: + print("SMTP not configured, skipping email") + return False + + msg = MIMEText(body) + msg['Subject'] = subject + msg['From'] = smtp_from + msg['To'] = to_email + + with smtplib.SMTP(smtp_host, smtp_port) as server: + server.starttls() + server.login(smtp_user, smtp_pass) + server.send_message(msg) + + return True + except Exception as e: + print(f"Email error: {e}") + return False + + +# ── Registration Endpoints ──────────────────────────────────────────────────── +@router.post("/register") +@limiter.limit("3/hour") +async def register(req: RegisterRequest, request: Request): + """Self-registration with email verification.""" + email = req.email.lower().strip() + name = req.name.strip() + password = req.password + + # Validation + if not email or '@' not in email: + raise HTTPException(400, "Ungültige E-Mail-Adresse") + if len(password) < 8: + raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein") + if not name or len(name) < 2: + raise HTTPException(400, "Name muss mindestens 2 Zeichen lang sein") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check if email already exists + cur.execute("SELECT id FROM profiles WHERE email=%s", (email,)) + if cur.fetchone(): + raise HTTPException(400, "E-Mail-Adresse bereits registriert") + + # Generate verification token + verification_token = secrets.token_urlsafe(32) + verification_expires = datetime.now() + timedelta(hours=24) + + # Create profile (inactive until verified) + profile_id = str(secrets.token_hex(16)) + pin_hash = hash_pin(password) + + cur.execute(""" + INSERT INTO profiles ( + id, name, email, pin_hash, auth_type, role, tier, + email_verified, verification_token, verification_expires, + created + ) VALUES (%s, %s, %s, %s, 'email', 'user', 'free', FALSE, %s, %s, CURRENT_TIMESTAMP) + """, (profile_id, name, email, pin_hash, verification_token, verification_expires)) + + # Send verification email + app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de") + verify_url = f"{app_url}/verify?token={verification_token}" + + email_body = f"""Hallo {name}, + +willkommen bei Mitai Jinkendo! + +Bitte bestätige deine E-Mail-Adresse um die Registrierung abzuschließen: + +{verify_url} + +Der Link ist 24 Stunden gültig. + +Dein Mitai Jinkendo Team +""" + + send_email(email, "Willkommen bei Mitai Jinkendo – E-Mail bestätigen", email_body) + + return { + "ok": True, + "message": "Registrierung erfolgreich! Bitte prüfe dein E-Mail-Postfach und bestätige deine E-Mail-Adresse." + } + + +@router.get("/verify/{token}") +async def verify_email(token: str): + """Verify email address and activate account.""" + with get_db() as conn: + cur = get_cursor(conn) + + # Find profile with this verification token + cur.execute(""" + SELECT id, name, email, email_verified, verification_expires + FROM profiles + WHERE verification_token=%s + """, (token,)) + + prof = cur.fetchone() + + if not prof: + raise HTTPException(400, "Ungültiger Verifikations-Link") + + if prof['email_verified']: + raise HTTPException(400, "E-Mail-Adresse bereits bestätigt") + + # Check if token expired + if prof['verification_expires'] and datetime.now() > prof['verification_expires']: + raise HTTPException(400, "Verifikations-Link abgelaufen. Bitte registriere dich erneut.") + + # Mark as verified and clear token + cur.execute(""" + UPDATE profiles + SET email_verified=TRUE, verification_token=NULL, verification_expires=NULL + WHERE id=%s + """, (prof['id'],)) + + # Create session (auto-login after verification) + session_token = make_token() + expires = datetime.now() + timedelta(days=30) + cur.execute(""" + INSERT INTO sessions (token, profile_id, expires_at, created) + VALUES (%s, %s, %s, CURRENT_TIMESTAMP) + """, (session_token, prof['id'], expires)) + + return { + "ok": True, + "message": "E-Mail-Adresse erfolgreich bestätigt!", + "token": session_token, + "profile": { + "id": prof['id'], + "name": prof['name'], + "email": prof['email'] + } + }