diff --git a/src/api/auth.py b/src/api/auth.py index 8be61a2..1d9efef 100644 --- a/src/api/auth.py +++ b/src/api/auth.py @@ -15,6 +15,7 @@ from flask_wtf import FlaskForm 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 sqlalchemy.exc import IntegrityError from urllib.parse import urlparse, urljoin import markdown @@ -202,6 +203,11 @@ def _generate_unique_username(email: str) -> str: Append a numeric suffix on collision. User.username is required, unique, and capped at 20 chars by the schema. + + Note: this lookup-then-pick is inherently racy under concurrent signups + (two requests can both read no-collision and both pick `jane`, then one + flush wins and the other raises IntegrityError). The signup() view wraps + the call site in an IntegrityError retry loop to absorb that race. """ local = re.sub(r'[^a-z0-9]', '', email.split('@', 1)[0].lower())[:20] or 'user' candidate = local @@ -240,18 +246,21 @@ def signup(): if form.validate_on_submit(): # Hard-stop: required Loi 25 consents (CGU + politique de confidentialité). - missing_required = [] + # Errors are attached to the field directly (not flashed) so that the + # message stays scoped to this form-render and never leaks to other + # navigations via the session-backed flash queue. + consent_errors_present = False if not form.consent_cgu.data: - missing_required.append( + form.consent_cgu.errors.append( "Vous devez accepter les conditions d'utilisation pour créer un compte." ) + consent_errors_present = True if not form.consent_confidentialite.data: - missing_required.append( + form.consent_confidentialite.errors.append( "Vous devez accepter la politique de confidentialité pour créer un compte." ) - if missing_required: - for msg in missing_required: - flash(msg, 'danger') + consent_errors_present = True + if consent_errors_present: return render_template( 'register.html', title='Créer un compte', form=form ), 400 @@ -270,17 +279,29 @@ def signup(): form.password.data ).decode('utf-8') - user = User( - username=_generate_unique_username(email_normalized), - email=email_normalized, - password=hashed_password, - 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 + # Username generation can race under concurrent signups (two requests + # can both pick `jane` before either flushes). Retry up to 5 times on + # unique-constraint conflict; cap iterations to prevent an infinite + # loop on pathological username churn. + max_username_attempts = 5 + for attempt in range(max_username_attempts): + user = User( + username=_generate_unique_username(email_normalized), + email=email_normalized, + password=hashed_password, + 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) + try: + db.session.flush() # need user.id for ConsentLog FK + break + except IntegrityError: + db.session.rollback() + if attempt == max_username_attempts - 1: + raise ip = _client_ip() ua = (request.headers.get('User-Agent') or '')[:500] diff --git a/static/css/marketing.css b/static/css/marketing.css index 36b7380..1c5c6d8 100644 --- a/static/css/marketing.css +++ b/static/css/marketing.css @@ -828,6 +828,9 @@ .min-h-\[8rem\] { min-height: 8rem; } + .min-h-\[calc\(100vh-62px\)\] { + min-height: calc(100vh - 62px); + } .min-h-screen { min-height: 100vh; } @@ -2436,6 +2439,9 @@ .text-amber-800 { color: var(--color-amber-800); } + .text-amber-900 { + color: var(--color-amber-900); + } .text-blue-400 { color: var(--color-blue-400); } @@ -2451,6 +2457,9 @@ .text-blue-800 { color: var(--color-blue-800); } + .text-blue-900 { + color: var(--color-blue-900); + } .text-brand-b3 { color: #00c896; } @@ -2466,6 +2475,9 @@ .text-brand-navy\/80 { color: color-mix(in oklab, #060d1a 80%, transparent); } + .text-brand-navy\/90 { + color: color-mix(in oklab, #060d1a 90%, transparent); + } .text-emerald-500 { color: var(--color-emerald-500); } @@ -2496,6 +2508,9 @@ .text-green-800 { color: var(--color-green-800); } + .text-green-900 { + color: var(--color-green-900); + } .text-indigo-600 { color: var(--color-indigo-600); } @@ -2529,6 +2544,9 @@ .text-red-800 { color: var(--color-red-800); } + .text-red-900 { + color: var(--color-red-900); + } .text-teal-600 { color: var(--color-teal-600); } diff --git a/static/css/tailwind.config.js b/static/css/tailwind.config.js index 765b002..c3bf169 100644 --- a/static/css/tailwind.config.js +++ b/static/css/tailwind.config.js @@ -1,6 +1,6 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: ['./templates/marketing/**/*.html', './templates/legal/**/*.html', './templates/billing/**/*.html', './templates/macros/**/*.html', './src/marketing/**/*.py', './src/legal/**/*.py', './src/billing/**/*.py'], + content: ['./templates/marketing/**/*.html', './templates/legal/**/*.html', './templates/billing/**/*.html', './templates/macros/**/*.html', './templates/auth/**/*.html', './templates/register.html', './src/marketing/**/*.py', './src/legal/**/*.py', './src/billing/**/*.py'], darkMode: 'class', theme: { extend: { diff --git a/templates/register.html b/templates/register.html index 0714d76..494792f 100644 --- a/templates/register.html +++ b/templates/register.html @@ -13,10 +13,10 @@ {% if messages %} {% for category, message in messages %} {% endfor %} @@ -34,9 +34,9 @@
- {{ 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 %}

{{ form.password.errors[0] }}

{% endif %} -

8 caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial.

+ {{ form.password(id='password', autocomplete='new-password', required=true, minlength=8, **{'aria-required':'true', 'aria-describedby':'password-help', '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 %}

{{ form.password.errors[0] }}

{% endif %} +

8 caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial.

@@ -77,11 +77,13 @@ {{ 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'}) }} J'accepte les conditions d'utilisation. + {% if form.consent_cgu.errors %}{% endif %} + {% if form.consent_confidentialite.errors %}{% endif %}