"""Auth-Service-Logik: Allowlist, User-Anlage, Code-Erzeugung/-Versand/-Prüfung. Trennt die fachliche Logik von den Routen. Codes werden 6-stellig numerisch erzeugt, nur gehasht gespeichert und per E-Mail versandt. """ import secrets from datetime import timedelta from flask import current_app from flask_mail import Message from ..extensions import db, mail from ..models import User, LoginCode, utcnow def email_allowed(email: str) -> bool: """True, wenn die E-Mail freigeschaltet ist. Leere Allowlist (z.B. lokale Entwicklung ohne .env) = keine Einschränkung. In Produktion sollte ALLOWED_EMAILS gesetzt sein (Self-Registration begrenzt). """ allow = current_app.config.get("ALLOWED_EMAILS", []) return (not allow) or (email.lower() in allow) def get_or_create_user(email: str) -> User: """User per E-Mail holen oder passwortlos anlegen (Self-Registration).""" email = email.lower().strip() user = User.query.filter_by(email=email).first() if user is None: user = User(email=email) db.session.add(user) db.session.commit() return user def issue_login_code(user: User) -> str: """Neuen Login-Code erzeugen, alte unbenutzte Codes des Users verwerfen. Gibt den Klartext-Code zurück (nur zum sofortigen Versand) — gespeichert wird ausschließlich der Hash. """ LoginCode.query.filter_by(user_id=user.id, used=False).delete() code = f"{secrets.randbelow(1_000_000):06d}" ttl = current_app.config.get("LOGIN_CODE_TTL_MINUTES", 10) lc = LoginCode(user_id=user.id, expires_at=utcnow() + timedelta(minutes=ttl)) lc.set_code(code) db.session.add(lc) db.session.commit() return code def send_login_code(user: User, code: str) -> None: """Code per E-Mail senden (Klartext, kein HTML/Tracking). Im Debug-Modus wird der Code zusätzlich geloggt und Versandfehler werden verschluckt, damit lokal ohne SMTP getestet werden kann. In Produktion wird eine Versand-Exception weitergereicht. """ ttl = current_app.config.get("LOGIN_CODE_TTL_MINUTES", 10) msg = Message( subject="Dein Login-Code für den Projekt-Hub", recipients=[user.email], body=( "Hallo,\n\n" f"dein Login-Code lautet: {code}\n\n" f"Der Code ist {ttl} Minuten gültig und nur einmal verwendbar.\n" "Wenn du keinen Login angefordert hast, ignoriere diese Mail.\n" ), ) try: mail.send(msg) except Exception as exc: # noqa: BLE001 current_app.logger.warning("Mailversand fehlgeschlagen: %s", exc) if not current_app.debug: raise if current_app.debug: current_app.logger.info("DEV — Login-Code für %s: %s", user.email, code) def verify_code(user: User, code: str) -> bool: """Neuesten unbenutzten Code des Users prüfen; bei Erfolg als used markieren.""" lc = ( LoginCode.query.filter_by(user_id=user.id, used=False) .order_by(LoginCode.created_at.desc()) .first() ) if lc is None or not lc.is_valid(code): return False lc.used = True db.session.commit() return True