feat(auth): B-2.2 signup Loi 25-compliant (4 consent checkboxes)
Refondre /register en /signup avec consentement granulaire (LPRPSP art. 14):
- SignupLoi25Form (Flask-WTF) remplace RegistrationForm
- 4 BooleanField séparés: cgu, confidentialite (obligatoires) + marketing,
analytics (optionnels). Chaque consentement crée 1 row ConsentLog avec
ip_address (CF-Connecting-IP > remote_addr), user_agent (tronqué 500),
version='2026-04-27' (B-2.9 substituera LEGAL_VERSION canonique).
- Marketing/analytics non cochés -> ConsentLog row avec granted=False
(refus explicite tracé pour audit Loi 25).
- /register reste 302 -> /signup (backward compat).
- Username auto-généré unique depuis email local-part (max 20, alphanum,
suffixe numérique sur collision).
- name = "{first_name} {last_name}".strip() persisté dans User.name
(pas de colonnes first_name/last_name au modèle).
- send_verification_email() existant réutilisé (smtplib via env SMTP_*).
Template register.html refondu IN PLACE pour étendre marketing/base.html:
- 4 checkboxes dans <fieldset>+<legend>, AUCUNE pré-cochée
- WCAG 2.2 AA: focus-visible outlines, aria-required, label for=, role=alert
- OQLF: NBSP via | safe pour "Loi 25"
Tests: 9 cas couvrent GET 200, refus CGU, refus RPRP, happy path 4 rows,
capture IP+UA, duplicate email, username collision, /register redirect,
CSRF enforcement. Pattern test_consent_log.py (no conftest, env setup
avant imports, app_context, db.create_all/drop_all).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
208
src/api/auth.py
208
src/api/auth.py
@@ -12,7 +12,7 @@ import mimetypes
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session, current_app
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, SubmitField, BooleanField
|
||||
from wtforms import StringField, PasswordField, SubmitField, BooleanField, SelectField
|
||||
from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from urllib.parse import urlparse, urljoin
|
||||
@@ -20,6 +20,7 @@ import markdown
|
||||
|
||||
from src.database import db
|
||||
from src.models import User, SystemSetting, GroupMembership
|
||||
from src.models.consent import ConsentLog
|
||||
from src.utils import password_check
|
||||
from src.auth.sso import (
|
||||
init_sso_client,
|
||||
@@ -86,24 +87,58 @@ def csrf_exempt(f):
|
||||
return wrapper
|
||||
|
||||
|
||||
# --- Constants ---
|
||||
|
||||
# B-2.9 will define the canonical LEGAL_VERSION constant in src/legal/__init__.py.
|
||||
# Until then, use the ISO date of the current legal text revision as the placeholder.
|
||||
SIGNUP_LEGAL_VERSION = '2026-04-27'
|
||||
|
||||
|
||||
# --- Forms ---
|
||||
|
||||
class RegistrationForm(FlaskForm):
|
||||
username = StringField('Username', validators=[DataRequired(), Length(min=2, max=20)])
|
||||
email = StringField('Email', validators=[DataRequired(), Email()])
|
||||
password = PasswordField('Password', validators=[DataRequired(), password_check])
|
||||
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
|
||||
submit = SubmitField('Sign Up')
|
||||
class SignupLoi25Form(FlaskForm):
|
||||
"""Loi 25 art. 14 — granular consent (4 separate checkboxes).
|
||||
|
||||
def validate_username(self, username):
|
||||
user = User.query.filter_by(username=username.data).first()
|
||||
if user:
|
||||
raise ValidationError('That username is already taken. Please choose a different one.')
|
||||
Replaces the legacy RegistrationForm. Username is auto-generated from the
|
||||
email local-part by the view (User.username is required + max 20 + unique).
|
||||
Two consents are hard-required (CGU + politique de confidentialité); the
|
||||
other two (marketing, analytics) are optional and recorded either way.
|
||||
"""
|
||||
email = StringField('Courriel', validators=[
|
||||
DataRequired(), Email(), Length(max=120)
|
||||
])
|
||||
password = PasswordField('Mot de passe', validators=[
|
||||
DataRequired(), password_check
|
||||
])
|
||||
confirm_password = PasswordField('Confirmer le mot de passe', validators=[
|
||||
DataRequired(),
|
||||
EqualTo('password', message='Les mots de passe ne correspondent pas.'),
|
||||
])
|
||||
first_name = StringField('Prénom', validators=[DataRequired(), Length(max=49)])
|
||||
last_name = StringField('Nom', validators=[DataRequired(), Length(max=49)])
|
||||
cabinet = StringField('Cabinet / Organisation', validators=[Length(max=255)])
|
||||
ordre_pro = SelectField('Ordre professionnel', choices=[
|
||||
('', 'Aucun ou autre'),
|
||||
('barreau', 'Barreau du Québec'),
|
||||
('cpa', 'CPA Québec'),
|
||||
('chad', 'ChAD'),
|
||||
('oaciq', 'OACIQ'),
|
||||
('cmq', 'CMQ'),
|
||||
('oiiq', 'OIIQ'),
|
||||
('oppq', 'OPPQ'),
|
||||
('oeq', 'OEQ'),
|
||||
('opq', 'OPQ'),
|
||||
], default='', validators=[])
|
||||
consent_cgu = BooleanField('CGU')
|
||||
consent_confidentialite = BooleanField('Confidentialité')
|
||||
consent_marketing = BooleanField('Marketing')
|
||||
consent_analytics = BooleanField('Analytics')
|
||||
submit = SubmitField('Créer mon compte')
|
||||
|
||||
def validate_email(self, email):
|
||||
user = User.query.filter_by(email=email.data).first()
|
||||
user = User.query.filter_by(email=email.data.lower().strip()).first()
|
||||
if user:
|
||||
raise ValidationError('That email is already registered. Please use a different one.')
|
||||
raise ValidationError('Ce courriel est déjà utilisé.')
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
@@ -148,59 +183,144 @@ def is_registration_domain_allowed(email: str) -> bool:
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
@rate_limit("10 per minute")
|
||||
def register():
|
||||
# Check if registration is allowed
|
||||
allow_registration = os.environ.get('ALLOW_REGISTRATION', 'true').lower() == 'true'
|
||||
def _registration_open() -> bool:
|
||||
"""ALLOW_REGISTRATION env var (default: true) — gate for new signups."""
|
||||
return os.environ.get('ALLOW_REGISTRATION', 'true').lower() == 'true'
|
||||
|
||||
if not allow_registration:
|
||||
flash('Registration is currently disabled. Please contact the administrator.', 'danger')
|
||||
|
||||
def _client_ip() -> str:
|
||||
"""Return the originating client IP, honoring Cloudflare's CF-Connecting-IP header.
|
||||
|
||||
Falls back to remote_addr (which is already corrected by ProxyFix when
|
||||
behind a reverse proxy). Used for Loi 25 ConsentLog.ip_address.
|
||||
"""
|
||||
return request.headers.get('CF-Connecting-IP') or request.remote_addr or '0.0.0.0'
|
||||
|
||||
|
||||
def _generate_unique_username(email: str) -> str:
|
||||
"""Derive a unique username from the email local-part (max 20 chars).
|
||||
|
||||
Append a numeric suffix on collision. User.username is required, unique,
|
||||
and capped at 20 chars by the schema.
|
||||
"""
|
||||
local = re.sub(r'[^a-z0-9]', '', email.split('@', 1)[0].lower())[:20] or 'user'
|
||||
candidate = local
|
||||
suffix = 0
|
||||
while User.query.filter_by(username=candidate).first():
|
||||
suffix += 1
|
||||
tail = str(suffix)
|
||||
candidate = f"{local[:20 - len(tail)]}{tail}"
|
||||
return candidate
|
||||
|
||||
|
||||
@auth_bp.route('/register')
|
||||
def register():
|
||||
"""Backward-compat alias: /register → /signup (Loi 25 redesign B-2.2)."""
|
||||
return redirect(url_for('auth.signup'), code=302)
|
||||
|
||||
|
||||
@auth_bp.route('/signup', methods=['GET', 'POST'])
|
||||
@rate_limit("10 per minute")
|
||||
def signup():
|
||||
"""Loi 25-compliant signup with 4 separate consent checkboxes.
|
||||
|
||||
Each signup creates exactly 4 ConsentLog rows (cgu, confidentialite,
|
||||
marketing, analytics) with the user's explicit choice. CGU and
|
||||
confidentialité are hard-required; marketing and analytics are optional
|
||||
but their refusal is also recorded (granted=False) for the audit trail.
|
||||
"""
|
||||
if not _registration_open():
|
||||
flash('La création de comptes est temporairement désactivée.', 'danger')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('recordings.index'))
|
||||
|
||||
form = RegistrationForm()
|
||||
form = SignupLoi25Form()
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Check if email domain is allowed
|
||||
# Hard-stop: required Loi 25 consents (CGU + politique de confidentialité).
|
||||
missing_required = []
|
||||
if not form.consent_cgu.data:
|
||||
missing_required.append(
|
||||
"Vous devez accepter les conditions d'utilisation pour créer un compte."
|
||||
)
|
||||
if not form.consent_confidentialite.data:
|
||||
missing_required.append(
|
||||
"Vous devez accepter la politique de confidentialité pour créer un compte."
|
||||
)
|
||||
if missing_required:
|
||||
for msg in missing_required:
|
||||
flash(msg, 'danger')
|
||||
return render_template(
|
||||
'register.html', title='Créer un compte', form=form
|
||||
), 400
|
||||
|
||||
if not is_registration_domain_allowed(form.email.data):
|
||||
flash('Registration is restricted. Please contact the administrator.', 'danger')
|
||||
return render_template('register.html', title='Register', form=form)
|
||||
flash("Le domaine de votre courriel n'est pas autorisé.", 'danger')
|
||||
return render_template(
|
||||
'register.html', title='Créer un compte', form=form
|
||||
), 400
|
||||
|
||||
hashed_password = bcrypt.generate_password_hash(form.password.data).decode('utf-8')
|
||||
|
||||
# Set email_verified based on whether verification is enabled
|
||||
# If verification is enabled, new users start unverified
|
||||
# If disabled, new users are considered verified by default
|
||||
email_verified = not is_email_verification_enabled()
|
||||
email_normalized = form.email.data.lower().strip()
|
||||
full_name = (
|
||||
f"{form.first_name.data.strip()} {form.last_name.data.strip()}".strip()
|
||||
)
|
||||
hashed_password = bcrypt.generate_password_hash(
|
||||
form.password.data
|
||||
).decode('utf-8')
|
||||
|
||||
user = User(
|
||||
username=form.username.data,
|
||||
email=form.email.data,
|
||||
username=_generate_unique_username(email_normalized),
|
||||
email=email_normalized,
|
||||
password=hashed_password,
|
||||
email_verified=email_verified
|
||||
name=full_name,
|
||||
cabinet=(form.cabinet.data.strip() or None) if form.cabinet.data else None,
|
||||
ordre_pro=(form.ordre_pro.data or None),
|
||||
email_verified=not is_email_verification_enabled(),
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.flush() # need user.id for ConsentLog FK
|
||||
|
||||
ip = _client_ip()
|
||||
ua = (request.headers.get('User-Agent') or '')[:500]
|
||||
consent_state = {
|
||||
'cgu': bool(form.consent_cgu.data),
|
||||
'confidentialite': bool(form.consent_confidentialite.data),
|
||||
'marketing': bool(form.consent_marketing.data),
|
||||
'analytics': bool(form.consent_analytics.data),
|
||||
}
|
||||
for ctype, granted in consent_state.items():
|
||||
db.session.add(ConsentLog(
|
||||
user_id=user.id,
|
||||
consent_type=ctype,
|
||||
version=SIGNUP_LEGAL_VERSION,
|
||||
granted=granted,
|
||||
ip_address=ip,
|
||||
user_agent=ua,
|
||||
))
|
||||
db.session.commit()
|
||||
audit_register(user.id)
|
||||
|
||||
# Send verification email if enabled
|
||||
if is_email_verification_enabled() and is_smtp_configured():
|
||||
if send_verification_email(user):
|
||||
return render_template('auth/check_email.html',
|
||||
title='Check Your Email',
|
||||
email=user.email,
|
||||
action='verification')
|
||||
else:
|
||||
# Email failed to send, but account was created
|
||||
flash('Your account has been created, but we could not send a verification email. Please contact support.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
return render_template(
|
||||
'auth/check_email.html',
|
||||
title='Vérifiez votre courriel',
|
||||
email=user.email,
|
||||
action='verification',
|
||||
)
|
||||
flash(
|
||||
"Compte créé, mais l'envoi du courriel de vérification a échoué. "
|
||||
"Contactez le support.",
|
||||
'warning',
|
||||
)
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
flash('Your account has been created! You can now log in.', 'success')
|
||||
flash('Votre compte a été créé. Vous pouvez vous connecter.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
return render_template('register.html', title='Register', form=form)
|
||||
return render_template('register.html', title='Créer un compte', form=form)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
|
||||
Reference in New Issue
Block a user