From 5ca85a9da9be32800f846a2b0dce717a5f661bfc Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Wed, 1 Mar 2023 19:10:37 +0100 Subject: [PATCH] =?UTF-8?q?CAS:=20options=20cas=5Fforce=20et=20cas=5Fallow?= =?UTF-8?q?=5Fscodoc=5Flogin,=20am=C3=A9liorations=20diverses.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/auth/models.py | 41 +++++++++++++++++----------- app/auth/routes.py | 37 +++++++++++++++++++++---- app/decorators.py | 2 +- app/forms/main/config_cas.py | 9 ++++-- app/models/config.py | 19 ++----------- app/scodoc/sco_etud.py | 2 -- app/scodoc/sco_import_users.py | 24 +++++++++------- app/scodoc/sco_preferences.py | 4 +-- app/scodoc/sco_users.py | 2 +- app/static/css/scodoc.css | 5 ++++ app/templates/auth/login.j2 | 14 ++++++---- app/templates/auth/user_info_page.j2 | 3 ++ app/templates/config_cas.j2 | 8 ++++++ app/views/scodoc.py | 5 +++- app/views/users.py | 16 +++++++++-- scodoc.py | 1 - tests/unit/test_users.py | 10 +++---- 17 files changed, 129 insertions(+), 73 deletions(-) diff --git a/app/auth/models.py b/app/auth/models.py index 18709de0..e8e1be50 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -22,6 +22,7 @@ import jwt from app import db, log, login from app.models import Departement from app.models import SHORT_STR_LEN +from app.models.config import ScoDocSiteConfig from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_permissions import Permission from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS @@ -71,9 +72,8 @@ class User(UserMixin, db.Model): cas_allow_scodoc_login = db.Column( db.Boolean, default=False, server_default="false", nullable=False ) - """(not yet implemented XXX) - si CAS activé, peut-on se logguer sur ScoDoc directement ? - (le rôle ScoSuperAdmin peut toujours) + """Si CAS forcé (cas_force), peut-on se logguer sur ScoDoc directement ? + (le rôle ScoSuperAdmin peut toujours, mettre à True pour les utilisateur API) """ password_hash = db.Column(db.String(128)) @@ -133,28 +133,37 @@ class User(UserMixin, db.Model): self.password_hash = None self.passwd_temp = False - def check_password(self, password): + def check_password(self, password: str) -> bool: """Check given password vs current one. Returns `True` if the password matched, `False` otherwise. """ if not self.active: # inactived users can't login return False - if (not self.password_hash) and self.password_scodoc7: - # Special case: user freshly migrated from ScoDoc7 - if scu.check_scodoc7_password(self.password_scodoc7, password): - current_app.logger.warning( - f"migrating legacy ScoDoc7 password for {self}" - ) - self.set_password(password) - self.password_scodoc7 = None - db.session.add(self) - db.session.commit() - return True - return False + + # if CAS activated and forced, allow only super-user and users with cas_allow_scodoc_login + if ScoDocSiteConfig.is_cas_enabled() and ScoDocSiteConfig.get("cas_force"): + if (not self.is_administrator()) and not self.cas_allow_scodoc_login: + return False + if not self.password_hash: # user without password can't login + if self.password_scodoc7: + # Special case: user freshly migrated from ScoDoc7 + return self._migrate_scodoc7_password(password) return False + return check_password_hash(self.password_hash, password) + def _migrate_scodoc7_password(self, password) -> bool: + """After migration, rehash password.""" + if scu.check_scodoc7_password(self.password_scodoc7, password): + current_app.logger.warning(f"migrating legacy ScoDoc7 password for {self}") + self.set_password(password) + self.password_scodoc7 = None + db.session.add(self) + db.session.commit() + return True + return False + def get_reset_password_token(self, expires_in=600): "Un token pour réinitialiser son mot de passe" return jwt.encode( diff --git a/app/auth/routes.py b/app/auth/routes.py index 5c984828..ab06dfb1 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -27,12 +27,8 @@ _ = lambda x: x # sans babel _l = _ -@bp.route("/login", methods=["GET", "POST"]) -def login(): - "ScoDoc Login form" - if current_user.is_authenticated: - return redirect(url_for("scodoc.index")) - +def _login_form(): + """le formulaire de login, avec un lien CAS s'il est configuré.""" form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(user_name=form.user_name.data).first() @@ -40,9 +36,12 @@ def login(): current_app.logger.info("login: invalid (%s)", form.user_name.data) flash(_("Nom ou mot de passe invalide")) return redirect(url_for("auth.login")) + login_user(user, remember=form.remember_me.data) + current_app.logger.info("login: success (%s)", form.user_name.data) return form.redirect("scodoc.index") + message = request.args.get("message", "") return render_template( "auth/login.j2", @@ -53,6 +52,32 @@ def login(): ) +@bp.route("/login", methods=["GET", "POST"]) +def login(): + """ScoDoc Login form + Si paramètre cas_force, redirige vers le CAS. + """ + if current_user.is_authenticated: + return redirect(url_for("scodoc.index")) + + if ScoDocSiteConfig.get("cas_force"): + current_app.logger.info("login: forcing CAS") + return redirect(url_for("cas.login")) + + return _login_form() + + +@bp.route("/login_scodoc", methods=["GET", "POST"]) +def login_scodoc(): + """ScoDoc Login form. + Formulaire login, sans redirection immédiate sur CAS si ce dernier est configuré. + Sans CAS, ce formulaire est identique à /login + """ + if current_user.is_authenticated: + return redirect(url_for("scodoc.index")) + return _login_form() + + @bp.route("/logout") def logout() -> flask.Response: "Logout a scodoc user. If CAS session, logout from CAS. Redirect." diff --git a/app/decorators.py b/app/decorators.py index 5338828f..8ececa72 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -96,7 +96,7 @@ def permission_required(permission): return decorator -def permission_required_compat_scodoc7(permission): +def permission_required_compat_scodoc7(permission): # XXX TODO A SUPPRIMER """Décorateur pour les fonctions utilisées comme API dans ScoDoc 7 Comme @permission_required mais autorise de passer directement les informations d'auth en paramètres: diff --git a/app/forms/main/config_cas.py b/app/forms/main/config_cas.py index ef8b84cf..5b6020b8 100644 --- a/app/forms/main/config_cas.py +++ b/app/forms/main/config_cas.py @@ -26,17 +26,20 @@ ############################################################################## """ -Formulaires configuration Exports Apogée (codes) +Formulaire configuration CAS """ from flask_wtf import FlaskForm from wtforms import BooleanField, SubmitField -from wtforms.fields.simple import FileField, StringField, TextAreaField +from wtforms.fields.simple import FileField, StringField class ConfigCASForm(FlaskForm): "Formulaire paramétrage CAS" - cas_enable = BooleanField("activer le CAS") + cas_enable = BooleanField("Activer le CAS") + cas_force = BooleanField( + "Forcer l'utilisation de CAS (tous les utilisateurs seront redirigés vers le CAS)" + ) cas_server = StringField( label="URL du serveur CAS", diff --git a/app/models/config.py b/app/models/config.py index 13110630..de46f95b 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -214,20 +214,6 @@ class ScoDocSiteConfig(db.Model): cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").first() return cfg is not None and cfg.value - @classmethod - def cas_enable(cls, enabled=True) -> bool: - """Active (ou déactive) le CAS. True si changement.""" - if enabled != ScoDocSiteConfig.is_cas_enabled(): - cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").first() - if cfg is None: - cfg = ScoDocSiteConfig(name="cas_enable", value="on" if enabled else "") - else: - cfg.value = "on" if enabled else "" - db.session.add(cfg) - db.session.commit() - return True - return False - @classmethod def is_entreprises_enabled(cls) -> bool: """True si on doit activer le module entreprise""" @@ -259,10 +245,11 @@ class ScoDocSiteConfig(db.Model): @classmethod def set(cls, name: str, value: str) -> bool: "Set parameter, returns True if change. Commit session." - if cls.get(name) != (value or ""): + value_str = str(value or "") + if (cls.get(name) or "") != value_str: cfg = ScoDocSiteConfig.query.filter_by(name=name).first() if cfg is None: - cfg = ScoDocSiteConfig(name=name, value=str(value)) + cfg = ScoDocSiteConfig(name=name, value=value_str) else: cfg.value = str(value or "") current_app.logger.info( diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py index 4c87bc41..7ba01743 100644 --- a/app/scodoc/sco_etud.py +++ b/app/scodoc/sco_etud.py @@ -226,8 +226,6 @@ _identiteEditor = ndb.EditableTable( "nom_usuel", "prenom", "cas_id", - "cas_allow_login", - "cas_allow_scodoc_login", "civilite", # 'M", "F", or "X" "date_naissance", "lieu_naissance", diff --git a/app/scodoc/sco_import_users.py b/app/scodoc/sco_import_users.py index 2dd52731..e593b4ec 100644 --- a/app/scodoc/sco_import_users.py +++ b/app/scodoc/sco_import_users.py @@ -31,7 +31,7 @@ import random import time from email.mime.multipart import MIMEMultipart -from flask import g, url_for +from flask import url_for from flask_login import current_user from app import db @@ -41,30 +41,34 @@ import app.scodoc.sco_utils as scu from app import log from app.scodoc.sco_exceptions import AccessDenied, ScoValueError from app.scodoc import sco_excel -from app.scodoc import sco_preferences from app.scodoc import sco_users -TITLES = ("user_name", "nom", "prenom", "email", "roles", "dept") +TITLES = ("user_name", "nom", "prenom", "email", "roles", "dept", "cas_id") COMMENTS = ( """user_name: + L'identifiant (login). Composé de lettres (minuscules ou majuscules), de chiffres ou du caractère _ """, """nom: - Maximum 64 caractères""", + Maximum 64 caractères.""", """prenom: - Maximum 64 caractères""", + Maximum 64 caractères.""", """email: - Maximum 120 caractères""", + Maximum 120 caractères.""", """roles: un plusieurs rôles séparés par ',' - chaque role est fait de 2 composantes séparées par _: - 1. Le role (Ens, Secr ou Admin) + chaque rôle est fait de 2 composantes séparées par _: + 1. Le rôle (Ens, Secr ou Admin) 2. Le département (en majuscule) - Exemple: "Ens_RT,Admin_INFO" + Exemple: "Ens_RT,Admin_INFO". """, """dept: - Le département d'appartenance du l'utillsateur. Laisser vide si l'utilisateur intervient dans plusieurs dépatements + Le département d'appartenance de l'utilisateur. Laisser vide si l'utilisateur intervient dans plusieurs départements. + """, + """cas_id: + + Identifiant de l'utilisateur sur CAS (optionnel). """, ) diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index 07ac4288..0c9b4710 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -423,9 +423,9 @@ class BasePreferences(object): "email_chefdpt", { "initvalue": "", - "title": "e-mail chef du département", + "title": "e-mail du chef du département", "size": 40, - "explanation": "utilisé pour envoi mail notification absences", + "explanation": "pour lui envoyer des notifications sur les absences", "category": "abs", "only_global": True, }, diff --git a/app/scodoc/sco_users.py b/app/scodoc/sco_users.py index f9bd5692..7da03f29 100644 --- a/app/scodoc/sco_users.py +++ b/app/scodoc/sco_users.py @@ -63,7 +63,7 @@ def index_html(all_depts=False, with_inactives=False, format="html"): ) if current_user.is_administrator(): H.append( - """   Importer des utilisateurs

""" ) diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 687fa156..803855a5 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -4545,6 +4545,11 @@ table.formation_table_recap td.heures_tp { text-align: right; } +div.cas_link { + margin-bottom: 8px; + margin-top: 16px; +} + div.cas_etat_certif_ssl { margin-top: 12px; font-style: italic; diff --git a/app/templates/auth/login.j2 b/app/templates/auth/login.j2 index 8f9f4a8c..a974fdf3 100644 --- a/app/templates/auth/login.j2 +++ b/app/templates/auth/login.j2 @@ -10,18 +10,20 @@

Connexion

-{% if is_cas_enabled %} -
-Se connecter avec CAS -
-{% endif %}
{{ wtf.quick_form(form) }}
+ +{% if is_cas_enabled %} + +{% endif %} +
-En cas d'oubli de votre mot de passe +En cas d'oubli de votre mot de passe ScoDoc cliquez ici pour le réinitialiser.

diff --git a/app/templates/auth/user_info_page.j2 b/app/templates/auth/user_info_page.j2 index d5284579..9adb97f2 100644 --- a/app/templates/auth/user_info_page.j2 +++ b/app/templates/auth/user_info_page.j2 @@ -9,6 +9,9 @@ Login : {{user.user_name}}
CAS id: {{user.cas_id or "(aucun)"}} (CAS {{'autorisé' if user.cas_allow_login else 'interdit'}} pour cet utilisateur) + {% if user.cas_allow_scodoc_login %} + (connexion sans CAS autorisée) + {% endif %}
Nom : {{user.nom or ""}}
Prénom : {{user.prenom or ""}}
diff --git a/app/templates/config_cas.j2 b/app/templates/config_cas.j2 index 430f1ffe..64e4f3c1 100644 --- a/app/templates/config_cas.j2 +++ b/app/templates/config_cas.j2 @@ -18,8 +18,16 @@ non chargé. {% endif %} +
+ℹ️ Note: si le CAS est forcé, le super-admin et les utilisateurs autorisés + à "se connecter via ScoDoc" pourront toujours se + connecter via l'adresse spéciale + {{url_for("auth.login_scodoc", _external=True)}} +
+ + {% endblock %} \ No newline at end of file diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 2aa27968..f298026a 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -144,8 +144,10 @@ def config_cas(): if request.method == "POST" and form.cancel.data: # cancel button return redirect(url_for("scodoc.index")) if form.validate_on_submit(): - if ScoDocSiteConfig.cas_enable(enabled=form.data["cas_enable"]): + if ScoDocSiteConfig.set("cas_enable", form.data["cas_enable"]): flash("CAS " + ("activé" if form.data["cas_enable"] else "désactivé")) + if ScoDocSiteConfig.set("cas_force", form.data["cas_force"]): + flash("CAS " + ("forcé" if form.data["cas_force"] else "non forcé")) if ScoDocSiteConfig.set("cas_server", form.data["cas_server"]): flash("Serveur CAS enregistré") if ScoDocSiteConfig.set("cas_attribute_id", form.data["cas_attribute_id"]): @@ -165,6 +167,7 @@ def config_cas(): elif request.method == "GET": form.cas_enable.data = ScoDocSiteConfig.get("cas_enable") + form.cas_force.data = ScoDocSiteConfig.get("cas_force") form.cas_server.data = ScoDocSiteConfig.get("cas_server") form.cas_attribute_id.data = ScoDocSiteConfig.get("cas_attribute_id") form.cas_ssl_verify.data = ScoDocSiteConfig.get("cas_ssl_verify") diff --git a/app/views/users.py b/app/views/users.py index ef2361be..d76b616a 100644 --- a/app/views/users.py +++ b/app/views/users.py @@ -403,7 +403,16 @@ def create_user_form(user_name=None, edit=0, all_roles=True): { "title": "Autorise connexion via CAS", "input_type": "boolcheckbox", - "explanation": "en test: seul le super-administrateur peut changer ce réglage", + "explanation": " seul le super-administrateur peut changer ce réglage", + "readonly": not current_user.is_administrator(), + }, + ), + ( + "cas_allow_scodoc_login", + { + "title": "Autorise connexion via ScoDoc", + "input_type": "boolcheckbox", + "explanation": " seul le super-administrateur peut changer ce réglage", "readonly": not current_user.is_administrator(), }, ), @@ -764,8 +773,9 @@ def import_users_form():
  • envoi à chaque utilisateur de son mot de passe initial par mail.
  • """ H.append( - """
    1. - Obtenir la feuille excel à remplir
    2. """ + f"""
      1. Obtenir la feuille excel à remplir
      2. """ ) F = html_sco_header.sco_footer() tf = TrivialFormulator( diff --git a/scodoc.py b/scodoc.py index 2f152b29..71f77d5b 100755 --- a/scodoc.py +++ b/scodoc.py @@ -516,7 +516,6 @@ def photos_import_files(formsemestre_id: int, xlsfile: str, zipfile: str): """Import des photos d'étudiants à partir d'une liste excel et d'un zip avec les images.""" import app as mapp from app.scodoc import sco_trombino, sco_photos - from flask_login import login_user from app.auth.models import get_super_admin sem = mapp.models.formsemestre.FormSemestre.query.get(formsemestre_id) diff --git a/tests/unit/test_users.py b/tests/unit/test_users.py index 21b13fb4..8e085424 100644 --- a/tests/unit/test_users.py +++ b/tests/unit/test_users.py @@ -4,7 +4,7 @@ Ré-écriture de test_users avec pytest. -Usage: pytest tests/unit/test_users.py +Usage: pytest tests/unit/test_users.py """ import pytest @@ -24,7 +24,7 @@ def test_password_hashing(test_client): db.session.add(u) db.session.commit() # nota: default attributes values, like active, - # are not set before the first commit() (?) + # are not set before the first commit() assert u.active u.set_password("cat") assert not u.check_password("dog") @@ -62,7 +62,7 @@ def test_roles_permissions(test_client): def test_users_roles(test_client): - dept = "XX" + dept = DEPT perm = Permission.ScoAbsChange perm2 = Permission.ScoView u = User(user_name="un_enseignant") @@ -97,14 +97,14 @@ def test_users_roles(test_client): def test_user_admin(test_client): - dept = "XX" + dept = DEPT perm = 0x1234 # a random perm u = User(user_name="un_admin", email=current_app.config["SCODOC_ADMIN_MAIL"]) db.session.add(u) assert len(u.roles) == 1 assert u.has_permission(perm, dept) # Le grand admin a accès à tous les départements: - assert u.has_permission(perm, dept + "XX") + assert u.has_permission(perm, dept + DEPT) assert u.roles[0].name == "SuperAdmin"