diff --git a/app/__init__.py b/app/__init__.py index 9614d55b..db66bc4d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -17,25 +17,28 @@ from flask import current_app, g, request from flask import Flask from flask import abort, flash, has_request_context, jsonify from flask import render_template +from flask.json import JSONEncoder +from flask.logging import default_handler + from flask_bootstrap import Bootstrap from flask_caching import Cache -from flask_cas import CAS from flask_login import LoginManager, current_user from flask_mail import Mail from flask_migrate import Migrate from flask_moment import Moment from flask_sqlalchemy import SQLAlchemy -from flask.json import JSONEncoder -from flask.logging import default_handler from jinja2 import select_autoescape import sqlalchemy +from flask_cas import CAS + from app.scodoc.sco_exceptions import ( AccessDenied, ScoBugCatcher, ScoException, ScoGenError, + ScoInvalidCSRF, ScoValueError, APIInvalidParams, ) @@ -71,6 +74,15 @@ def handle_access_denied(exc): return render_template("error_access_denied.j2", exc=exc), 403 +def handle_invalid_csrf(exc): + """Form submit with invalid CSRF token""" + # logout user and go back to login page with an error message + from app import auth + + auth.logic.logout() + return render_template("error_csrf.j2", exc=exc), 404 + + def internal_server_error(exc): """Bugs scodoc, erreurs 500""" # note that we set the 500 status explicitly @@ -260,6 +272,7 @@ def create_app(config_class=DevConfig): app.register_error_handler(ScoGenError, handle_sco_value_error) app.register_error_handler(ScoValueError, handle_sco_value_error) app.register_error_handler(ScoBugCatcher, handle_sco_bug) + app.register_error_handler(ScoInvalidCSRF, handle_invalid_csrf) app.register_error_handler(AccessDenied, handle_access_denied) app.register_error_handler(500, internal_server_error) app.register_error_handler(503, postgresql_server_error) diff --git a/app/auth/logic.py b/app/auth/logic.py index a1737a3b..fc4ee02b 100644 --- a/app/auth/logic.py +++ b/app/auth/logic.py @@ -5,12 +5,13 @@ import http import flask -from flask import g, redirect, request, url_for +from flask import current_app, g, redirect, request, url_for from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth import flask_login from app import login from app.auth.models import User +from app.models.config import ScoDocSiteConfig from app.scodoc.sco_utils import json_error basic_auth = HTTPBasicAuth() @@ -84,3 +85,15 @@ def unauthorized(): if request.blueprint == "api" or request.blueprint == "apiweb": return json_error(http.HTTPStatus.UNAUTHORIZED, "Non autorise (logic)") return redirect(url_for("auth.login")) + + +def logout() -> flask.Response: + """Logout the current user: If CAS session, logout from CAS. Redirect.""" + if flask_login.current_user: + user_name = getattr(flask_login.current_user, "user_name", "anonymous") + current_app.logger.info(f"logout user {user_name}") + flask_login.logout_user() + if ScoDocSiteConfig.is_cas_enabled() and flask.session.get("scodoc_cas_login_date"): + flask.session.pop("scodoc_cas_login_date", None) + return redirect(url_for("cas.logout")) + return redirect(url_for("scodoc.index")) diff --git a/app/auth/routes.py b/app/auth/routes.py index c3b4f717..8fedf302 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -6,12 +6,11 @@ auth.routes.py import flask from flask import current_app, flash, render_template from flask import redirect, url_for, request -from flask_login import login_user, logout_user, current_user +from flask_login import login_user, current_user from sqlalchemy import func from app import db -from app.auth import bp -from app.auth import cas +from app.auth import bp, cas, logic from app.auth.forms import ( CASUsersImportConfigForm, LoginForm, @@ -87,14 +86,7 @@ def login_scodoc(): @bp.route("/logout") def logout() -> flask.Response: "Logout a scodoc user. If CAS session, logout from CAS. Redirect." - if current_user: - user_name = getattr(current_user, "user_name", "anonymous") - current_app.logger.info(f"logout user {user_name}") - logout_user() - if ScoDocSiteConfig.is_cas_enabled() and flask.session.get("scodoc_cas_login_date"): - flask.session.pop("scodoc_cas_login_date", None) - return redirect(url_for("cas.logout")) - return redirect(url_for("scodoc.index")) + return logic.logout() @bp.route("/create_user", methods=["GET", "POST"]) @@ -140,7 +132,10 @@ def reset_password_request(): ) return redirect(url_for("auth.login")) return render_template( - "auth/reset_password_request.j2", title=_("Reset Password"), form=form + "auth/reset_password_request.j2", + title=_("Reset Password"), + form=form, + is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(), ) diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py index 0e805f01..b673a3eb 100644 --- a/app/scodoc/TrivialFormulator.py +++ b/app/scodoc/TrivialFormulator.py @@ -10,6 +10,11 @@ """ import html import re + +import flask_wtf +import wtforms +from app import log +from app.scodoc.sco_exceptions import ScoInvalidCSRF import app.scodoc.sco_utils as scu # re validant dd/mm/yyyy @@ -22,7 +27,7 @@ def TrivialFormulator( form_url, values, formdescription=(), - initvalues={}, + initvalues=None, method="post", enctype=None, submitlabel="OK", @@ -32,7 +37,7 @@ def TrivialFormulator( cssclass="", cancelbutton=None, submitbutton=True, - submitbuttonattributes=[], + submitbuttonattributes=None, top_buttons=False, # place buttons at top of form bottom_buttons=True, # buttons after form html_foot_markup="", @@ -99,7 +104,7 @@ def TrivialFormulator( form_url, values, formdescription, - initvalues, + initvalues or {}, method, enctype, submitlabel, @@ -109,7 +114,7 @@ def TrivialFormulator( cssclass=cssclass, cancelbutton=cancelbutton, submitbutton=submitbutton, - submitbuttonattributes=submitbuttonattributes, + submitbuttonattributes=submitbuttonattributes or [], top_buttons=top_buttons, bottom_buttons=bottom_buttons, html_foot_markup=html_foot_markup, @@ -134,8 +139,8 @@ class TF(object): self, form_url, values, - formdescription=[], - initvalues={}, + formdescription=None, + initvalues=None, method="POST", enctype=None, submitlabel="OK", @@ -145,7 +150,7 @@ class TF(object): cssclass="", cancelbutton=None, submitbutton=True, - submitbuttonattributes=[], + submitbuttonattributes=None, top_buttons=False, # place buttons at top of form bottom_buttons=True, # buttons after form html_foot_markup="", # html snippet put at the end, just after the table @@ -157,8 +162,8 @@ class TF(object): ): self.form_url = form_url self.values = values.copy() - self.formdescription = list(formdescription) - self.initvalues = initvalues + self.formdescription = list(formdescription or []) + self.initvalues = initvalues or {} self.method = method self.enctype = enctype self.submitlabel = submitlabel @@ -171,7 +176,7 @@ class TF(object): self.cssclass = cssclass self.cancelbutton = cancelbutton self.submitbutton = submitbutton - self.submitbuttonattributes = submitbuttonattributes + self.submitbuttonattributes = submitbuttonattributes or [] self.top_buttons = top_buttons self.bottom_buttons = bottom_buttons self.html_foot_markup = html_foot_markup @@ -189,11 +194,26 @@ class TF(object): "true if form has been submitted" if self.is_submitted: return True - return self.values.get("%s_submitted" % self.formid, False) + form_submitted = self.values.get(f"{self.formid}_submitted", False) + if form_submitted: + self.check_csrf() + return form_submitted + + def check_csrf(self): + """check token for POST forms. + Raises ScoInvalidCSRF on failure. + """ + if self.method == "post": + token = self.values.get("csrf_token") + try: + flask_wtf.csrf.validate_csrf(token) + except wtforms.validators.ValidationError as exc: + log(f"Form.check_csrf: invalid CSRF token\n{exc.args}") + raise ScoInvalidCSRF() from exc def canceled(self): "true if form has been canceled" - return self.values.get("%s_cancel" % self.formid, False) + return self.values.get(f"{self.formid}_cancel", False) def getform(self): "return HTML form" @@ -447,7 +467,13 @@ class TF(object): self.form_attrs, ) ) - R.append('' % self.formid) + if self.method == "post": + R.append( + f"""""" + ) + R.append(f"""""") if self.top_buttons: R.append(buttons_markup + "
") R.append(self.before_table.format(title=self.title)) diff --git a/app/scodoc/sco_archives.py b/app/scodoc/sco_archives.py index e63ef32b..c95add7d 100644 --- a/app/scodoc/sco_archives.py +++ b/app/scodoc/sco_archives.py @@ -500,7 +500,6 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement. scu.get_request_args(), descr, cancelbutton="Annuler", - method="POST", submitlabel="Générer et archiver les documents", name="tf", formid="group_selector", diff --git a/app/scodoc/sco_exceptions.py b/app/scodoc/sco_exceptions.py index 77c9a077..a2186940 100644 --- a/app/scodoc/sco_exceptions.py +++ b/app/scodoc/sco_exceptions.py @@ -38,6 +38,10 @@ class InvalidNoteValue(ScoException): "Valeur note invalide. Usage interne saisie note." +class ScoInvalidCSRF(ScoException): + "Erreur validation token CSRF" + + class ScoValueError(ScoException): "Exception avec page d'erreur utilisateur, et qui stoque dest_url" # mal nommée: super classe de toutes les exceptions avec page diff --git a/app/scodoc/sco_formsemestre_custommenu.py b/app/scodoc/sco_formsemestre_custommenu.py index ed39904b..ce9557eb 100644 --- a/app/scodoc/sco_formsemestre_custommenu.py +++ b/app/scodoc/sco_formsemestre_custommenu.py @@ -122,7 +122,6 @@ def formsemestre_custommenu_edit(formsemestre_id): descr, initvalues=initvalues, cancelbutton="Annuler", - method="GET", submitlabel="Enregistrer", name="tf", ) diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index 1d0a25ef..79f7f006 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -633,7 +633,6 @@ function chkbx_select(field_id, state) { descr, initvalues, cancelbutton="Annuler", - method="post", submitlabel="Modifier les inscriptions", cssclass="inscription", name="tf", diff --git a/app/scodoc/sco_groups.py b/app/scodoc/sco_groups.py index 08d09bea..36d6946c 100644 --- a/app/scodoc/sco_groups.py +++ b/app/scodoc/sco_groups.py @@ -1441,7 +1441,6 @@ def groups_auto_repartition(partition_id=None): descr, {}, cancelbutton="Annuler", - method="GET", submitlabel="Créer et peupler les groupes", name="tf", ) diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py index f0451941..ac3f94aa 100644 --- a/app/scodoc/sco_liste_notes.py +++ b/app/scodoc/sco_liste_notes.py @@ -186,7 +186,7 @@ def do_evaluation_listenotes( cancelbutton=None, submitbutton=None, bottom_buttons=False, - method="GET", + method="GET", # consultation cssclass="noprint", name="tf", is_submitted=True, # toujours "soumis" (démarre avec liste complète) diff --git a/app/scodoc/sco_pv_forms.py b/app/scodoc/sco_pv_forms.py index a60a88ca..13fe41c6 100644 --- a/app/scodoc/sco_pv_forms.py +++ b/app/scodoc/sco_pv_forms.py @@ -385,7 +385,6 @@ def formsemestre_pvjury_pdf(formsemestre_id, group_ids: list[int] = None, etudid scu.get_request_args(), descr, cancelbutton="Annuler", - method="get", submitlabel="Générer document", name="tf", formid="group_selector", @@ -554,7 +553,6 @@ def formsemestre_lettres_individuelles(formsemestre_id, group_ids=[]): scu.get_request_args(), descr, cancelbutton="Annuler", - method="POST", submitlabel="Générer document", name="tf", formid="group_selector", diff --git a/app/templates/auth/reset_password_request.j2 b/app/templates/auth/reset_password_request.j2 index 773007a3..291d89a3 100644 --- a/app/templates/auth/reset_password_request.j2 +++ b/app/templates/auth/reset_password_request.j2 @@ -9,4 +9,15 @@ {{ wtf.quick_form(form) }} +{% if is_cas_enabled %} +Attention: ce mécanisme permet de changer le mot de passe ScoDoc + mais ne changera pas votre mot de passe sur le système de l'établissement. +
++ Si vous vous connectez via vos identifiants de l'université (CAS), passez + par la procédure de celle-ci (ENT ou autre). +
+