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'])
|
||||
|
||||
@@ -1,164 +1,103 @@
|
||||
<!DOCTYPE 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') }}">
|
||||
{% extends 'marketing/base.html' %}
|
||||
|
||||
<!-- Loading overlay to prevent FOUC -->
|
||||
{% include 'includes/loading_overlay.html' %}
|
||||
{% block title %}Créer un compte — DictIA{% endblock %}
|
||||
{% block description %}Créez votre compte DictIA. Conformité Loi 25 du Québec, hébergement local, consentement granulaire.{% endblock %}
|
||||
|
||||
<script>
|
||||
// Function to apply the theme based on localStorage
|
||||
function applyTheme() {
|
||||
// Guard against early execution
|
||||
if (!document.documentElement) return;
|
||||
{% block content %}
|
||||
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="signup-title">
|
||||
<div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
|
||||
<h1 id="signup-title" class="text-3xl font-black text-brand-navy mb-2">Créer un compte</h1>
|
||||
<p class="text-sm text-brand-navy/70 mb-6">{{ "Conformité Loi 25 incluse — consentement granulaire, hébergement au Québec." | safe }}</p>
|
||||
|
||||
// Apply dark mode
|
||||
const savedMode = localStorage.getItem('darkMode');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div role="alert" class="mb-3 p-3 rounded-lg text-sm
|
||||
{% if category == 'danger' %}bg-red-50 text-red-700 border border-red-200
|
||||
{% elif category == 'warning' %}bg-amber-50 text-amber-800 border border-amber-200
|
||||
{% 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
|
||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
||||
<form method="POST" action="{{ url_for('auth.signup') }}" class="space-y-4" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
// 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}`);
|
||||
});
|
||||
<div>
|
||||
<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>
|
||||
{{ 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>
|
||||
|
||||
// Add the correct theme class
|
||||
if (savedScheme !== 'blue') {
|
||||
document.documentElement.classList.add(themePrefix + savedScheme);
|
||||
}
|
||||
}
|
||||
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>
|
||||
<div>
|
||||
<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>
|
||||
{{ 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 %}
|
||||
<p class="text-xs text-brand-navy/70 mt-1">8 caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial.</p>
|
||||
</div>
|
||||
|
||||
<main class="flex-grow flex items-center justify-center">
|
||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6 text-center">Create an Account</h2>
|
||||
<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>
|
||||
{{ 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'}) }}
|
||||
{% if form.confirm_password.errors %}<p class="text-xs text-red-700 mt-1">{{ form.confirm_password.errors[0] }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% 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 %}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<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>
|
||||
{{ 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'}) }}
|
||||
{% if form.first_name.errors %}<p class="text-xs text-red-700 mt-1">{{ form.first_name.errors[0] }}</p>{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
{{ 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'}) }}
|
||||
{% if form.last_name.errors %}<p class="text-xs text-red-700 mt-1">{{ form.last_name.errors[0] }}</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.register') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
<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 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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{# 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 25" | safe }}</legend>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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)]") }}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
{{ 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>
|
||||
|
||||
<script>
|
||||
// Hide loading overlay when page is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (window.AppLoader) {
|
||||
AppLoader.waitForReady();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<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
304
tests/test_signup_loi25.py
Normal 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()
|
||||
Reference in New Issue
Block a user