Anciens formulaires: ajout csrf

This commit is contained in:
Emmanuel Viennet 2023-03-19 10:26:03 +01:00
parent dc0c20b56d
commit 32c321bcd1
14 changed files with 97 additions and 39 deletions

View File

@ -17,25 +17,28 @@ from flask import current_app, g, request
from flask import Flask from flask import Flask
from flask import abort, flash, has_request_context, jsonify from flask import abort, flash, has_request_context, jsonify
from flask import render_template from flask import render_template
from flask.json import JSONEncoder
from flask.logging import default_handler
from flask_bootstrap import Bootstrap from flask_bootstrap import Bootstrap
from flask_caching import Cache from flask_caching import Cache
from flask_cas import CAS
from flask_login import LoginManager, current_user from flask_login import LoginManager, current_user
from flask_mail import Mail from flask_mail import Mail
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_moment import Moment from flask_moment import Moment
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask.json import JSONEncoder
from flask.logging import default_handler
from jinja2 import select_autoescape from jinja2 import select_autoescape
import sqlalchemy import sqlalchemy
from flask_cas import CAS
from app.scodoc.sco_exceptions import ( from app.scodoc.sco_exceptions import (
AccessDenied, AccessDenied,
ScoBugCatcher, ScoBugCatcher,
ScoException, ScoException,
ScoGenError, ScoGenError,
ScoInvalidCSRF,
ScoValueError, ScoValueError,
APIInvalidParams, APIInvalidParams,
) )
@ -71,6 +74,15 @@ def handle_access_denied(exc):
return render_template("error_access_denied.j2", exc=exc), 403 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): def internal_server_error(exc):
"""Bugs scodoc, erreurs 500""" """Bugs scodoc, erreurs 500"""
# note that we set the 500 status explicitly # 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(ScoGenError, handle_sco_value_error)
app.register_error_handler(ScoValueError, 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(ScoBugCatcher, handle_sco_bug)
app.register_error_handler(ScoInvalidCSRF, handle_invalid_csrf)
app.register_error_handler(AccessDenied, handle_access_denied) app.register_error_handler(AccessDenied, handle_access_denied)
app.register_error_handler(500, internal_server_error) app.register_error_handler(500, internal_server_error)
app.register_error_handler(503, postgresql_server_error) app.register_error_handler(503, postgresql_server_error)

View File

@ -5,12 +5,13 @@
import http import http
import flask 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 from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
import flask_login import flask_login
from app import login from app import login
from app.auth.models import User from app.auth.models import User
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_utils import json_error from app.scodoc.sco_utils import json_error
basic_auth = HTTPBasicAuth() basic_auth = HTTPBasicAuth()
@ -84,3 +85,15 @@ def unauthorized():
if request.blueprint == "api" or request.blueprint == "apiweb": if request.blueprint == "api" or request.blueprint == "apiweb":
return json_error(http.HTTPStatus.UNAUTHORIZED, "Non autorise (logic)") return json_error(http.HTTPStatus.UNAUTHORIZED, "Non autorise (logic)")
return redirect(url_for("auth.login")) 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"))

View File

@ -6,12 +6,11 @@ auth.routes.py
import flask import flask
from flask import current_app, flash, render_template from flask import current_app, flash, render_template
from flask import redirect, url_for, request 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 sqlalchemy import func
from app import db from app import db
from app.auth import bp from app.auth import bp, cas, logic
from app.auth import cas
from app.auth.forms import ( from app.auth.forms import (
CASUsersImportConfigForm, CASUsersImportConfigForm,
LoginForm, LoginForm,
@ -87,14 +86,7 @@ def login_scodoc():
@bp.route("/logout") @bp.route("/logout")
def logout() -> flask.Response: def logout() -> flask.Response:
"Logout a scodoc user. If CAS session, logout from CAS. Redirect." "Logout a scodoc user. If CAS session, logout from CAS. Redirect."
if current_user: return logic.logout()
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"))
@bp.route("/create_user", methods=["GET", "POST"]) @bp.route("/create_user", methods=["GET", "POST"])
@ -140,7 +132,10 @@ def reset_password_request():
) )
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
return render_template( 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(),
) )

View File

@ -10,6 +10,11 @@
""" """
import html import html
import re 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 import app.scodoc.sco_utils as scu
# re validant dd/mm/yyyy # re validant dd/mm/yyyy
@ -22,7 +27,7 @@ def TrivialFormulator(
form_url, form_url,
values, values,
formdescription=(), formdescription=(),
initvalues={}, initvalues=None,
method="post", method="post",
enctype=None, enctype=None,
submitlabel="OK", submitlabel="OK",
@ -32,7 +37,7 @@ def TrivialFormulator(
cssclass="", cssclass="",
cancelbutton=None, cancelbutton=None,
submitbutton=True, submitbutton=True,
submitbuttonattributes=[], submitbuttonattributes=None,
top_buttons=False, # place buttons at top of form top_buttons=False, # place buttons at top of form
bottom_buttons=True, # buttons after form bottom_buttons=True, # buttons after form
html_foot_markup="", html_foot_markup="",
@ -99,7 +104,7 @@ def TrivialFormulator(
form_url, form_url,
values, values,
formdescription, formdescription,
initvalues, initvalues or {},
method, method,
enctype, enctype,
submitlabel, submitlabel,
@ -109,7 +114,7 @@ def TrivialFormulator(
cssclass=cssclass, cssclass=cssclass,
cancelbutton=cancelbutton, cancelbutton=cancelbutton,
submitbutton=submitbutton, submitbutton=submitbutton,
submitbuttonattributes=submitbuttonattributes, submitbuttonattributes=submitbuttonattributes or [],
top_buttons=top_buttons, top_buttons=top_buttons,
bottom_buttons=bottom_buttons, bottom_buttons=bottom_buttons,
html_foot_markup=html_foot_markup, html_foot_markup=html_foot_markup,
@ -134,8 +139,8 @@ class TF(object):
self, self,
form_url, form_url,
values, values,
formdescription=[], formdescription=None,
initvalues={}, initvalues=None,
method="POST", method="POST",
enctype=None, enctype=None,
submitlabel="OK", submitlabel="OK",
@ -145,7 +150,7 @@ class TF(object):
cssclass="", cssclass="",
cancelbutton=None, cancelbutton=None,
submitbutton=True, submitbutton=True,
submitbuttonattributes=[], submitbuttonattributes=None,
top_buttons=False, # place buttons at top of form top_buttons=False, # place buttons at top of form
bottom_buttons=True, # buttons after form bottom_buttons=True, # buttons after form
html_foot_markup="", # html snippet put at the end, just after the table 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.form_url = form_url
self.values = values.copy() self.values = values.copy()
self.formdescription = list(formdescription) self.formdescription = list(formdescription or [])
self.initvalues = initvalues self.initvalues = initvalues or {}
self.method = method self.method = method
self.enctype = enctype self.enctype = enctype
self.submitlabel = submitlabel self.submitlabel = submitlabel
@ -171,7 +176,7 @@ class TF(object):
self.cssclass = cssclass self.cssclass = cssclass
self.cancelbutton = cancelbutton self.cancelbutton = cancelbutton
self.submitbutton = submitbutton self.submitbutton = submitbutton
self.submitbuttonattributes = submitbuttonattributes self.submitbuttonattributes = submitbuttonattributes or []
self.top_buttons = top_buttons self.top_buttons = top_buttons
self.bottom_buttons = bottom_buttons self.bottom_buttons = bottom_buttons
self.html_foot_markup = html_foot_markup self.html_foot_markup = html_foot_markup
@ -189,11 +194,26 @@ class TF(object):
"true if form has been submitted" "true if form has been submitted"
if self.is_submitted: if self.is_submitted:
return True 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): def canceled(self):
"true if form has been canceled" "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): def getform(self):
"return HTML form" "return HTML form"
@ -447,7 +467,13 @@ class TF(object):
self.form_attrs, self.form_attrs,
) )
) )
R.append('<input type="hidden" name="%s_submitted" value="1">' % self.formid) if self.method == "post":
R.append(
f"""<input type="hidden" name="csrf_token" value="{
flask_wtf.csrf.generate_csrf()
}">"""
)
R.append(f"""<input type="hidden" name="{self.formid}_submitted" value="1">""")
if self.top_buttons: if self.top_buttons:
R.append(buttons_markup + "<p></p>") R.append(buttons_markup + "<p></p>")
R.append(self.before_table.format(title=self.title)) R.append(self.before_table.format(title=self.title))

View File

@ -500,7 +500,6 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
scu.get_request_args(), scu.get_request_args(),
descr, descr,
cancelbutton="Annuler", cancelbutton="Annuler",
method="POST",
submitlabel="Générer et archiver les documents", submitlabel="Générer et archiver les documents",
name="tf", name="tf",
formid="group_selector", formid="group_selector",

View File

@ -38,6 +38,10 @@ class InvalidNoteValue(ScoException):
"Valeur note invalide. Usage interne saisie note." "Valeur note invalide. Usage interne saisie note."
class ScoInvalidCSRF(ScoException):
"Erreur validation token CSRF"
class ScoValueError(ScoException): class ScoValueError(ScoException):
"Exception avec page d'erreur utilisateur, et qui stoque dest_url" "Exception avec page d'erreur utilisateur, et qui stoque dest_url"
# mal nommée: super classe de toutes les exceptions avec page # mal nommée: super classe de toutes les exceptions avec page

View File

@ -122,7 +122,6 @@ def formsemestre_custommenu_edit(formsemestre_id):
descr, descr,
initvalues=initvalues, initvalues=initvalues,
cancelbutton="Annuler", cancelbutton="Annuler",
method="GET",
submitlabel="Enregistrer", submitlabel="Enregistrer",
name="tf", name="tf",
) )

View File

@ -633,7 +633,6 @@ function chkbx_select(field_id, state) {
descr, descr,
initvalues, initvalues,
cancelbutton="Annuler", cancelbutton="Annuler",
method="post",
submitlabel="Modifier les inscriptions", submitlabel="Modifier les inscriptions",
cssclass="inscription", cssclass="inscription",
name="tf", name="tf",

View File

@ -1441,7 +1441,6 @@ def groups_auto_repartition(partition_id=None):
descr, descr,
{}, {},
cancelbutton="Annuler", cancelbutton="Annuler",
method="GET",
submitlabel="Créer et peupler les groupes", submitlabel="Créer et peupler les groupes",
name="tf", name="tf",
) )

View File

@ -186,7 +186,7 @@ def do_evaluation_listenotes(
cancelbutton=None, cancelbutton=None,
submitbutton=None, submitbutton=None,
bottom_buttons=False, bottom_buttons=False,
method="GET", method="GET", # consultation
cssclass="noprint", cssclass="noprint",
name="tf", name="tf",
is_submitted=True, # toujours "soumis" (démarre avec liste complète) is_submitted=True, # toujours "soumis" (démarre avec liste complète)

View File

@ -385,7 +385,6 @@ def formsemestre_pvjury_pdf(formsemestre_id, group_ids: list[int] = None, etudid
scu.get_request_args(), scu.get_request_args(),
descr, descr,
cancelbutton="Annuler", cancelbutton="Annuler",
method="get",
submitlabel="Générer document", submitlabel="Générer document",
name="tf", name="tf",
formid="group_selector", formid="group_selector",
@ -554,7 +553,6 @@ def formsemestre_lettres_individuelles(formsemestre_id, group_ids=[]):
scu.get_request_args(), scu.get_request_args(),
descr, descr,
cancelbutton="Annuler", cancelbutton="Annuler",
method="POST",
submitlabel="Générer document", submitlabel="Générer document",
name="tf", name="tf",
formid="group_selector", formid="group_selector",

View File

@ -9,4 +9,15 @@
{{ wtf.quick_form(form) }} {{ wtf.quick_form(form) }}
</div> </div>
</div> </div>
{% if is_cas_enabled %}
<div style="margin-top: 12px; color: red;">
<p>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.
</p>
<p>
Si vous vous connectez via vos identifiants de l'université (CAS), passez
par la procédure de celle-ci (ENT ou autre).
</p>
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -1248,7 +1248,7 @@ def XMLgetBilletsEtud(etudid=False, code_nip=False):
return "" return ""
@bp.route("/list_billets", methods=["GET"]) @bp.route("/list_billets", methods=["GET", "POST"])
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@scodoc7func @scodoc7func
@ -1271,7 +1271,6 @@ def list_billets():
request.base_url, request.base_url,
scu.get_request_args(), scu.get_request_args(),
(("billet_id", {"input_type": "text", "title": "Numéro du billet :"}),), (("billet_id", {"input_type": "text", "title": "Numéro du billet :"}),),
method="get",
submitbutton=False, submitbutton=False,
) )
if tf[0] == 0: if tf[0] == 0:

View File

@ -2916,7 +2916,10 @@ sco_publish(
methods=["GET", "POST"], methods=["GET", "POST"],
) )
sco_publish( sco_publish(
"/formsemestre_pvjury_pdf", sco_pv_forms.formsemestre_pvjury_pdf, Permission.ScoView "/formsemestre_pvjury_pdf",
sco_pv_forms.formsemestre_pvjury_pdf,
Permission.ScoView,
methods=["GET", "POST"],
) )
sco_publish( sco_publish(
"/feuille_preparation_jury", "/feuille_preparation_jury",