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 18709de043..e8e1be50b2 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 5c98482860..ab06dfb16b 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 5338828f45..8ececa72f6 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 ef8b84cf3c..5b6020b844 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 131106308c..de46f95b75 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 4c87bc4142..7ba017434c 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 2dd5273199..e593b4eccd 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 07ac4288b0..0c9b4710c2 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 f9bd569224..7da03f29ce 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 687fa156e1..803855a5e2 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 8f9f4a8cfe..a974fdf3cf 100644
--- a/app/templates/auth/login.j2
+++ b/app/templates/auth/login.j2
@@ -10,18 +10,20 @@
Connexion
-{% if is_cas_enabled %}
-
-{% 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 d5284579af..9adb97f235 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 430f1ffe8f..64e4f3c1a3 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 2aa2796877..f298026aaf 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 ef2361be93..d76b616ab5 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(
- """-
- Obtenir la feuille excel à remplir
- """
+ f"""
- Obtenir la feuille excel à remplir
- """
)
F = html_sco_header.sco_footer()
tf = TrivialFormulator(
diff --git a/scodoc.py b/scodoc.py
index 2f152b29a7..71f77d5b83 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 21b13fb426..8e08542478 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"