"""Authentifizierung & Admin-Anlage. - Login (Nutzername + Passwort, werkzeug-Hashing), Logout, Session. - Team-Scoping wird beim Login aktiviert (Team in die Session). - Admin-geschützte Route zum Anlegen neuer Nutzer (Rolle wählbar). - Keine Selbstregistrierung: den ersten Admin legt der CLI-Befehl create-admin an. """ import functools import click from flask.cli import with_appcontext from flask import ( Blueprint, flash, g, redirect, render_template, request, session, url_for, ) from werkzeug.security import check_password_hash, generate_password_hash from . import db from .models import Team, User from .team_scope import ( SESSION_USER_KEY, clear_session, current_team, get_active_team_id, set_active_session, team_query, ) bp = Blueprint("auth", __name__) # ----------------------------------------------------------------------------- # Nutzer pro Request laden # ----------------------------------------------------------------------------- @bp.before_app_request def load_logged_in_user(): """g.user pro Request setzen – team-gescoped, damit Session und Team passen.""" user_id = session.get(SESSION_USER_KEY) team_id = get_active_team_id() if user_id is None or team_id is None: g.user = None return # Bewusst über team_query: ein manipuliertes Session-Cookie kann so keinen # Nutzer aus einem fremden Team laden. g.user = team_query(User).filter_by(id=user_id).first() if g.user is None: # Session verweist ins Leere (z. B. Nutzer gelöscht) -> aufräumen. clear_session() def current_user(): return getattr(g, "user", None) @bp.app_context_processor def inject_user_and_team(): """current_user und current_team in allen Templates verfügbar machen (Topbar, Bottom-Nav, Brand-Token-Injektion in ).""" return {"current_user": current_user(), "current_team": current_team()} # ----------------------------------------------------------------------------- # Dekoratoren # ----------------------------------------------------------------------------- def login_required(view): @functools.wraps(view) def wrapped(*args, **kwargs): if current_user() is None: return redirect(url_for("auth.login", next=request.path)) return view(*args, **kwargs) return wrapped def admin_required(view): @functools.wraps(view) def wrapped(*args, **kwargs): user = current_user() if user is None: return redirect(url_for("auth.login", next=request.path)) if not user.ist_admin: return render_template("403.html"), 403 return view(*args, **kwargs) return wrapped # ----------------------------------------------------------------------------- # Login / Logout # ----------------------------------------------------------------------------- @bp.route("/login", methods=("GET", "POST")) def login(): if current_user() is not None: return redirect(url_for("main.index")) # Nutzername ist nur PRO Team eindeutig → der Login muss team-scoped sein. mehrere_teams = Team.query.count() > 1 if request.method == "POST": nutzername = (request.form.get("nutzername") or "").strip() passwort = request.form.get("passwort") or "" team_kuerzel = (request.form.get("team") or "").strip().lower() # Team bestimmen: per Kürzel; ohne Kürzel nur, wenn es genau eins gibt. if team_kuerzel: team = Team.query.filter_by(slug=team_kuerzel).first() elif not mehrere_teams: team = Team.query.first() else: team = None user = None if team is not None: user = User.query.filter_by(team_id=team.id, nutzername=nutzername).first() if user is None or not check_password_hash(user.passwort_hash, passwort): # Gleiche Meldung für alle Fälle (kein Enumerieren von Team/Nutzer/Passwort). flash("Anmeldedaten stimmen nicht.") else: set_active_session(user.id, user.team_id) next_url = request.args.get("next") # Nur lokale Pfade zulassen (kein offener Redirect). if next_url and next_url.startswith("/"): return redirect(next_url) return redirect(url_for("main.index")) return render_template("login.html", mehrere_teams=mehrere_teams) @bp.route("/logout", methods=("POST",)) def logout(): clear_session() return redirect(url_for("auth.login")) # ----------------------------------------------------------------------------- # Admin: Nutzer anlegen # ----------------------------------------------------------------------------- @bp.route("/admin/nutzer", methods=("GET", "POST")) @admin_required def nutzer_anlegen(): if request.method == "POST": nutzername = (request.form.get("nutzername") or "").strip() anzeigename = (request.form.get("anzeigename") or "").strip() passwort = request.form.get("passwort") or "" rolle = request.form.get("rolle") or User.ROLLE_EHRENAMT fehler = None if not nutzername or not anzeigename or not passwort: fehler = "Nutzername, Anzeigename und Passwort sind nötig." elif rolle not in (User.ROLLE_ADMIN, User.ROLLE_EHRENAMT): fehler = "Unbekannte Rolle." elif team_query(User).filter_by(nutzername=nutzername).first() is not None: fehler = f"Nutzername „{nutzername}“ ist im Team schon vergeben." if fehler: flash(fehler) else: user = User( team_id=get_active_team_id(), rolle=rolle, nutzername=nutzername, anzeigename=anzeigename, passwort_hash=generate_password_hash(passwort), ) db.session.add(user) db.session.commit() flash(f"Nutzer „{anzeigename}“ angelegt.") return redirect(url_for("auth.nutzer_anlegen")) nutzer = team_query(User).order_by(User.anzeigename).all() return render_template("nutzer_neu.html", nutzer=nutzer) # ----------------------------------------------------------------------------- # CLI: ersten Admin anlegen (Bootstrap, da keine Selbstregistrierung) # ----------------------------------------------------------------------------- @click.command("create-admin") @click.argument("nutzername") @click.argument("anzeigename") @click.argument("passwort") @click.option("--team", "team_name", default="CheckPoint", help="Teamname (Default: CheckPoint).") @with_appcontext def create_admin_command(nutzername, anzeigename, passwort, team_name): """Legt einen Admin im genannten Team an (Team muss existieren – siehe seed.py).""" team = Team.query.filter_by(name=team_name).first() if team is None: raise click.ClickException( f"Team „{team_name}“ existiert nicht. Erst `python seed.py` ausführen." ) if User.query.filter_by(team_id=team.id, nutzername=nutzername).first() is not None: raise click.ClickException(f"Nutzername „{nutzername}“ ist in „{team_name}“ schon vergeben.") user = User( team_id=team.id, rolle=User.ROLLE_ADMIN, nutzername=nutzername, anzeigename=anzeigename, passwort_hash=generate_password_hash(passwort), ) db.session.add(user) db.session.commit() click.echo(f"Admin „{anzeigename}“ in Team „{team_name}“ angelegt.") def register_cli(app): app.cli.add_command(create_admin_command)