diff --git a/app/auth/cas.py b/app/auth/cas.py index 268611469..c96e9689e 100644 --- a/app/auth/cas.py +++ b/app/auth/cas.py @@ -38,7 +38,7 @@ def after_cas_login(): flask.session["scodoc_cas_login_date"] = ( datetime.datetime.now().isoformat() ) - user.cas_last_login = datetime.datetime.utcnow() + user.cas_last_login = datetime.datetime.now() if flask.session.get("CAS_EDT_ID"): # essaie de récupérer l'edt_id s'il est présent # cet ID peut être renvoyé par le CAS et extrait par ScoDoc diff --git a/app/auth/logic.py b/app/auth/logic.py index 496aea1d6..51f1d68df 100644 --- a/app/auth/logic.py +++ b/app/auth/logic.py @@ -9,8 +9,8 @@ from flask import current_app, g, redirect, request, url_for from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth import flask_login -from app import db, login -from app.auth.models import User +from app import db, log, login +from app.auth.models import User, Role from app.models.config import ScoDocSiteConfig from app.scodoc.sco_utils import json_error @@ -19,7 +19,7 @@ token_auth = HTTPTokenAuth() @basic_auth.verify_password -def verify_password(username, password): +def verify_password(username, password) -> User | None: """Verify password for this user Appelé lors d'une demande de jeton (normalement via la route /tokens) """ @@ -28,6 +28,7 @@ def verify_password(username, password): g.current_user = user # note: est aussi basic_auth.current_user() return user + return None @basic_auth.error_handler @@ -61,7 +62,8 @@ def token_auth_error(status): @token_auth.get_user_roles -def get_user_roles(user): +def get_user_roles(user) -> list[Role]: + "list roles" return user.roles @@ -82,7 +84,7 @@ def load_user_from_request(req: flask.Request) -> User: @login.unauthorized_handler def unauthorized(): "flask-login: si pas autorisé, redirige vers page login, sauf si API" - if request.blueprint == "api" or request.blueprint == "apiweb": + if request.blueprint in ("api", "apiweb"): return json_error(http.HTTPStatus.UNAUTHORIZED, "Non autorise (logic)") return redirect(url_for("auth.login")) diff --git a/app/auth/models.py b/app/auth/models.py index 0354e9288..b73190459 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -105,6 +105,9 @@ class User(UserMixin, ScoDocModel): date_modif_passwd = db.Column(db.DateTime, default=datetime.now) date_created = db.Column(db.DateTime, default=datetime.now) date_expiration = db.Column(db.DateTime, default=None) + passwd_must_be_changed = db.Column( + db.Boolean, nullable=False, server_default="false", default=False + ) passwd_temp = db.Column(db.Boolean, default=False) """champ obsolete. Si connexion alors que passwd_temp est vrai, efface mot de passe et redirige vers accueil.""" @@ -185,6 +188,8 @@ class User(UserMixin, ScoDocModel): # La création d'un mot de passe efface l'éventuel mot de passe historique self.password_scodoc7 = None self.passwd_temp = False + # Retire le flag + self.passwd_must_be_changed = False def check_password(self, password: str) -> bool: """Check given password vs current one. @@ -282,6 +287,7 @@ class User(UserMixin, ScoDocModel): if self.date_modif_passwd else None ), + "passwd_must_be_changed": self.passwd_must_be_changed, "date_created": ( self.date_created.isoformat() + "Z" if self.date_created else None ), @@ -385,7 +391,7 @@ class User(UserMixin, ScoDocModel): def get_token(self, expires_in=3600): "Un jeton pour cet user. Stocké en base, non commité." - now = datetime.utcnow() + now = datetime.now() if self.token and self.token_expiration > now + timedelta(seconds=60): return self.token self.token = base64.b64encode(os.urandom(24)).decode("utf-8") @@ -395,7 +401,7 @@ class User(UserMixin, ScoDocModel): def revoke_token(self): "Révoque le jeton de cet utilisateur" - self.token_expiration = datetime.utcnow() - timedelta(seconds=1) + self.token_expiration = datetime.now() - timedelta(seconds=1) @staticmethod def check_token(token): @@ -403,7 +409,7 @@ class User(UserMixin, ScoDocModel): and returns the user object. """ user = User.query.filter_by(token=token).first() - if user is None or user.token_expiration < datetime.utcnow(): + if user is None or user.token_expiration < datetime.now(): return None return user diff --git a/app/auth/routes.py b/app/auth/routes.py index 778cf8e5e..e8283c1a4 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -4,7 +4,7 @@ auth.routes.py """ import flask -from flask import current_app, flash, render_template +from flask import current_app, flash, g, render_template from flask import redirect, url_for, request from flask_login import login_user, current_user from sqlalchemy import func @@ -23,6 +23,7 @@ from app.auth.email import send_password_reset_email from app.decorators import admin_required from app.forms.generic import SimpleConfirmationForm from app.models.config import ScoDocSiteConfig +from app.models.departements import Departement from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS from app.scodoc import sco_utils as scu @@ -49,6 +50,24 @@ def _login_form(): login_user(user, remember=form.remember_me.data) current_app.logger.info("login: success (%s)", form.user_name.data) + + if user.passwd_must_be_changed: + # Mot de passe à changer à la première connexion + dept = user.dept or getattr(g, "scodoc_dept", None) + if not dept: + departement = db.session.query(Departement).first() + dept = departement.acronym + if dept: + # Redirect to the password change page + flash("Votre mot de passe doit être changé") + return redirect( + url_for( + "users.form_change_password", + scodoc_dept=dept, + user_name=user.user_name, + ) + ) + return form.redirect("scodoc.index") return render_template( diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 4a6908ec3..cf706008b 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -407,7 +407,9 @@ class BulletinBUT: d = { "version": "0", "type": "BUT", - "date": datetime.datetime.utcnow().isoformat() + "Z", + "date": datetime.datetime.now(datetime.timezone.utc) + .astimezone() + .isoformat(), "publie": not formsemestre.bul_hide_xml, "etat_inscription": etud.inscription_etat(formsemestre.id), "etudiant": etud.to_dict_bul(), diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py index d89e19dab..69c3e0fc5 100644 --- a/app/models/but_refcomp.py +++ b/app/models/but_refcomp.py @@ -76,7 +76,7 @@ class ApcReferentielCompetences(models.ScoDocModel, XMLModel): "version": "version_orebut", } # ScoDoc specific fields: - scodoc_date_loaded = db.Column(db.DateTime, default=datetime.utcnow) + scodoc_date_loaded = db.Column(db.DateTime, default=datetime.now) scodoc_orig_filename = db.Column(db.Text()) # Relations: competences = db.relationship( diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 863b5d8ce..dc08a847b 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -646,9 +646,11 @@ class FormSemestre(models.ScoDocModel): ) return [db.session.get(ModuleImpl, modimpl_id) for modimpl_id in cursor] - def can_be_edited_by(self, user): + def can_be_edited_by(self, user: User): """Vrai si user peut modifier ce semestre (est chef ou l'un des responsables)""" - if not user.has_permission(Permission.EditFormSemestre): # pas chef + if user.passwd_must_be_changed or not user.has_permission( + Permission.EditFormSemestre + ): # pas chef if not self.resp_can_edit or user.id not in [ resp.id for resp in self.responsables ]: @@ -897,6 +899,8 @@ class FormSemestre(models.ScoDocModel): if not self.etat: return False # semestre verrouillé user = user or current_user + if user.passwd_must_be_changed: + return False if user.has_permission(Permission.EtudChangeGroups): return True # typiquement admin, chef dept return self.est_responsable(user) @@ -906,11 +910,15 @@ class FormSemestre(models.ScoDocModel): dans ce semestre: vérifie permission et verrouillage. """ user = user or current_user + if user.passwd_must_be_changed: + return False return self.etat and self.est_chef_or_diretud(user) def can_edit_pv(self, user: User = None): "Vrai si utilisateur (par def. current) peut editer un PV de jury de ce semestre" user = user or current_user + if user.passwd_must_be_changed: + return False # Autorise les secrétariats, repérés via la permission EtudChangeAdr return self.est_chef_or_diretud(user) or user.has_permission( Permission.EtudChangeAdr diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index c3a3c2698..a3ce27a72 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -199,6 +199,8 @@ class ModuleImpl(ScoDocModel): """True if this user can create, delete or edit and evaluation in this modimpl (nb: n'implique pas le droit de saisir ou modifier des notes) """ + if user.passwd_must_be_changed: + return False # acces pour resp. moduleimpl et resp. form semestre (dir etud) if ( user.has_permission(Permission.EditAllEvals) @@ -222,6 +224,8 @@ class ModuleImpl(ScoDocModel): # was sco_permissions_check.can_edit_notes from app.scodoc import sco_cursus_dut + if user.passwd_must_be_changed: + return False if not self.formsemestre.etat: return False # semestre verrouillé is_dir_etud = user.id in (u.id for u in self.formsemestre.responsables) @@ -247,6 +251,8 @@ class ModuleImpl(ScoDocModel): if raise_exc: raise ScoLockedSemError("Modification impossible: semestre verrouille") return False + if user.passwd_must_be_changed: + return False # -- check access # admin ou resp. semestre avec flag resp_can_change_resp if user.has_permission(Permission.EditFormSemestre): @@ -264,6 +270,8 @@ class ModuleImpl(ScoDocModel): if user is None, current user. """ user = current_user if user is None else user + if user.passwd_must_be_changed: + return False if not self.formsemestre.etat: if raise_exc: raise ScoLockedSemError("Modification impossible: semestre verrouille") @@ -285,6 +293,8 @@ class ModuleImpl(ScoDocModel): Autorise ScoEtudInscrit ou responsables semestre. """ user = current_user if user is None else user + if user.passwd_must_be_changed: + return False if not self.formsemestre.etat: if raise_exc: raise ScoLockedSemError("Modification impossible: semestre verrouille") diff --git a/app/scodoc/sco_archives_etud.py b/app/scodoc/sco_archives_etud.py index 0067be0af..be0b70523 100644 --- a/app/scodoc/sco_archives_etud.py +++ b/app/scodoc/sco_archives_etud.py @@ -54,9 +54,11 @@ class EtudsArchiver(sco_archives.BaseArchiver): ETUDS_ARCHIVER = EtudsArchiver() -def can_edit_etud_archive(authuser): +def can_edit_etud_archive(user): """True si l'utilisateur peut modifier les archives etudiantes""" - return authuser.has_permission(Permission.EtudAddAnnotations) + if user.passwd_must_be_changed: + return False + return user.has_permission(Permission.EtudAddAnnotations) def etud_list_archives_html(etud: Identite): diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py index e59249926..5279ab98b 100644 --- a/app/scodoc/sco_bulletins.py +++ b/app/scodoc/sco_bulletins.py @@ -1001,6 +1001,8 @@ def formsemestre_bulletinetud( def can_send_bulletin_by_mail(formsemestre_id): """True if current user is allowed to send a bulletin (pdf) by mail""" sem = sco_formsemestre.get_formsemestre(formsemestre_id) + if current_user.passwd_must_be_changed: + return False return ( sco_preferences.get_preference("bul_mail_allowed_for_all", formsemestre_id) or current_user.has_permission(Permission.EditFormSemestre) diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index 74f1d7481..ee7a133a3 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -134,15 +134,6 @@ def formsemestre_editwithmodules(formsemestre_id: int): ) -def can_edit_sem(formsemestre_id: int = None, sem=None): - """Return sem if user can edit it, False otherwise""" - sem = sem or sco_formsemestre.get_formsemestre(formsemestre_id) - if not current_user.has_permission(Permission.EditFormSemestre): # pas chef - if not sem["resp_can_edit"] or current_user.id not in sem["responsables"]: - return False - return sem - - RESP_FIELDS = [ "responsable_id", "responsable_id2", diff --git a/app/scodoc/sco_permissions_check.py b/app/scodoc/sco_permissions_check.py index 6f98aafd2..4145b7e06 100644 --- a/app/scodoc/sco_permissions_check.py +++ b/app/scodoc/sco_permissions_check.py @@ -17,6 +17,8 @@ def can_suppress_annotation(annotation_id): Seuls l'auteur de l'annotation et le chef de dept peuvent supprimer une annotation. """ + if current_user.passwd_must_be_changed: + return False annotation = ( EtudAnnotation.query.filter_by(id=annotation_id) .join(Identite) @@ -30,8 +32,10 @@ def can_suppress_annotation(annotation_id): ) -def can_edit_suivi(): +def can_edit_suivi() -> bool: """Vrai si l'utilisateur peut modifier les informations de suivi sur la page etud" """ + if current_user.passwd_must_be_changed: + return False return current_user.has_permission(Permission.EtudChangeAdr) diff --git a/app/scodoc/sco_users.py b/app/scodoc/sco_users.py index d31c08380..db27e0c76 100644 --- a/app/scodoc/sco_users.py +++ b/app/scodoc/sco_users.py @@ -184,9 +184,11 @@ def list_users( if not current_user.is_administrator(): # si non super-admin, ne donne pas la date exacte de derniere connexion d["last_seen"] = _approximate_date(u.last_seen) + d["passwd_must_be_changed"] = "OUI" if d["passwd_must_be_changed"] else "" else: d["date_modif_passwd"] = "(non visible)" d["non_migre"] = "" + d["passwd_must_be_changed"] = "" if detail_roles: d["roles_set"] = { f"{r.role.name or ''}_{r.dept or ''}" for r in u.user_roles @@ -209,6 +211,7 @@ def list_users( "roles_string", "date_expiration", "date_modif_passwd", + "passwd_must_be_changed", "non_migre", "status_txt", ] @@ -240,6 +243,7 @@ def list_users( "roles_string": "Rôles", "date_expiration": "Expiration", "date_modif_passwd": "Modif. mot de passe", + "passwd_must_be_changed": "À changer", "last_seen": "Dernière cnx.", "non_migre": "Non migré (!)", "status_txt": "Etat", diff --git a/app/templates/auth/msg_change_password.j2 b/app/templates/auth/msg_change_password.j2 new file mode 100644 index 000000000..ed572907b --- /dev/null +++ b/app/templates/auth/msg_change_password.j2 @@ -0,0 +1,38 @@ + +