diff --git a/app/__init__.py b/app/__init__.py index 72cc1cb72..856dd8ea5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -17,15 +17,17 @@ 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_sqlalchemy import SQLAlchemy -from flask_migrate import Migrate +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_bootstrap import Bootstrap +from flask_migrate import Migrate from flask_moment import Moment -from flask_caching import Cache +from flask_sqlalchemy import SQLAlchemy +from flask.json import JSONEncoder +from flask.logging import default_handler + from jinja2 import select_autoescape import sqlalchemy @@ -132,7 +134,7 @@ class ScoDocJSONEncoder(JSONEncoder): def render_raw_html(template_filename: str, **args) -> str: """Load and render an HTML file _without_ using Flask - Necessary for 503 error mesage, when DB is down and Flask may be broken. + Necessary for 503 error message, when DB is down and Flask may be broken. """ template_path = os.path.join( current_app.config["SCODOC_DIR"], @@ -226,14 +228,14 @@ class ReverseProxied(object): def create_app(config_class=DevConfig): app = Flask(__name__, static_url_path="/ScoDoc/static", static_folder="static") + CAS(app, url_prefix="/cas") app.wsgi_app = ReverseProxied(app.wsgi_app) app.json_encoder = ScoDocJSONEncoder - app.logger.setLevel(logging.INFO) - - # Evite de logguer toutes les requetes dans notre log - logging.getLogger("werkzeug").disabled = True app.config.from_object(config_class) + # Evite de logguer toutes les requetes dans notre log + logging.getLogger("werkzeug").disabled = True + app.logger.setLevel(app.config["LOG_LEVEL"]) # Vérifie/crée lien sym pour les URL statiques link_filename = f"{app.root_path}/static/links/{sco_version.SCOVERSION}" @@ -378,6 +380,11 @@ def create_app(config_class=DevConfig): sco_bulletins_generator.register_bulletin_class(BulletinGeneratorExample) + from app.auth.cas import set_cas_configuration + + with app.app_context(): + set_cas_configuration(app) + return app diff --git a/app/auth/__init__.py b/app/auth/__init__.py index 4fb9575d9..7dcff2b3e 100644 --- a/app/auth/__init__.py +++ b/app/auth/__init__.py @@ -6,3 +6,4 @@ from flask import Blueprint bp = Blueprint("auth", __name__) from app.auth import routes +from app.auth import cas diff --git a/app/auth/cas.py b/app/auth/cas.py new file mode 100644 index 000000000..4b478eaf8 --- /dev/null +++ b/app/auth/cas.py @@ -0,0 +1,88 @@ +# -*- coding: UTF-8 -* +""" +auth.cas.py +""" +import datetime + +import flask +from flask import current_app, flash, url_for +from flask_login import login_user + +from app.auth import bp +from app.auth.models import User +from app.models.config import ScoDocSiteConfig +from app.scodoc.sco_exceptions import ScoValueError + +# after_cas_login/after_cas_logout : routes appelées par redirect depuis le serveur CAS. + + +@bp.route("/after_cas_login") +def after_cas_login(): + "Called by CAS after CAS authentication" + # Ici on a les infos dans flask.session["CAS_ATTRIBUTES"] + if ScoDocSiteConfig.is_cas_enabled() and ("CAS_ATTRIBUTES" in flask.session): + # Lookup user: + cas_id = flask.session["CAS_ATTRIBUTES"].get( + "cas:" + ScoDocSiteConfig.get("cas_attribute_id") + ) + if cas_id is not None: + user = User.query.filter_by(cas_id=cas_id).first() + if user and user.active: + if user.cas_allow_login: + current_app.logger.info(f"CAS: login {user.user_name}") + if login_user(user): + flask.session[ + "scodoc_cas_login_date" + ] = datetime.datetime.now().isoformat() + return flask.redirect(url_for("scodoc.index")) + else: + current_app.logger.info( + f"CAS login denied for {user.user_name} (not allowed to use CAS)" + ) + else: + current_app.logger.info( + f"""CAS login denied for {user.user_name if user else ""} cas_id={cas_id} (unknown or inactive)""" + ) + else: + current_app.logger.info( + f"""CAS attribute '{ScoDocSiteConfig.get("cas_attribute_id")}' not found ! + (check your ScoDoc config)""" + ) + + # Echec: + flash("échec de l'authentification") + return flask.redirect(url_for("auth.login")) + + +@bp.route("/after_cas_logout") +def after_cas_logout(): + "Called by CAS after CAS logout" + flash("Vous êtes déconnecté") + current_app.logger.info("after_cas_logout") + return flask.redirect(url_for("scodoc.index")) + + +def cas_error_callback(message): + "Called by CAS when an error occurs, with a message" + raise ScoValueError(f"Erreur authentification CAS: {message}") + + +def set_cas_configuration(app: flask.app.Flask = None): + """Force la configuration du module flask_cas à partir des paramètres de + la config de ScoDoc. + Appelé au démarrage et à chaque modif des paramètres. + """ + app = app or current_app + if ScoDocSiteConfig.is_cas_enabled(): + app.config["CAS_SERVER"] = ScoDocSiteConfig.get("cas_server") + app.config["CAS_AFTER_LOGIN"] = "auth.after_cas_login" + app.config["CAS_AFTER_LOGOUT"] = "auth.after_cas_logout" + app.config["CAS_ERROR_CALLBACK"] = cas_error_callback + app.config["CAS_SSL_VERIFY"] = ScoDocSiteConfig.get("cas_ssl_verify") + app.config["CAS_SSL_CERTIFICATE"] = ScoDocSiteConfig.get("cas_ssl_certificate") + else: + app.config.pop("CAS_SERVER", None) + app.config.pop("CAS_AFTER_LOGIN", None) + app.config.pop("CAS_AFTER_LOGOUT", None) + app.config.pop("CAS_SSL_VERIFY", None) + app.config.pop("CAS_SSL_CERTIFICATE", None) diff --git a/app/auth/logic.py b/app/auth/logic.py index ba3f73a42..a1737a3b4 100644 --- a/app/auth/logic.py +++ b/app/auth/logic.py @@ -8,9 +8,10 @@ import flask from flask import g, redirect, request, url_for from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth import flask_login + from app import login -from app.scodoc.sco_utils import json_error from app.auth.models import User +from app.scodoc.sco_utils import json_error basic_auth = HTTPBasicAuth() token_auth = HTTPTokenAuth() diff --git a/app/auth/models.py b/app/auth/models.py index 274be8e7a..c7ef016dd 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -53,12 +53,28 @@ class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) user_name = db.Column(db.String(64), index=True, unique=True) + "le login" email = db.Column(db.String(120)) nom = db.Column(db.String(64)) prenom = db.Column(db.String(64)) dept = db.Column(db.String(SHORT_STR_LEN), index=True) + "acronyme du département de l'utilisateur" active = db.Column(db.Boolean, default=True, index=True) + "si faux, compte utilisateur désactivé" + cas_id = db.Column(db.Text(), index=True, unique=True, nullable=True) + "uid sur le CAS (mail ou autre attribut, selon config.cas_attribute_id)" + cas_allow_login = db.Column( + db.Boolean, default=False, server_default="false", nullable=False + ) + "Peut-on se logguer via le CAS ?" + cas_allow_scodoc_login = db.Column( + db.Boolean, default=True, 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) + """ password_hash = db.Column(db.String(128)) password_scodoc7 = db.Column(db.String(42)) @@ -184,6 +200,9 @@ class User(UserMixin, db.Model): "dept": self.dept, "id": self.id, "active": self.active, + "cas_id": self.cas_id, + "cas_allow_login": self.cas_allow_login, + "cas_allow_scodoc_login": self.cas_allow_scodoc_login, "status_txt": "actif" if self.active else "fermé", "last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else None, "nom": (self.nom or ""), # sco8 @@ -206,7 +225,17 @@ class User(UserMixin, db.Model): """Set users' attributes from given dict values. Roles must be encoded as "roles_string", like "Ens_RT, Secr_CJ" """ - for field in ["nom", "prenom", "dept", "active", "email", "date_expiration"]: + for field in [ + "nom", + "prenom", + "dept", + "active", + "email", + "date_expiration", + "cas_id", + "cas_allow_login", + "cas_allow_scodoc_login", + ]: if field in data: setattr(self, field, data[field] or None) if new_user: @@ -310,7 +339,7 @@ class User(UserMixin, db.Model): """string repr. of user's roles (with depts) e.g. "Ens_RT, Ens_Info, Secr_CJ" """ - return ",".join( + return ", ".join( f"{r.role.name or ''}_{r.dept or ''}" for r in self.user_roles if r is not None diff --git a/app/auth/routes.py b/app/auth/routes.py index 2c1594bc8..b6a0df9a7 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -3,6 +3,7 @@ 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 @@ -20,6 +21,7 @@ from app.auth.models import Role from app.auth.models import User from app.auth.email import send_password_reset_email from app.decorators import admin_required +from app.models.config import ScoDocSiteConfig _ = lambda x: x # sans babel _l = _ @@ -30,6 +32,7 @@ def login(): "ScoDoc Login form" if current_user.is_authenticated: return redirect(url_for("scodoc.index")) + form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(user_name=form.user_name.data).first() @@ -42,14 +45,22 @@ def login(): return form.redirect("scodoc.index") message = request.args.get("message", "") return render_template( - "auth/login.j2", title=_("Sign In"), form=form, message=message + "auth/login.j2", + title=_("Sign In"), + form=form, + message=message, + is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(), ) @bp.route("/logout") -def logout(): - "Logout current user and redirect to home page" +def logout() -> flask.Response: + "Logout a scodoc user. If CAS session, logout from CAS. Redirect." + current_app.logger.info(f"logout user {current_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")) diff --git a/app/forms/main/config_cas.py b/app/forms/main/config_cas.py new file mode 100644 index 000000000..217b274f3 --- /dev/null +++ b/app/forms/main/config_cas.py @@ -0,0 +1,60 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +""" +Formulaires configuration Exports Apogée (codes) +""" + +from flask_wtf import FlaskForm +from wtforms import BooleanField, SubmitField +from wtforms.fields.simple import FileField, StringField, TextAreaField + + +class ConfigCASForm(FlaskForm): + "Formulaire paramétrage CAS" + cas_enable = BooleanField("activer le CAS") + + cas_server = StringField( + label="URL du serveur CAS", + description="""url complète. Commence en général par https://.""", + ) + + cas_attribute_id = StringField( + label="Attribut CAS utilisé comme id", + description="""Le champs CAS qui sera considéré comme l'id unique des + comptes utilisateurs.""", + ) + + cas_ssl_verify = BooleanField("Vérification du certificat SSL") + cas_ssl_certificate_file = FileField( + label="Certificat (PEM)", + description="""Le contenu du certificat PEM + (commence typiquement par -----BEGIN CERTIFICATE-----)""", + ) + + submit = SubmitField("Valider") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/models/config.py b/app/models/config.py index f4f09cc32..131106308 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -4,7 +4,7 @@ """ from flask import flash -from app import db, log +from app import current_app, db, log from app.comp import bonus_spo from app.scodoc import sco_utils as scu @@ -87,6 +87,10 @@ class ScoDocSiteConfig(db.Model): "enable_entreprises": bool, "month_debut_annee_scolaire": int, "month_debut_periode2": int, + # CAS + "cas_enable": bool, + "cas_server": str, + "cas_attribute_id": str, } def __init__(self, name, value): @@ -170,7 +174,7 @@ class ScoDocSiteConfig(db.Model): (starting with empty string to represent "no bonus function"). """ d = bonus_spo.get_bonus_class_dict() - class_list = [(name, d[name].displayed_name) for name in d.keys()] + class_list = [(name, d[name].displayed_name) for name in d] class_list.sort(key=lambda x: x[1].replace(" du ", " de ")) return [("", "")] + class_list @@ -204,13 +208,31 @@ class ScoDocSiteConfig(db.Model): db.session.add(cfg) db.session.commit() + @classmethod + def is_cas_enabled(cls) -> bool: + """True si on utilise le CAS""" + 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""" cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first() - if (cfg is None) or not cfg.value: - return False - return True + return cfg is not None and cfg.value @classmethod def enable_entreprises(cls, enabled=True) -> bool: @@ -228,6 +250,29 @@ class ScoDocSiteConfig(db.Model): return True return False + @classmethod + def get(cls, name: str) -> str: + "Get configuration param; empty string if unset" + cfg = ScoDocSiteConfig.query.filter_by(name=name).first() + return (cfg.value or "") if cfg else "" + + @classmethod + def set(cls, name: str, value: str) -> bool: + "Set parameter, returns True if change. Commit session." + if cls.get(name) != (value or ""): + cfg = ScoDocSiteConfig.query.filter_by(name=name).first() + if cfg is None: + cfg = ScoDocSiteConfig(name=name, value=str(value)) + else: + cfg.value = str(value or "") + current_app.logger.info( + f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}...'""" + ) + db.session.add(cfg) + db.session.commit() + return True + return False + @classmethod def _get_int_field(cls, name: str, default=None) -> int: """Valeur d'un champs integer""" diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py index 6d9bc49f4..a4240d4be 100644 --- a/app/scodoc/sco_etud.py +++ b/app/scodoc/sco_etud.py @@ -225,6 +225,9 @@ _identiteEditor = ndb.EditableTable( "nom", "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_users.py b/app/scodoc/sco_users.py index d3db5b8e7..f9bd56922 100644 --- a/app/scodoc/sco_users.py +++ b/app/scodoc/sco_users.py @@ -28,11 +28,10 @@ """Fonctions sur les utilisateurs """ -# Anciennement ZScoUsers.py, fonctions de gestion des données réécrite avec flask/SQLAlchemy +# Anciennement ZScoUsers.py, fonctions de gestion des données réécrites avec flask/SQLAlchemy import re from flask import url_for, g, request -from flask.templating import render_template from flask_login import current_user @@ -40,25 +39,13 @@ from app import db, Departement from app.auth.models import Permission from app.auth.models import User - +from app.models import ScoDocSiteConfig from app.scodoc import html_sco_header -from app.scodoc import sco_etud -from app.scodoc import sco_excel from app.scodoc import sco_preferences from app.scodoc.gen_tables import GenTable -from app import log, cache -from app.scodoc.scolog import logdb -import app.scodoc.sco_utils as scu +from app import cache -from app.scodoc.sco_exceptions import ( - AccessDenied, - ScoValueError, -) - - -# --------------- - -# --------------- +from app.scodoc.sco_exceptions import ScoValueError def index_html(all_depts=False, with_inactives=False, format="html"): @@ -70,19 +57,21 @@ def index_html(all_depts=False, with_inactives=False, format="html"): if current_user.has_permission(Permission.ScoUsersAdmin, g.scodoc_dept): H.append( - '
Ajouter un utilisateur'.format( - url_for("users.create_user_form", scodoc_dept=g.scodoc_dept) - ) + f"""
Ajouter un utilisateur""" ) if current_user.is_administrator(): H.append( - ' Importer des utilisateurs
'.format( - url_for("users.import_users_form", scodoc_dept=g.scodoc_dept) - ) + """ Importer des utilisateurs""" ) + else: H.append( - " Pour importer des utilisateurs en masse (via xlsx file) contactez votre administrateur scodoc." + """ Pour importer des utilisateurs en masse (via fichier xlsx) + contactez votre administrateur scodoc.""" ) if all_depts: checked = "checked" @@ -93,11 +82,12 @@ def index_html(all_depts=False, with_inactives=False, format="html"): else: olds_checked = "" H.append( - """""" - % (request.base_url, checked, olds_checked) ) L = list_users( @@ -127,7 +117,7 @@ def list_users( if dept and not all_depts: users = get_user_list(dept=dept, with_inactives=with_inactives) - comm = "dept. %s" % dept + comm = f"dept. {dept}" else: users = get_user_list(with_inactives=with_inactives) comm = "tous" @@ -135,13 +125,13 @@ def list_users( comm += ", avec anciens" comm = "(" + comm + ")" # -- Add some information and links: - r = [] + rows = [] for u in users: # Can current user modify this user ? can_modify = can_handle_passwd(u, allow_admindepts=True) d = u.to_dict() - r.append(d) + rows.append(d) # Add links if with_links and can_modify: target = url_for( @@ -168,11 +158,15 @@ def list_users( "status_txt", ] # Seul l'admin peut voir les dates de dernière connexion + # et les infos CAS if current_user.is_administrator(): columns_ids.append("last_seen") + if ScoDocSiteConfig.is_cas_enabled(): + columns_ids += ["cas_id", "cas_allow_login", "cas_allow_scodoc_login"] + title = "Utilisateurs définis dans ScoDoc" tab = GenTable( - rows=r, + rows=rows, columns_ids=columns_ids, titles={ "user_name": "Login", @@ -186,12 +180,14 @@ def list_users( "last_seen": "Dernière cnx.", "passwd_temp": "Temp.", "status_txt": "Etat", + "cas_id": "Id CAS", + "cas_allow_login": "CAS autorisé", + "cas_allow_scodoc_login": "Cnx sans CAS", }, caption=title, page_title="title", - html_title="""Cliquer sur un nom pour changer son mot de passe
""" - % (len(r), comm), + html_title=f"""Cliquer sur un nom pour changer son mot de passe
""", html_class="table_leftalign list_users", html_with_td_classes=True, html_sortable=True, @@ -217,15 +213,6 @@ def get_user_list(dept=None, with_inactives=False): return q.order_by(User.nom, User.user_name).all() -def _user_list(user_name): - "return user as a dict" - u = User.query.filter_by(user_name=user_name).first() - if u: - return u.to_dict() - else: - return None - - @cache.memoize(timeout=50) # seconds def user_info(user_name_or_id=None, user: User = None): """Dict avec infos sur l'utilisateur (qui peut ne pas etre dans notre base). @@ -273,6 +260,9 @@ def user_info(user_name_or_id=None, user: User = None): return info +MSG_OPT = """
Login : {{user.user_name}}
+ CAS id: {{user.cas_id or "(aucun)"}}
+ (CAS {{'autorisé' if user.cas_allow_login else 'interdit'}} pour cet utilisateur)
+
Nom : {{user.nom or ""}}
Prénom : {{user.prenom or ""}}
Mail : {{user.email}}
@@ -48,9 +51,15 @@
{% if current_user.id == user.id %}
-
Se déconnecter: - logout -
+CAS session started at {{ session_info }}
+ {% endif %} +Se déconnecter: + logout + +
+Le CAS permet d'utiliser un service SSO pour connecter les utilisateurs.
+remettre les permissions des rôles standards à leurs valeurs par défaut (efface les modifications apportées)
diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 83055b504..2aa279687 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -54,6 +54,7 @@ from werkzeug.exceptions import BadRequest, NotFound from app import db from app.auth.models import User +from app.auth.cas import set_cas_configuration from app.decorators import ( admin_required, scodoc7func, @@ -62,6 +63,7 @@ from app.decorators import ( from app.forms.main import config_logos, config_main from app.forms.main.create_dept import CreateDeptForm from app.forms.main.config_apo import CodesDecisionsForm +from app.forms.main.config_cas import ConfigCASForm from app import models from app.models import Departement, Identite from app.models import departements @@ -73,7 +75,7 @@ from app.scodoc import sco_find_etud from app.scodoc import sco_logos from app.scodoc import sco_utils as scu -from app.scodoc.sco_exceptions import AccessDenied +from app.scodoc.sco_exceptions import AccessDenied, ScoValueError from app.scodoc.sco_permissions import Permission from app.views import scodoc_bp as bp import sco_version @@ -134,6 +136,46 @@ def toggle_dept_vis(dept_id): return redirect(url_for("scodoc.index")) +@bp.route("/ScoDoc/config_cas", methods=["GET", "POST"]) +@admin_required +def config_cas(): + """Form config CAS""" + form = ConfigCASForm() + 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"]): + flash("CAS " + ("activé" if form.data["cas_enable"] else "désactivé")) + if ScoDocSiteConfig.set("cas_server", form.data["cas_server"]): + flash("Serveur CAS enregistré") + if ScoDocSiteConfig.set("cas_attribute_id", form.data["cas_attribute_id"]): + flash("Serveur CAS enregistré") + if ScoDocSiteConfig.set("cas_ssl_verify", form.data["cas_ssl_verify"]): + flash("Vérification SSL modifiée") + if form.cas_ssl_certificate_file.data: + data = request.files[form.cas_ssl_certificate_file.name].read() + try: + data_str = data.decode("ascii") + except UnicodeDecodeError as exc: + raise ScoValueError("Fichier certificat invalide (non ASCII)") from exc + if ScoDocSiteConfig.set("cas_ssl_certificate", data_str): + flash("Certificat SSL enregistré") + set_cas_configuration() + return redirect(url_for("scodoc.configuration")) + + elif request.method == "GET": + form.cas_enable.data = ScoDocSiteConfig.get("cas_enable") + 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") + return render_template( + "config_cas.j2", + form=form, + title="Configuration du Service d'Authentification Central (CAS)", + cas_ssl_certificate_loaded=ScoDocSiteConfig.get("cas_ssl_certificate"), + ) + + @bp.route("/ScoDoc/config_codes_decisions", methods=["GET", "POST"]) @admin_required def config_codes_decisions(): diff --git a/app/views/users.py b/app/views/users.py index f6d0bcc71..7efc08e1a 100644 --- a/app/views/users.py +++ b/app/views/users.py @@ -56,6 +56,7 @@ from app.auth.models import UserRole from app.auth.models import is_valid_password from app.email import send_email from app.models import Departement +from app.models.config import ScoDocSiteConfig from app.decorators import ( scodoc, @@ -226,7 +227,7 @@ def create_user_form(user_name=None, edit=0, all_roles=True): if edit: if not user_name: raise ValueError("missing argument: user_name") - the_user = User.query.filter_by(user_name=user_name).first() + the_user: User = User.query.filter_by(user_name=user_name).first() if not the_user: raise ScoValueError("utilisateur inexistant") initvalues = the_user.to_dict() @@ -367,6 +368,7 @@ def create_user_form(user_name=None, edit=0, all_roles=True): {"input_type": "hidden", "default": initvalues["user_name"]}, ) ] + cas_enabled = ScoDocSiteConfig.is_cas_enabled() descr += [ ( "email", @@ -376,11 +378,34 @@ def create_user_form(user_name=None, edit=0, all_roles=True): "explanation": "requis, doit fonctionner" if not edit_only_roles else "", - "size": 20, + "size": 36, "allow_null": False, "readonly": edit_only_roles, }, - ) + ), + ( + "cas_id", + { + "title": "Identifiant CAS", + "input_type": "text", + "explanation": "id du compte utilisateur sur le CAS de l'établissement " + + "(service CAS activé)" + if cas_enabled + else "(service CAS non activé)", + "size": 36, + "allow_null": True, + "readonly": not cas_enabled, + }, + ), + ( + "cas_allow_login", + { + "title": "Autorise connexion via CAS", + "input_type": "boolcheckbox", + "explanation": "en test: seul le super-administrateur peut changer ce réglage", + "readonly": not current_user.is_administrator(), + }, + ), ] if not edit: # options création utilisateur descr += [ @@ -438,7 +463,8 @@ def create_user_form(user_name=None, edit=0, all_roles=True): "d", { "input_type": "separator", - "title": f"""L'utilisateur appartient au département {the_user.dept or "(tous)"}""", + "title": f"""L'utilisateur appartient au département { + the_user.dept or "(tous)"}""", }, ) ) @@ -541,7 +567,6 @@ def create_user_form(user_name=None, edit=0, all_roles=True): if err_msg: H.append(tf_error_message(f"""Erreur: {err_msg}""")) return "\n".join(H) + "\n" + tf[1] + F - if not edit_only_roles: ok_modif, msg = sco_users.check_modif_user( edit, @@ -552,6 +577,7 @@ def create_user_form(user_name=None, edit=0, all_roles=True): email=vals["email"], dept=vals.get("dept", auth_dept), roles=vals["roles"], + cas_id=vals["cas_id"], ) if not ok_modif: H.append(tf_error_message(msg)) @@ -815,12 +841,17 @@ def user_info_page(user_name=None): if not user: raise ScoValueError("invalid user_name") + session_info = None + if user.id == current_user.id: + session_info = flask.session.get("scodoc_cas_login_date") + return render_template( "auth/user_info_page.j2", user=user, title=f"Utilisateur {user.user_name}", Permission=Permission, dept=dept, + session_info=session_info, ) diff --git a/config.py b/config.py index 740860a61..ed456b0b2 100755 --- a/config.py +++ b/config.py @@ -1,7 +1,7 @@ # -*- coding: UTF-8 -* import os -import uuid +import logging from dotenv import load_dotenv BASEDIR = os.path.abspath(os.path.dirname(__file__)) @@ -16,6 +16,7 @@ class Config: SECRET_KEY = os.environ.get("SECRET_KEY") or "90e01e75831e4276a4c70d29564b425f" SQLALCHEMY_TRACK_MODIFICATIONS = False LOG_TO_STDOUT = os.environ.get("LOG_TO_STDOUT") + LOG_LEVEL = getattr(logging, os.environ.get("LOG_LEVEL", "INFO"), "INFO") MAIL_SERVER = os.environ.get("MAIL_SERVER", "localhost") MAIL_PORT = int(os.environ.get("MAIL_PORT", 25)) MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") is not None @@ -40,9 +41,6 @@ class Config: # Pour conserver l'ordre des objets dans les JSON: # e.g. l'ordre des UE dans les bulletins JSON_SORT_KEYS = False - # STATIC_URL_PATH = "/ScoDoc/static" - # static_folder = "stat" - # SERVER_NAME = os.environ.get("SERVER_NAME") class ProdConfig(Config): diff --git a/flask_cas/README.md b/flask_cas/README.md new file mode 100644 index 000000000..d71e8b507 --- /dev/null +++ b/flask_cas/README.md @@ -0,0 +1,7 @@ +# Flask-CAS + +Forked from