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 <noreply@anthropic.com>
This commit is contained in:
parent
888b5c3e40
commit
c1562a27f4
25
backend/migrations/003_add_email_verification.sql
Normal file
25
backend/migrations/003_add_email_verification.sql
Normal file
|
|
@ -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)';
|
||||||
|
|
@ -110,6 +110,12 @@ class PasswordResetConfirm(BaseModel):
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
# ── Admin Models ──────────────────────────────────────────────────────────────
|
# ── Admin Models ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class AdminProfileUpdate(BaseModel):
|
class AdminProfileUpdate(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from slowapi.util import get_remote_address
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
from db import get_db, get_cursor
|
||||||
from auth import hash_pin, verify_pin, make_token, require_auth
|
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"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
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}",))
|
cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",))
|
||||||
|
|
||||||
return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"}
|
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']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user