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&nbsp;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:
Allison
2026-04-27 22:29:12 -04:00
parent 8792ffb8a4
commit d2fc1f03ed
3 changed files with 563 additions and 200 deletions

View File

@@ -12,7 +12,7 @@ import mimetypes
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session, current_app 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_login import login_user, logout_user, login_required, current_user
from flask_wtf import FlaskForm 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 wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from urllib.parse import urlparse, urljoin from urllib.parse import urlparse, urljoin
@@ -20,6 +20,7 @@ import markdown
from src.database import db from src.database import db
from src.models import User, SystemSetting, GroupMembership from src.models import User, SystemSetting, GroupMembership
from src.models.consent import ConsentLog
from src.utils import password_check from src.utils import password_check
from src.auth.sso import ( from src.auth.sso import (
init_sso_client, init_sso_client,
@@ -86,24 +87,58 @@ def csrf_exempt(f):
return wrapper 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 --- # --- Forms ---
class RegistrationForm(FlaskForm): class SignupLoi25Form(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=2, max=20)]) """Loi 25 art. 14 — granular consent (4 separate checkboxes).
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')
def validate_username(self, username): Replaces the legacy RegistrationForm. Username is auto-generated from the
user = User.query.filter_by(username=username.data).first() email local-part by the view (User.username is required + max 20 + unique).
if user: Two consents are hard-required (CGU + politique de confidentialité); the
raise ValidationError('That username is already taken. Please choose a different one.') 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): 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: 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): class LoginForm(FlaskForm):
@@ -148,59 +183,144 @@ def is_registration_domain_allowed(email: str) -> bool:
# --- Routes --- # --- Routes ---
@auth_bp.route('/register', methods=['GET', 'POST']) def _registration_open() -> bool:
@rate_limit("10 per minute") """ALLOW_REGISTRATION env var (default: true) — gate for new signups."""
def register(): return os.environ.get('ALLOW_REGISTRATION', 'true').lower() == 'true'
# Check if registration is allowed
allow_registration = 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')) return redirect(url_for('auth.login'))
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('recordings.index')) return redirect(url_for('recordings.index'))
form = RegistrationForm() form = SignupLoi25Form()
if form.validate_on_submit(): 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): if not is_registration_domain_allowed(form.email.data):
flash('Registration is restricted. Please contact the administrator.', 'danger') flash("Le domaine de votre courriel n'est pas autorisé.", 'danger')
return render_template('register.html', title='Register', form=form) 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') email_normalized = form.email.data.lower().strip()
full_name = (
# Set email_verified based on whether verification is enabled f"{form.first_name.data.strip()} {form.last_name.data.strip()}".strip()
# If verification is enabled, new users start unverified )
# If disabled, new users are considered verified by default hashed_password = bcrypt.generate_password_hash(
email_verified = not is_email_verification_enabled() form.password.data
).decode('utf-8')
user = User( user = User(
username=form.username.data, username=_generate_unique_username(email_normalized),
email=form.email.data, email=email_normalized,
password=hashed_password, 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.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() db.session.commit()
audit_register(user.id) audit_register(user.id)
# Send verification email if enabled
if is_email_verification_enabled() and is_smtp_configured(): if is_email_verification_enabled() and is_smtp_configured():
if send_verification_email(user): if send_verification_email(user):
return render_template('auth/check_email.html', return render_template(
title='Check Your Email', 'auth/check_email.html',
email=user.email, title='Vérifiez votre courriel',
action='verification') email=user.email,
else: action='verification',
# 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') flash(
return redirect(url_for('auth.login')) "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 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']) @auth_bp.route('/login', methods=['GET', 'POST'])

View File

@@ -1,164 +1,103 @@
<!DOCTYPE html> {% extends 'marketing/base.html' %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<title>{{ title }} - DictIA</title>
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
<!-- All dependencies bundled locally for offline support -->
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
<!-- All dependencies bundled locally for offline support -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<!-- Loading overlay to prevent FOUC --> {% block title %}Créer un compte — DictIA{% endblock %}
{% include 'includes/loading_overlay.html' %} {% block description %}Créez votre compte DictIA. Conformité Loi&nbsp;25 du Québec, hébergement local, consentement granulaire.{% endblock %}
<script> {% block content %}
// Function to apply the theme based on localStorage <section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="signup-title">
function applyTheme() { <div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
// Guard against early execution <h1 id="signup-title" class="text-3xl font-black text-brand-navy mb-2">Créer un compte</h1>
if (!document.documentElement) return; <p class="text-sm text-brand-navy/70 mb-6">{{ "Conformité Loi&nbsp;25 incluse — consentement granulaire, hébergement au Québec." | safe }}</p>
// Apply dark mode {% with messages = get_flashed_messages(with_categories=true) %}
const savedMode = localStorage.getItem('darkMode'); {% if messages %}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; {% for category, message in messages %}
if (savedMode === 'true' || (savedMode === null && prefersDark)) { <div role="alert" class="mb-3 p-3 rounded-lg text-sm
document.documentElement.classList.add('dark'); {% if category == 'danger' %}bg-red-50 text-red-700 border border-red-200
} else { {% elif category == 'warning' %}bg-amber-50 text-amber-800 border border-amber-200
document.documentElement.classList.remove('dark'); {% elif category == 'success' %}bg-green-50 text-green-700 border border-green-200
} {% else %}bg-blue-50 text-blue-700 border border-blue-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
// Apply color scheme <form method="POST" action="{{ url_for('auth.signup') }}" class="space-y-4" novalidate>
const savedScheme = localStorage.getItem('colorScheme') || 'blue'; {{ form.hidden_tag() }}
const isDark = document.documentElement.classList.contains('dark');
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
// Remove all other theme classes
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
themeClasses.forEach(theme => {
document.documentElement.classList.remove(`theme-light-${theme}`);
document.documentElement.classList.remove(`theme-dark-${theme}`);
});
// Add the correct theme class <div>
if (savedScheme !== 'blue') { <label for="email" class="block text-sm font-medium text-brand-navy mb-1">Courriel <span class="text-red-600" aria-hidden="true">*</span></label>
document.documentElement.classList.add(themePrefix + savedScheme); {{ form.email(id='email', type='email', autocomplete='email', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
} {% if form.email.errors %}<p class="text-xs text-red-700 mt-1">{{ form.email.errors[0] }}</p>{% endif %}
} </div>
applyTheme();
</script>
</head>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
DictIA
</a>
</h1>
</header>
<main class="flex-grow flex items-center justify-center"> <div>
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]"> <label for="password" class="block text-sm font-medium text-brand-navy mb-1">Mot de passe <span class="text-red-600" aria-hidden="true">*</span></label>
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6 text-center">Create an Account</h2> {{ form.password(id='password', autocomplete='new-password', required=true, minlength=8, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
{% if form.password.errors %}<p class="text-xs text-red-700 mt-1">{{ form.password.errors[0] }}</p>{% endif %}
{% with messages = get_flashed_messages(with_categories=true) %} <p class="text-xs text-brand-navy/70 mt-1">8&nbsp;caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial.</p>
{% if messages %} </div>
{% for category, message in messages %}
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.register') }}">
{{ form.hidden_tag() }}
<div class="mb-4">
{{ form.username.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
{% if form.username.errors %}
{{ form.username(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
<div class="text-[var(--text-danger)] text-xs mt-1">
{% for error in form.username.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.username(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
{% endif %}
</div>
<div class="mb-4">
{{ form.email.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
{% if form.email.errors %}
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
<div class="text-[var(--text-danger)] text-xs mt-1">
{% for error in form.email.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
{% endif %}
</div>
<div class="mb-4">
{{ form.password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
{% if form.password.errors %}
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
<div class="text-[var(--text-danger)] text-xs mt-1">
{% for error in form.password.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
{% endif %}
<p class="text-xs text-[var(--text-muted)] mt-1">Password must be at least 8 characters long.</p>
</div>
<div class="mb-6">
{{ form.confirm_password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
{% if form.confirm_password.errors %}
{{ form.confirm_password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
<div class="text-[var(--text-danger)] text-xs mt-1">
{% for error in form.confirm_password.errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% else %}
{{ form.confirm_password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
{% endif %}
</div>
<div class="flex flex-col space-y-4">
{{ form.submit(class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]") }}
<div class="text-center text-sm text-[var(--text-muted)]">
<span>Already have an account?</span>
<a href="{{ url_for('auth.login') }}" class="font-medium text-[var(--text-accent)] hover:underline">Login here</a>
</div>
</div>
</form>
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]"> <div>
<div>&copy; {{ now.year }} InnovA AI &middot; <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialit&eacute;</a> &middot; <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div> <label for="confirm_password" class="block text-sm font-medium text-brand-navy mb-1">Confirmer le mot de passe <span class="text-red-600" aria-hidden="true">*</span></label>
</footer> {{ form.confirm_password(id='confirm_password', autocomplete='new-password', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
</div> {% if form.confirm_password.errors %}<p class="text-xs text-red-700 mt-1">{{ form.confirm_password.errors[0] }}</p>{% endif %}
</div>
<script> <div class="grid grid-cols-2 gap-3">
// Hide loading overlay when page is ready <div>
document.addEventListener('DOMContentLoaded', function() { <label for="first_name" class="block text-sm font-medium text-brand-navy mb-1">Prénom <span class="text-red-600" aria-hidden="true">*</span></label>
if (window.AppLoader) { {{ form.first_name(id='first_name', autocomplete='given-name', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
AppLoader.waitForReady(); {% if form.first_name.errors %}<p class="text-xs text-red-700 mt-1">{{ form.first_name.errors[0] }}</p>{% endif %}
} </div>
}); <div>
</script> <label for="last_name" class="block text-sm font-medium text-brand-navy mb-1">Nom <span class="text-red-600" aria-hidden="true">*</span></label>
</body> {{ form.last_name(id='last_name', autocomplete='family-name', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
</html> {% if form.last_name.errors %}<p class="text-xs text-red-700 mt-1">{{ form.last_name.errors[0] }}</p>{% endif %}
</div>
</div>
<div>
<label for="cabinet" class="block text-sm font-medium text-brand-navy mb-1">Cabinet / Organisation</label>
{{ form.cabinet(id='cabinet', autocomplete='organization', **{'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
{% if form.cabinet.errors %}<p class="text-xs text-red-700 mt-1">{{ form.cabinet.errors[0] }}</p>{% endif %}
</div>
<div>
<label for="ordre_pro" class="block text-sm font-medium text-brand-navy mb-1">Ordre professionnel</label>
{{ form.ordre_pro(id='ordre_pro', **{'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy bg-white focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
</div>
{# 4 SEPARATE consent checkboxes — Loi 25 art. 14 (consent must be granular, free, informed) #}
<fieldset class="space-y-3 pt-4 mt-2 border-t border-brand-border">
<legend class="text-xs font-semibold text-brand-navy uppercase tracking-wide mb-1">{{ "Consentements — Loi&nbsp;25" | safe }}</legend>
<label for="consent_cgu" class="flex items-start gap-2 text-sm text-brand-navy/90">
{{ form.consent_cgu(id='consent_cgu', required=true, **{'aria-required':'true', 'class':'mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
<span>J'accepte les <a href="/legal/conditions" target="_blank" rel="noopener" class="grad-text underline">conditions d'utilisation</a>. <span class="text-red-600" aria-hidden="true">*</span></span>
</label>
<label for="consent_confidentialite" class="flex items-start gap-2 text-sm text-brand-navy/90">
{{ form.consent_confidentialite(id='consent_confidentialite', required=true, **{'aria-required':'true', 'class':'mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
<span>J'accepte la <a href="/legal/confidentialite" target="_blank" rel="noopener" class="grad-text underline">politique de confidentialité</a>. <span class="text-red-600" aria-hidden="true">*</span></span>
</label>
<label for="consent_marketing" class="flex items-start gap-2 text-sm text-brand-navy/90">
{{ form.consent_marketing(id='consent_marketing', **{'class':'mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
<span>J'accepte de recevoir des communications marketing (optionnel, désactivable à tout moment).</span>
</label>
<label for="consent_analytics" class="flex items-start gap-2 text-sm text-brand-navy/90">
{{ form.consent_analytics(id='consent_analytics', **{'class':'mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
<span>J'accepte les statistiques d'usage anonymisées (optionnel, désactivable à tout moment).</span>
</label>
</fieldset>
{{ form.submit(**{'class':'w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
</form>
<p class="text-center text-sm text-brand-navy/70 mt-6">Déjà un compte ? <a href="{{ url_for('auth.login') }}" class="grad-text font-semibold">Se connecter</a></p>
</div>
</section>
{% endblock %}

304
tests/test_signup_loi25.py Normal file
View File

@@ -0,0 +1,304 @@
"""Tests for B-2.2 — /signup Loi 25-compliant flow.
Covers:
- GET /signup renders form with 4 unchecked consent checkboxes.
- POST /signup rejects when CGU or politique de confidentialité is missing.
- POST /signup creates User + 4 ConsentLog rows (one per consent type),
capturing IP (CF-Connecting-IP preferred) and User-Agent.
- /register is a 302 redirect to /signup (backward-compat).
- CSRF is enforced when WTF_CSRF_ENABLED is True.
The default test config sets WTF_CSRF_ENABLED=False for convenience; tests
that need to verify CSRF temporarily flip it back to True.
Note: pytest cannot collect this file on Windows native because
src/init_db.py imports `fcntl` (POSIX-only). Tests run in CI / Docker.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
# Make sure no email sending is attempted during tests.
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
from src.app import app, db # noqa: E402
from src.models.user import User # noqa: E402
from src.models.consent import ConsentLog # noqa: E402
# Strong password that satisfies password_check (uppercase, lowercase, digit, special, len>=8).
STRONG_PASSWORD = 'SecurePass123!Long'
VALID_FORM = {
'email': 'jane@example.qc.ca',
'password': STRONG_PASSWORD,
'confirm_password': STRONG_PASSWORD,
'first_name': 'Jane',
'last_name': 'Bouchard',
'cabinet': '',
'ordre_pro': '',
}
def _disable_csrf():
app.config['WTF_CSRF_ENABLED'] = False
def test_signup_get_returns_200_with_4_unchecked_checkboxes():
"""GET /signup renders the form with 4 consent checkboxes, none pre-checked."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
client = app.test_client()
resp = client.get('/signup')
assert resp.status_code == 200
body = resp.data.decode('utf-8')
# 4 distinct checkboxes (one per consent_type).
for ctype in ('consent_cgu', 'consent_confidentialite',
'consent_marketing', 'consent_analytics'):
assert f'name="{ctype}"' in body, f'missing checkbox {ctype}'
# None pre-checked: WTForms emits checked="checked" or checked attribute
# only when BooleanField data is truthy. With a blank GET, all 4 are False.
for ctype in ('consent_cgu', 'consent_confidentialite',
'consent_marketing', 'consent_analytics'):
# No occurrence of `name="<ctype>" ... checked` should appear.
idx = body.find(f'name="{ctype}"')
assert idx >= 0
# Look at a 200-char window around the input tag.
window = body[max(0, idx - 100): idx + 200]
assert 'checked' not in window, (
f'{ctype} must NOT be pre-checked at GET time'
)
finally:
db.session.rollback()
db.drop_all()
def test_signup_rejects_without_cgu_consent():
"""POST without consent_cgu returns 400 + French flash; no User created."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
client = app.test_client()
data = dict(VALID_FORM)
# Cocher confidentialité mais PAS la CGU.
data['consent_confidentialite'] = 'y'
resp = client.post('/signup', data=data)
assert resp.status_code == 400
body = resp.data.decode('utf-8').lower()
assert "conditions d'utilisation" in body or 'cgu' in body
assert User.query.filter_by(email=VALID_FORM['email']).first() is None
finally:
db.session.rollback()
db.drop_all()
def test_signup_rejects_without_confidentialite_consent():
"""POST without consent_confidentialite returns 400 + French flash; no User created."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
client = app.test_client()
data = dict(VALID_FORM)
data['consent_cgu'] = 'y'
# Politique de confidentialité non cochée.
resp = client.post('/signup', data=data)
assert resp.status_code == 400
body = resp.data.decode('utf-8').lower()
assert 'confidentialit' in body
assert User.query.filter_by(email=VALID_FORM['email']).first() is None
finally:
db.session.rollback()
db.drop_all()
def test_signup_creates_4_consent_logs_with_correct_state():
"""Granular consent — 4 rows, one per type, with the correct granted/refused state."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
client = app.test_client()
data = dict(VALID_FORM)
data['consent_cgu'] = 'y'
data['consent_confidentialite'] = 'y'
# marketing absent => False
data['consent_analytics'] = 'y'
resp = client.post('/signup', data=data)
assert resp.status_code in (200, 302)
user = User.query.filter_by(email=VALID_FORM['email']).first()
assert user is not None, 'User should have been created'
consents = ConsentLog.query.filter_by(user_id=user.id).all()
assert len(consents) == 4, (
'One ConsentLog row per consent type — granular Loi 25 art. 14'
)
state = {c.consent_type: c.granted for c in consents}
assert state == {
'cgu': True,
'confidentialite': True,
'marketing': False, # explicit refusal recorded
'analytics': True,
}
assert all(c.version == '2026-04-27' for c in consents)
assert all(c.granted_at is not None for c in consents)
finally:
db.session.rollback()
db.drop_all()
def test_signup_captures_ip_and_user_agent_in_consent_logs():
"""CF-Connecting-IP is honored over remote_addr; User-Agent recorded on each row."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
client = app.test_client()
data = dict(VALID_FORM)
data['email'] = 'audit@example.qc.ca'
data['consent_cgu'] = 'y'
data['consent_confidentialite'] = 'y'
resp = client.post(
'/signup',
data=data,
headers={
'CF-Connecting-IP': '203.0.113.42',
'User-Agent': 'TestAgent/1.0',
},
)
assert resp.status_code in (200, 302)
user = User.query.filter_by(email='audit@example.qc.ca').first()
assert user is not None
consents = ConsentLog.query.filter_by(user_id=user.id).all()
assert len(consents) == 4
assert all(c.ip_address == '203.0.113.42' for c in consents), (
'CF-Connecting-IP must take precedence over remote_addr'
)
assert all(c.user_agent == 'TestAgent/1.0' for c in consents)
finally:
db.session.rollback()
db.drop_all()
def test_signup_rejects_duplicate_email():
"""Duplicate email — form re-rendered with French error; no second User row."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
existing = User(
username='preexisting',
email='taken@example.qc.ca',
password='x' * 60,
)
db.session.add(existing)
db.session.commit()
client = app.test_client()
data = dict(VALID_FORM)
data['email'] = 'taken@example.qc.ca'
data['consent_cgu'] = 'y'
data['consent_confidentialite'] = 'y'
resp = client.post('/signup', data=data)
# Form re-rendered (200) with validation error from validate_email.
assert resp.status_code in (200, 400)
body = resp.data.decode('utf-8').lower()
assert 'courriel' in body and ('déjà' in body or 'deja' in body)
# Still only one user with that email.
count = User.query.filter_by(email='taken@example.qc.ca').count()
assert count == 1
finally:
db.session.rollback()
db.drop_all()
def test_signup_creates_user_with_combined_name_and_unique_username():
"""name = '<first> <last>'; username derived from email local-part, unique, max 20."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
client = app.test_client()
data = dict(VALID_FORM)
data['email'] = 'jean.tremblay@example.qc.ca'
data['first_name'] = 'Jean'
data['last_name'] = 'Tremblay'
data['consent_cgu'] = 'y'
data['consent_confidentialite'] = 'y'
resp = client.post('/signup', data=data)
assert resp.status_code in (200, 302)
user = User.query.filter_by(email='jean.tremblay@example.qc.ca').first()
assert user is not None
assert user.name == 'Jean Tremblay'
assert user.username
assert len(user.username) <= 20
# Derived from local part 'jean.tremblay' (dots stripped).
assert user.username.startswith('jeantremblay')
# Second signup with email producing same local-part collision.
data2 = dict(VALID_FORM)
data2['email'] = 'jeantremblay@otherdomain.ca'
data2['first_name'] = 'Other'
data2['last_name'] = 'Person'
data2['consent_cgu'] = 'y'
data2['consent_confidentialite'] = 'y'
resp2 = client.post('/signup', data=data2)
assert resp2.status_code in (200, 302)
user2 = User.query.filter_by(email='jeantremblay@otherdomain.ca').first()
assert user2 is not None
assert user2.username != user.username, (
'collision must produce a distinct unique username'
)
assert len(user2.username) <= 20
finally:
db.session.rollback()
db.drop_all()
def test_register_redirects_to_signup_for_backward_compat():
"""GET /register returns 302 → /signup (backward-compat after redesign)."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
client = app.test_client()
resp = client.get('/register', follow_redirects=False)
assert resp.status_code == 302
assert resp.headers['Location'].endswith('/signup')
finally:
db.session.rollback()
db.drop_all()
def test_signup_route_csrf_enforced():
"""When WTF_CSRF_ENABLED=True, POST /signup without csrf_token is rejected."""
with app.app_context():
prev = app.config.get('WTF_CSRF_ENABLED', True)
app.config['WTF_CSRF_ENABLED'] = True
db.create_all()
try:
client = app.test_client()
data = dict(VALID_FORM)
data['email'] = 'csrf@example.qc.ca'
data['consent_cgu'] = 'y'
data['consent_confidentialite'] = 'y'
resp = client.post('/signup', data=data)
# Flask-WTF returns 400 by default on missing/invalid CSRF token;
# the app's error handler may also short-circuit via JSON. Either way,
# no User must have been created.
assert resp.status_code in (400, 403)
assert User.query.filter_by(email='csrf@example.qc.ca').first() is None
finally:
app.config['WTF_CSRF_ENABLED'] = prev
db.session.rollback()
db.drop_all()