CAS: options cas_force et cas_allow_scodoc_login, améliorations diverses.

This commit is contained in:
Emmanuel Viennet 2023-03-01 19:10:37 +01:00
parent dc13e8bba5
commit 5ca85a9da9
17 changed files with 129 additions and 73 deletions

View File

@ -22,6 +22,7 @@ import jwt
from app import db, log, login from app import db, log, login
from app.models import Departement from app.models import Departement
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS 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( cas_allow_scodoc_login = db.Column(
db.Boolean, default=False, server_default="false", nullable=False db.Boolean, default=False, server_default="false", nullable=False
) )
"""(not yet implemented XXX) """Si CAS forcé (cas_force), peut-on se logguer sur ScoDoc directement ?
si CAS activé, peut-on se logguer sur ScoDoc directement ? (le rôle ScoSuperAdmin peut toujours, mettre à True pour les utilisateur API)
(le rôle ScoSuperAdmin peut toujours)
""" """
password_hash = db.Column(db.String(128)) password_hash = db.Column(db.String(128))
@ -133,28 +133,37 @@ class User(UserMixin, db.Model):
self.password_hash = None self.password_hash = None
self.passwd_temp = False self.passwd_temp = False
def check_password(self, password): def check_password(self, password: str) -> bool:
"""Check given password vs current one. """Check given password vs current one.
Returns `True` if the password matched, `False` otherwise. Returns `True` if the password matched, `False` otherwise.
""" """
if not self.active: # inactived users can't login if not self.active: # inactived users can't login
return False return False
if (not self.password_hash) and self.password_scodoc7:
# Special case: user freshly migrated from ScoDoc7 # if CAS activated and forced, allow only super-user and users with cas_allow_scodoc_login
if scu.check_scodoc7_password(self.password_scodoc7, password): if ScoDocSiteConfig.is_cas_enabled() and ScoDocSiteConfig.get("cas_force"):
current_app.logger.warning( if (not self.is_administrator()) and not self.cas_allow_scodoc_login:
f"migrating legacy ScoDoc7 password for {self}" return False
)
self.set_password(password)
self.password_scodoc7 = None
db.session.add(self)
db.session.commit()
return True
return False
if not self.password_hash: # user without password can't login 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 False
return check_password_hash(self.password_hash, password) 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): def get_reset_password_token(self, expires_in=600):
"Un token pour réinitialiser son mot de passe" "Un token pour réinitialiser son mot de passe"
return jwt.encode( return jwt.encode(

View File

@ -27,12 +27,8 @@ _ = lambda x: x # sans babel
_l = _ _l = _
@bp.route("/login", methods=["GET", "POST"]) def _login_form():
def login(): """le formulaire de login, avec un lien CAS s'il est configuré."""
"ScoDoc Login form"
if current_user.is_authenticated:
return redirect(url_for("scodoc.index"))
form = LoginForm() form = LoginForm()
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter_by(user_name=form.user_name.data).first() 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) current_app.logger.info("login: invalid (%s)", form.user_name.data)
flash(_("Nom ou mot de passe invalide")) flash(_("Nom ou mot de passe invalide"))
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
login_user(user, remember=form.remember_me.data) login_user(user, remember=form.remember_me.data)
current_app.logger.info("login: success (%s)", form.user_name.data) current_app.logger.info("login: success (%s)", form.user_name.data)
return form.redirect("scodoc.index") return form.redirect("scodoc.index")
message = request.args.get("message", "") message = request.args.get("message", "")
return render_template( return render_template(
"auth/login.j2", "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") @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."

View File

@ -96,7 +96,7 @@ def permission_required(permission):
return decorator 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 """Décorateur pour les fonctions utilisées comme API dans ScoDoc 7
Comme @permission_required mais autorise de passer directement Comme @permission_required mais autorise de passer directement
les informations d'auth en paramètres: les informations d'auth en paramètres:

View File

@ -26,17 +26,20 @@
############################################################################## ##############################################################################
""" """
Formulaires configuration Exports Apogée (codes) Formulaire configuration CAS
""" """
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import BooleanField, SubmitField from wtforms import BooleanField, SubmitField
from wtforms.fields.simple import FileField, StringField, TextAreaField from wtforms.fields.simple import FileField, StringField
class ConfigCASForm(FlaskForm): class ConfigCASForm(FlaskForm):
"Formulaire paramétrage CAS" "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( cas_server = StringField(
label="URL du serveur CAS", label="URL du serveur CAS",

View File

@ -214,20 +214,6 @@ class ScoDocSiteConfig(db.Model):
cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").first() cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").first()
return cfg is not None and cfg.value 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 @classmethod
def is_entreprises_enabled(cls) -> bool: def is_entreprises_enabled(cls) -> bool:
"""True si on doit activer le module entreprise""" """True si on doit activer le module entreprise"""
@ -259,10 +245,11 @@ class ScoDocSiteConfig(db.Model):
@classmethod @classmethod
def set(cls, name: str, value: str) -> bool: def set(cls, name: str, value: str) -> bool:
"Set parameter, returns True if change. Commit session." "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() cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if cfg is None: if cfg is None:
cfg = ScoDocSiteConfig(name=name, value=str(value)) cfg = ScoDocSiteConfig(name=name, value=value_str)
else: else:
cfg.value = str(value or "") cfg.value = str(value or "")
current_app.logger.info( current_app.logger.info(

View File

@ -226,8 +226,6 @@ _identiteEditor = ndb.EditableTable(
"nom_usuel", "nom_usuel",
"prenom", "prenom",
"cas_id", "cas_id",
"cas_allow_login",
"cas_allow_scodoc_login",
"civilite", # 'M", "F", or "X" "civilite", # 'M", "F", or "X"
"date_naissance", "date_naissance",
"lieu_naissance", "lieu_naissance",

View File

@ -31,7 +31,7 @@ import random
import time import time
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from flask import g, url_for from flask import url_for
from flask_login import current_user from flask_login import current_user
from app import db from app import db
@ -41,30 +41,34 @@ import app.scodoc.sco_utils as scu
from app import log from app import log
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
from app.scodoc import sco_excel from app.scodoc import sco_excel
from app.scodoc import sco_preferences
from app.scodoc import sco_users 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 = ( COMMENTS = (
"""user_name: """user_name:
L'identifiant (login).
Composé de lettres (minuscules ou majuscules), de chiffres ou du caractère _ Composé de lettres (minuscules ou majuscules), de chiffres ou du caractère _
""", """,
"""nom: """nom:
Maximum 64 caractères""", Maximum 64 caractères.""",
"""prenom: """prenom:
Maximum 64 caractères""", Maximum 64 caractères.""",
"""email: """email:
Maximum 120 caractères""", Maximum 120 caractères.""",
"""roles: """roles:
un plusieurs rôles séparés par ',' un plusieurs rôles séparés par ','
chaque role est fait de 2 composantes séparées par _: chaque rôle est fait de 2 composantes séparées par _:
1. Le role (Ens, Secr ou Admin) 1. Le rôle (Ens, Secr ou Admin)
2. Le département (en majuscule) 2. Le département (en majuscule)
Exemple: "Ens_RT,Admin_INFO" Exemple: "Ens_RT,Admin_INFO".
""", """,
"""dept: """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).
""", """,
) )

View File

@ -423,9 +423,9 @@ class BasePreferences(object):
"email_chefdpt", "email_chefdpt",
{ {
"initvalue": "", "initvalue": "",
"title": "e-mail chef du département", "title": "e-mail du chef du département",
"size": 40, "size": 40,
"explanation": "utilisé pour envoi mail notification absences", "explanation": "pour lui envoyer des notifications sur les absences",
"category": "abs", "category": "abs",
"only_global": True, "only_global": True,
}, },

View File

@ -63,7 +63,7 @@ def index_html(all_depts=False, with_inactives=False, format="html"):
) )
if current_user.is_administrator(): if current_user.is_administrator():
H.append( H.append(
"""&nbsp;&nbsp; <a href="{url_for("users.import_users_form", f"""&nbsp;&nbsp; <a href="{url_for("users.import_users_form",
scodoc_dept=g.scodoc_dept) scodoc_dept=g.scodoc_dept)
}" class="stdlink">Importer des utilisateurs</a></p>""" }" class="stdlink">Importer des utilisateurs</a></p>"""
) )

View File

@ -4545,6 +4545,11 @@ table.formation_table_recap td.heures_tp {
text-align: right; text-align: right;
} }
div.cas_link {
margin-bottom: 8px;
margin-top: 16px;
}
div.cas_etat_certif_ssl { div.cas_etat_certif_ssl {
margin-top: 12px; margin-top: 12px;
font-style: italic; font-style: italic;

View File

@ -10,18 +10,20 @@
<h1>Connexion</h1> <h1>Connexion</h1>
{% if is_cas_enabled %}
<div class"cas_link">
<a href="{{ url_for('cas.login') }}" class="stdlink">Se connecter avec CAS</a>
</div>
{% endif %}
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">
{{ wtf.quick_form(form) }} {{ wtf.quick_form(form) }}
</div> </div>
</div> </div>
{% if is_cas_enabled %}
<div class="cas_link">
ou bien <a href="{{ url_for('cas.login') }}" class="stdlink">se connecter avec CAS</a>
</div>
{% endif %}
<br> <br>
En cas d'oubli de votre mot de passe En cas d'oubli de votre mot de passe ScoDoc
<a href="{{ url_for('auth.reset_password_request') }}">cliquez ici pour le réinitialiser</a>. <a href="{{ url_for('auth.reset_password_request') }}">cliquez ici pour le réinitialiser</a>.
</p> </p>

View File

@ -9,6 +9,9 @@
<b>Login :</b> {{user.user_name}}<br> <b>Login :</b> {{user.user_name}}<br>
<b>CAS id:</b> {{user.cas_id or "(aucun)"}} <b>CAS id:</b> {{user.cas_id or "(aucun)"}}
(CAS {{'autorisé' if user.cas_allow_login else 'interdit'}} pour cet utilisateur) (CAS {{'autorisé' if user.cas_allow_login else 'interdit'}} pour cet utilisateur)
{% if user.cas_allow_scodoc_login %}
(connexion sans CAS autorisée)
{% endif %}
<br> <br>
<b>Nom :</b> {{user.nom or ""}}<br> <b>Nom :</b> {{user.nom or ""}}<br>
<b>Prénom :</b> {{user.prenom or ""}}<br> <b>Prénom :</b> {{user.prenom or ""}}<br>

View File

@ -18,8 +18,16 @@
non chargé. non chargé.
{% endif %} {% endif %}
</div> </div>
<div style="margin-top:16px;">
<em>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</em>
<tt style="color: blue;">{{url_for("auth.login_scodoc", _external=True)}}</tt>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -144,8 +144,10 @@ def config_cas():
if request.method == "POST" and form.cancel.data: # cancel button if request.method == "POST" and form.cancel.data: # cancel button
return redirect(url_for("scodoc.index")) return redirect(url_for("scodoc.index"))
if form.validate_on_submit(): 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é")) 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"]): if ScoDocSiteConfig.set("cas_server", form.data["cas_server"]):
flash("Serveur CAS enregistré") flash("Serveur CAS enregistré")
if ScoDocSiteConfig.set("cas_attribute_id", form.data["cas_attribute_id"]): if ScoDocSiteConfig.set("cas_attribute_id", form.data["cas_attribute_id"]):
@ -165,6 +167,7 @@ def config_cas():
elif request.method == "GET": elif request.method == "GET":
form.cas_enable.data = ScoDocSiteConfig.get("cas_enable") 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_server.data = ScoDocSiteConfig.get("cas_server")
form.cas_attribute_id.data = ScoDocSiteConfig.get("cas_attribute_id") form.cas_attribute_id.data = ScoDocSiteConfig.get("cas_attribute_id")
form.cas_ssl_verify.data = ScoDocSiteConfig.get("cas_ssl_verify") form.cas_ssl_verify.data = ScoDocSiteConfig.get("cas_ssl_verify")

View File

@ -403,7 +403,16 @@ def create_user_form(user_name=None, edit=0, all_roles=True):
{ {
"title": "Autorise connexion via CAS", "title": "Autorise connexion via CAS",
"input_type": "boolcheckbox", "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(), "readonly": not current_user.is_administrator(),
}, },
), ),
@ -764,8 +773,9 @@ def import_users_form():
<li>envoi à chaque utilisateur de son <b>mot de passe initial par mail</b>.</li> <li>envoi à chaque utilisateur de son <b>mot de passe initial par mail</b>.</li>
</ol>""" </ol>"""
H.append( H.append(
"""<ol><li><a class="stdlink" href="import_users_generate_excel_sample"> f"""<ol><li><a class="stdlink" href="{
Obtenir la feuille excel à remplir</a></li><li>""" url_for("users.import_users_generate_excel_sample", scodoc_dept=g.scodoc_dept)
}">Obtenir la feuille excel à remplir</a></li><li>"""
) )
F = html_sco_header.sco_footer() F = html_sco_header.sco_footer()
tf = TrivialFormulator( tf = TrivialFormulator(

View File

@ -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 des photos d'étudiants à partir d'une liste excel et d'un zip avec les images."""
import app as mapp import app as mapp
from app.scodoc import sco_trombino, sco_photos from app.scodoc import sco_trombino, sco_photos
from flask_login import login_user
from app.auth.models import get_super_admin from app.auth.models import get_super_admin
sem = mapp.models.formsemestre.FormSemestre.query.get(formsemestre_id) sem = mapp.models.formsemestre.FormSemestre.query.get(formsemestre_id)

View File

@ -4,7 +4,7 @@
-écriture de test_users avec pytest. -écriture de test_users avec pytest.
Usage: pytest tests/unit/test_users.py Usage: pytest tests/unit/test_users.py
""" """
import pytest import pytest
@ -24,7 +24,7 @@ def test_password_hashing(test_client):
db.session.add(u) db.session.add(u)
db.session.commit() db.session.commit()
# nota: default attributes values, like active, # nota: default attributes values, like active,
# are not set before the first commit() (?) # are not set before the first commit()
assert u.active assert u.active
u.set_password("cat") u.set_password("cat")
assert not u.check_password("dog") assert not u.check_password("dog")
@ -62,7 +62,7 @@ def test_roles_permissions(test_client):
def test_users_roles(test_client): def test_users_roles(test_client):
dept = "XX" dept = DEPT
perm = Permission.ScoAbsChange perm = Permission.ScoAbsChange
perm2 = Permission.ScoView perm2 = Permission.ScoView
u = User(user_name="un_enseignant") u = User(user_name="un_enseignant")
@ -97,14 +97,14 @@ def test_users_roles(test_client):
def test_user_admin(test_client): def test_user_admin(test_client):
dept = "XX" dept = DEPT
perm = 0x1234 # a random perm perm = 0x1234 # a random perm
u = User(user_name="un_admin", email=current_app.config["SCODOC_ADMIN_MAIL"]) u = User(user_name="un_admin", email=current_app.config["SCODOC_ADMIN_MAIL"])
db.session.add(u) db.session.add(u)
assert len(u.roles) == 1 assert len(u.roles) == 1
assert u.has_permission(perm, dept) assert u.has_permission(perm, dept)
# Le grand admin a accès à tous les départements: # 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" assert u.roles[0].name == "SuperAdmin"