Initialisierung: Übertragung aus altem Repository

This commit is contained in:
2021-07-12 00:49:07 +02:00
commit c7f0c6a1c4
80 changed files with 12063 additions and 0 deletions
+477
View File
@@ -0,0 +1,477 @@
# NateMan Nachschreibtermin-Manager
# blueprints/auth.py
# Copyright © 2020 Niklas Elsbrock
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Authentifizierungs-Blueprint
"""
import functools
import time
from datetime import datetime, timedelta
from smtplib import SMTPRecipientsRefused
from flask import Blueprint, abort, current_app, flash, g, redirect, render_template, request, url_for, make_response
from .. import emails, util
from ..config_manager import config
from ..models import Lehrer, db, Session
bp = Blueprint("auth", __name__)
SESSION_COOKIE_NAME = "login_session"
@bp.before_app_request
def load_logged_in_lehrer():
"""
Lädt den angemeldeten Lehrer vor jeder HTTP-Anfrage in die g.lehrer-Variable.
"""
session_key = request.cookies.get(SESSION_COOKIE_NAME)
session = Session.get(session_key)
g.session = session
if g.session is None:
g.lehrer = None
else:
g.lehrer = g.session.lehrer
@bp.after_app_request
def clear_rotten_session_cookie(response):
"""
Löscht veraltete Session-Cookies.
"""
if request.cookies.get(SESSION_COOKIE_NAME) and not g.lehrer:
response.delete_cookie(SESSION_COOKIE_NAME)
return response
def login_required(view):
"""
Dieser Decorator kann an Views angehängt werden, die einen angemeldeten Lehrer erfordern.
"""
@functools.wraps(view)
def wrapped_view(**kwargs):
# Kein Lehrer angemeldet?
if g.lehrer is None:
flash("Bitte melden Sie sich an.", "error")
return redirect(url_for("auth.login", r=request.path), code=303)
# muss der Lehrer sein Passwort ändern?
if g.lehrer and not g.lehrer.pwd_changed:
return redirect(url_for("auth.password_change"), code=303)
return view(**kwargs)
return wrapped_view
def beratungslehrer_required(view):
"""
Dieser Decorator kann an Views angehängt werden,
die einen angemeldeten Beratungslehrer oder Administrator erfordern.
"""
@functools.wraps(view)
def wrapped_view(**kwargs):
# Kein Lehrer angemeldet?
if g.lehrer is None:
flash("Bitte melden Sie sich an.", "error")
return redirect(url_for("auth.login", r=request.path), code=303)
# Lehrer kein Beratungslehrer oder Administrator?
if not g.lehrer.can_access():
abort(403)
return
# muss der Lehrer sein Passwort ändern?
if g.lehrer and not g.lehrer.pwd_changed:
return redirect(url_for("auth.password_change"), code=303)
return view(**kwargs)
return wrapped_view
def admin_required(view):
"""
Dieser Decorator kann an Views angehängt werden, die einen angemeldeten Administrator erfordern.
"""
@functools.wraps(view)
def wrapped_view(**kwargs):
# Kein Lehrer angemeldet?
if g.lehrer is None:
flash("Bitte melden Sie sich an.", "error")
return redirect(url_for("auth.login", r=request.path), code=303)
# Lehrer kein Administrator?
if not g.lehrer.is_admin:
abort(403)
return
# muss der Lehrer sein Passwort ändern?
if g.lehrer and not g.lehrer.pwd_changed:
return redirect(url_for("auth.password_change"), code=303)
return view(**kwargs)
return wrapped_view
@bp.route("/login", methods=("GET", "POST"))
def login():
""" Anmeldung (Seite *Anmelden*)"""
if request.method != "POST":
return render_template("auth/login.html.j2")
# ELSE
request_start_time = time.process_time()
kuerzel = request.form["kuerzel"].strip()
password = request.form["password"]
remember_me = ("remember-me" in request.form)
error = False
lehrer = Lehrer.query.filter_by(kuerzel=kuerzel).first()
# existiert Lehrer nicht?
if lehrer is None:
error = True
current_app.logger.info(f"Fehlgeschlagener Anmeldeversuch von {request.remote_addr} "
f"(Ungültiges Kürzel '{kuerzel}')")
# ist das Passwort falsch?
elif not lehrer.check_password(password):
error = True
current_app.logger.info(f"Fehlgeschlagener Anmeldeversuch von {request.remote_addr} "
f"(Falsches Passwort für {lehrer.kuerzel})")
if error:
# Bei fehlgeschlagenem Anmeldeversuch 2 Sekunden vor Antwort warten
time.sleep(request_start_time - time.process_time() + 2)
flash("Ungültige Anmeldedaten.", "error")
return redirect(url_for(".login", r=request.args.get("r", None)), code=303)
# Kein Fehler -> Lehrer anmelden
current_app.logger.info(f"{lehrer} hat sich angemeldet.")
session_expiry_delta = timedelta(days=12) if remember_me else timedelta(hours=1)
session = Session.create(lehrer, session_expiry_delta)
if not lehrer.pwd_changed:
red = redirect(url_for(".password_change"), code=303)
else:
redirect_path = request.args.get("r", None)
# Wenn ein Weiterleitungspfad als GET-Parameter übergeben wurde
# (wie es z.B. der login_required-Wrapper macht, wenn kein Benutzer
# angemeldet ist), auf diesen Pfad weiterleiten, falls er mit einem
# Schrägstrich beginnt (also kein externer Link ist).
if redirect_path and len(redirect_path) > 0 and redirect_path[0] == "/":
red = redirect(redirect_path, code=303)
else:
red = redirect(url_for("klausuren.mine"), code=303)
db.session.commit()
resp = make_response(red)
resp.set_cookie(SESSION_COOKIE_NAME, session.key,
max_age=session_expiry_delta.total_seconds() if remember_me else None,
expires=session.expiry if remember_me else None, secure=config["uses-ssl"], httponly=True,
samesite="Strict")
return resp
@bp.route("/logout")
def logout():
""" Abmeldung (Seite *Abmelden*)"""
resp = make_response(redirect(url_for("auth.login"), code=303))
if g.lehrer:
current_app.logger.info(f"{g.lehrer} hat sich abgemeldet.")
resp.delete_cookie("login_session")
db.session.delete(g.session)
db.session.commit()
return resp
@bp.route("/confirm-email")
def confirm_email():
""" Emailbestätigung """
red = redirect(url_for(".login" if g.lehrer is None else "klausuren.mine"), code=303)
token = request.args.get("token", None)
if token is None:
flash("Ungültiger/veralteter Bestätigungslink.", "alert")
return red
lehrer = Lehrer.query.filter_by(confirmation_token=token).first()
if lehrer is None:
flash("Ungültiger/veralteter Bestätigungslink.", "alert")
return red
# ELSE
lehrer.confirmation_token = None
lehrer.is_confirmed = True
db.session.commit()
current_app.logger.info(f"{lehrer} hat die eigene E-Mail-Adresse ({lehrer.email}) bestätigt.")
flash("Die E-Mail-Adresse wurde erfolgreich bestätigt.", "success")
return red
@bp.route("/password-change", methods=("GET", "POST"))
def password_change():
""" Passwortänderung nach erstem Login """
# login_required kann hier nicht verwendet werden,
# da es ein verändertes Passwort erfordert
if not g.lehrer:
return redirect(url_for("auth.login"), code=303)
if g.lehrer.pwd_changed:
return redirect(url_for(".account"), code=303)
if request.method != "POST":
return render_template("auth/password-change.html.j2")
# ELSE
new_pass = request.form["new-password"]
new_pass_confirm = request.form["new-password-confirm"]
error_msg = None
if not new_pass or not new_pass_confirm:
error_msg = "Bitte geben Sie ein neues Passwort ein."
# ist das neue Passwort kürzer als 6 Zeichen?
if len(new_pass) < 6:
error_msg = "Das neue Passwort muss mindestens 6 Zeichen lang sein."
# ist das neue Passwort zu groß?
elif not util.validate_bcrypt_password(new_pass):
error_msg = "Das neue Passwort darf höchstens 72 Zeichen lang sein."
# stimmen die neuen Passwörter nicht überein?
elif new_pass != new_pass_confirm:
error_msg = "Die neuen Passwörter stimmen nicht überein."
# Änderungen vornehmen, falls kein Fehler aufgetreten ist
if error_msg:
# Es ist ein Fehler aufgetreten
flash(error_msg, "error")
return redirect(url_for(".account"), code=303)
g.lehrer.set_password(new_pass)
db.session.commit()
current_app.logger.info(f"{g.lehrer} hat das eigene Passwort geändert.")
return redirect(url_for("klausuren.mine"), code=303)
@bp.route("/password-reset", methods=("GET", "POST"))
def password_reset():
""" Passwortzurücksetzung (Seite *Passwort vergessen*)"""
red = redirect(url_for(".login" if g.lehrer is None else "klausuren.mine"), code=303)
token = request.args.get("token", None)
t_lehrer = None
if token is not None:
t_lehrer = Lehrer.query.filter_by(password_reset_token=token).first()
expired = False
if t_lehrer is not None:
expired = t_lehrer.password_reset_expiry < datetime.utcnow()
if expired:
t_lehrer.password_reset_token = None
t_lehrer.password_reset_expiry = None
db.session.commit()
if t_lehrer is None or expired:
flash("Ungültiger/veralteter Zurücksetzungslink.", "alert")
return red
if request.method == "POST":
if token is None:
# --- Antragstellung ---
lehrer_kuerzel = request.form["lehrer-kuerzel"]
email_address = request.form["email-address"]
lehrer = Lehrer.query.filter_by(kuerzel=lehrer_kuerzel, email=email_address).first()
if lehrer is None:
flash("Es gibt keine(n) Lehrer(in) mit den angegebenen Kriterien.", "error")
return redirect(url_for(".password_reset"), code=303)
if not lehrer.is_confirmed:
flash("Es gibt eine(n) Lehrer(in) mit den angegebenen Kriterien, allerdings ist dessen/deren "
"E-Mail-Adresse nicht bestätigt.\nWenden Sie sich an eine(n) Administrator(in).", "error")
return redirect(url_for(".password_reset"), code=303)
new_token = util.random_uri_safe_string(64)
lehrer.password_reset_token = new_token
lehrer.password_reset_expiry = datetime.utcnow() + timedelta(hours=1)
# Zurücksetzungsemail senden
try:
emails.send_password_reset_mail(lehrer.email, new_token)
except SMTPRecipientsRefused:
flash(f"Es konnte keine E-Mail an {lehrer.email} gesendet werden.\n"
f"Wahrscheinlich existiert diese Adresse nicht mehr.\n"
f"Wenden Sie sich an eine(n) Administrator(in).", "error")
return redirect(url_for(".password_reset"), code=303)
db.session.commit()
flash("Sie müssten nun eine E-Mail bekommen mit einem Link, über den Sie Ihr Passwort ändern können.\n"
"Der Link ist eine Stunde lang gültig.", "success")
current_app.logger.info(f"Für {lehrer} wurde eine Passwortzurücksetzung beantragt.")
else:
# --- Passwortänderung ---
new_pass = request.form["new-password"]
new_pass_confirm = request.form["new-password-confirm"]
error_msg = None
# ist das neue Passwort kürzer als 6 Zeichen?
if len(new_pass) < 6:
error_msg = "Das neue Passwort muss mindestens 6 Zeichen lang sein."
# ist das neue Passwort zu groß?
elif not util.validate_bcrypt_password(new_pass):
error_msg = "Das neue Passwort darf höchstens 72 Zeichen lang sein."
# stimmen die neuen Passwörter nicht überein?
elif new_pass != new_pass_confirm:
error_msg = "Die neuen Passwörter stimmen nicht überein."
if error_msg:
flash(error_msg, "error")
return redirect(url_for(".password_reset", token=token), code=303)
# ELSE
t_lehrer.password_reset_token = None
t_lehrer.set_password(new_pass)
db.session.commit()
current_app.logger.info(f"{t_lehrer} hat das eigene Passwort zurückgesetzt.")
flash("Das Passwort wurde erfolgreich zurückgesetzt.", "success")
return red
else:
if token is None:
# --- Formular zur Antragstellung ---
return render_template("auth/password-reset-send.html.j2")
else:
# --- Formular zur Passwortänderung ---
return render_template("auth/password-reset-do.html.j2")
@bp.route("/account", methods=("GET", "POST"))
@login_required
def account():
""" Kontoeinstellungen (Seite *Mein Konto*) """
if request.method != "POST":
return render_template("auth/account.html.j2")
# ELSE
current_password = request.form["current-password"]
new_email = request.form["new-email"].strip() or None
new_pass = request.form["new-password"]
new_pass_confirm = request.form["new-password-confirm"]
error_msg = None
change_kuerzel = False
change_email = False
change_password = False
# ist das aktuelle Passwort falsch?
if not g.lehrer.check_password(current_password):
error_msg = "Falsches aktuelles Passwort."
else:
# Neue E-Mail-Adresse, falls angegeben und nicht die aktuelle Adresse
if new_email != g.lehrer.email:
change_email = True
# ist die neue E-Mail-Adresse syntaktisch ungültig?
if new_email is not None and not util.EMAIL_ADDRESS_REGEX.match(new_email):
error_msg = "Bitte geben Sie eine gültige E-Mail-Adresse ein."
# Neues Passwort, falls angegeben
if new_pass:
change_password = True
# ist das neue Passwort kürzer als 6 Zeichen?
if len(new_pass) < 6:
error_msg = "Das neue Passwort muss mindestens 6 Zeichen lang sein."
# ist das neue Passwort zu groß?
elif not util.validate_bcrypt_password(new_pass):
error_msg = "Das neue Passwort darf höchstens 72 Zeichen lang sein."
# stimmen die neuen Passwörter nicht überein?
elif new_pass != new_pass_confirm:
error_msg = "Die neuen Passwörter stimmen nicht überein."
# Änderungen vornehmen, falls kein Fehler aufgetreten ist
if error_msg:
# Es ist ein Fehler aufgetreten
flash(error_msg, "error")
return redirect(url_for(".account"), code=303)
# ELSE
if not change_kuerzel and not change_email and not change_password:
return redirect(url_for(".account"), code=303)
if change_email:
if new_email is not None:
token = util.random_uri_safe_string(32)
# Bestätigungsemail senden
try:
emails.send_confirmation_link_mail(new_email, token)
except SMTPRecipientsRefused:
flash(f"Wir konnten keine Bestätigungsemail an {new_email} senden. "
f"Wahrscheinlich existiert diese Adresse nicht.", "error")
return redirect(url_for(".account"), code=303)
g.lehrer.confirmation_token = token
old_email = g.lehrer.email
g.lehrer.email = new_email
g.lehrer.is_confirmed = new_email is None
current_app.logger.info(f"{g.lehrer} hat die eigene E-Mail-Adresse von {old_email} "
f"auf {g.lehrer.email} geändert.")
if change_password:
g.lehrer.set_password(new_pass)
current_app.logger.info(f"{g.lehrer} hat das eigene Passwort geändert.")
db.session.commit()
flash("Benutzerdaten erfolgreich geändert.", "success")
return redirect(url_for(".account"), code=303)