CP_EHRENAMT/app/auth.py
2026-06-25 18:58:44 +02:00

215 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 <head>)."""
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)