215 lines
7.5 KiB
Python
215 lines
7.5 KiB
Python
"""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)
|