feat(auth): B-2.4 OAuth Microsoft/Google + magic link (Loi 25 deferred consent)

Adds Microsoft 365 + Google OAuth providers (separate from the existing
generic OIDC SSO at src/auth/sso.py) and a passwordless magic-link login
flow. New OAuth signups capture Loi 25 art. 14 consents (4 granular
checkboxes) BEFORE creating the User row via /auth/oauth/finish-signup.

Per compatibility-audit.md C2:
- No src/auth_extended/ directory — extends src/auth/ in place
- No new User columns — reuses sso_provider/sso_subject + email_verified
- Magic-link tokens via itsdangerous URLSafeTimedSerializer (15-min, no DB)
- All routes added to existing auth_bp; templates extend marketing/base.html
- Anti-enumeration on /auth/magic-link (generic flash for unknown OR
  unverified emails) and /auth/magic-link/<token> (same flash for
  invalid/expired/unverified-user)

Files added:
- src/auth/oauth_providers.py — Microsoft + Google OAuth registration,
  is_oauth_provider_enabled(), find_user_by_oauth(), create_oauth_user_with_consent()
- src/auth/magic_link.py — generate/consume magic-link tokens
- templates/auth/magic_link_request.html, templates/auth/oauth_finish_signup.html
- tests/test_oauth_magic_link.py + tests/_run_oauth_magic_link_windows.py (16 tests)
- config/env.oauth.example

Files modified:
- src/api/auth.py — 5 new routes (oauth_provider_login/callback,
  oauth_finish_signup, magic_link_request/consume); login flashes translated FR;
  oauth_*_enabled flags passed to login template
- src/app.py — wires init_oauth_providers(app) after blueprint registration
- src/services/email.py — adds send_magic_link_email() (FR + DictIA brand)
- templates/login.html — refondu IN PLACE (was 178 lines legacy Vue/TW3)
  to extend marketing/base.html with OAuth buttons, password form,
  magic-link CTA, signup link
- templates/auth/check_email.html — adds action='magic_link' branch
- static/css/tailwind.config.js — adds templates/login.html to content
- static/css/marketing.css — rebuilt

Tests: 16/16 PASS via Windows manual driver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Allison
2026-04-27 23:29:24 -04:00
parent dd270bca9e
commit 0513e67838
14 changed files with 1576 additions and 176 deletions

69
config/env.oauth.example Normal file
View File

@@ -0,0 +1,69 @@
###############################################################################
# OAuth Providers — Microsoft 365 + Google (B-2.4)
###############################################################################
#
# These providers complement (do NOT replace) the generic OIDC SSO at
# config/env.sso.example. Both can be enabled simultaneously: users see
# Microsoft 365, Google, and SSO buttons on /login, plus the magic-link
# fallback that does not require any OAuth provider.
#
# IMPORTANT — Loi 25 art. 14 (consent must be granular, free, informed):
# OAuth signups still require Loi 25 consent capture via
# /auth/oauth/finish-signup BEFORE the User row is created. Existing
# users (matched by sso_subject or email) skip the consent page and log
# in directly.
#
# Magic-link login (/auth/magic-link, /auth/magic-link/<token>) reuses
# the SMTP settings from env.email.example — no additional env vars needed.
###############################################################################
# Microsoft 365 (Microsoft Entra ID, formerly Azure AD)
###############################################################################
# 1. Register a new app at https://entra.microsoft.com
# > Identity > Applications > App registrations > New registration
# 2. Set the redirect URI to:
# https://your-domain.example/auth/oauth/microsoft/callback
# 3. Generate a client secret under Certificates & secrets > Client secrets
# 4. Set MS_CLIENT_ID to the Application (client) ID
# 5. Set MS_CLIENT_SECRET to the secret VALUE (NOT the secret ID)
#
# Tenant restriction: by default the OAuth flow accepts users from any
# Microsoft tenant (server_metadata_url uses /common/). To restrict to a
# specific organization, edit src/auth/oauth_providers.py and replace
# /common/ with your tenant ID (e.g. /your-tenant-id-guid/).
#
# MS_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# MS_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
###############################################################################
# Google (Google Cloud Console)
###############################################################################
# 1. Create an OAuth client at https://console.cloud.google.com
# > APIs & Services > Credentials > Create Credentials > OAuth client ID
# Application type: "Web application"
# 2. Set the redirect URI to:
# https://your-domain.example/auth/oauth/google/callback
# 3. Configure the OAuth consent screen in the same console
# (must be in "Production" status to accept users outside the test list)
# 4. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from the credentials page
#
# GOOGLE_CLIENT_ID=xxxxxxxxxxxx-xxxxxxxxxxxx.apps.googleusercontent.com
# GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxx
###############################################################################
# Notes
###############################################################################
#
# Token storage:
# - sso_provider stores the literal string "microsoft" or "google"
# - sso_subject stores the OAuth `sub` claim (provider-issued user ID)
# - email_verified is set to True automatically (the provider has
# already verified the email address)
# - password is NULL for OAuth-only accounts; users can set a password
# later via /forgot-password if they want a fallback login method
#
# Magic-link tokens:
# - Stateless via itsdangerous.URLSafeTimedSerializer
# - 15-minute expiry, signed with SECRET_KEY + salt 'magic-link-login'
# - No DB column — tokens are not single-use within the 15-min window
# - SMTP must be configured (see env.email.example) for the link to send

View File

@@ -43,11 +43,23 @@ from src.services.email import (
is_smtp_configured, is_smtp_configured,
send_verification_email, send_verification_email,
send_password_reset_email, send_password_reset_email,
send_magic_link_email,
verify_email_token, verify_email_token,
verify_reset_token, verify_reset_token,
can_resend_verification, can_resend_verification,
can_resend_password_reset, can_resend_password_reset,
) )
from src.auth.oauth_providers import (
is_oauth_provider_enabled,
get_oauth_client,
find_user_by_oauth,
create_oauth_user_with_consent,
get_oauth_provider_display_name,
)
from src.auth.magic_link import (
generate_magic_link_token,
consume_magic_link_token,
)
# Create blueprint # Create blueprint
auth_bp = Blueprint('auth', __name__) auth_bp = Blueprint('auth', __name__)
@@ -363,14 +375,14 @@ def login():
if user and user.password: if user and user.password:
# Check if password login is disabled for non-admins # Check if password login is disabled for non-admins
if password_login_disabled and not user.is_admin: if password_login_disabled and not user.is_admin:
flash('Password login is disabled. Please sign in with SSO.', 'warning') flash('La connexion par mot de passe est désactivée. Utilisez le SSO.', 'warning')
elif bcrypt.check_password_hash(user.password, form.password.data): elif bcrypt.check_password_hash(user.password, form.password.data):
# Check email verification if required # Check email verification if required
if is_email_verification_required() and not user.email_verified: if is_email_verification_required() and not user.email_verified:
# Store user email in session for resend functionality # Store user email in session for resend functionality
session['unverified_email'] = user.email session['unverified_email'] = user.email
return render_template('auth/check_email.html', return render_template('auth/check_email.html',
title='Email Verification Required', title='rification du courriel requise',
email=user.email, email=user.email,
action='verification_required', action='verification_required',
show_resend=True) show_resend=True)
@@ -384,23 +396,25 @@ def login():
else: else:
_email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16] _email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
audit_failed_login(details={'email_hash': _email_hash, 'reason': 'wrong_password'}) audit_failed_login(details={'email_hash': _email_hash, 'reason': 'wrong_password'})
flash('Login unsuccessful. Please check email and password.', 'danger') flash('Connexion impossible. Vérifiez votre courriel et votre mot de passe.', 'danger')
elif user and not user.password: elif user and not user.password:
_email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16] _email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
audit_failed_login(details={'email_hash': _email_hash, 'reason': 'sso_only_account'}) audit_failed_login(details={'email_hash': _email_hash, 'reason': 'sso_only_account'})
flash('This account uses SSO login. Please sign in with SSO.', 'warning') flash("Ce compte utilise une connexion fédérée (SSO ou Microsoft/Google). Utilisez l'un des boutons ci-dessus.", 'warning')
else: else:
_email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16] _email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
audit_failed_login(details={'email_hash': _email_hash, 'reason': 'user_not_found'}) audit_failed_login(details={'email_hash': _email_hash, 'reason': 'user_not_found'})
flash('Login unsuccessful. Please check email and password.', 'danger') flash('Connexion impossible. Vérifiez votre courriel et votre mot de passe.', 'danger')
return render_template( return render_template(
'login.html', 'login.html',
title='Login', title='Connexion',
form=form, form=form,
sso_enabled=sso_enabled, sso_enabled=sso_enabled,
sso_provider_name=sso_config.get('provider_name', 'SSO'), sso_provider_name=sso_config.get('provider_name', 'SSO'),
password_login_disabled=password_login_disabled password_login_disabled=password_login_disabled,
oauth_microsoft_enabled=is_oauth_provider_enabled('microsoft'),
oauth_google_enabled=is_oauth_provider_enabled('google'),
) )
@@ -529,6 +543,238 @@ def sso_unlink():
return redirect(url_for('auth.account')) return redirect(url_for('auth.account'))
# --- B-2.4: Microsoft 365 + Google OAuth + Magic Link ---
@auth_bp.route('/auth/oauth/<provider>/login')
@rate_limit("10 per minute")
def oauth_provider_login(provider):
"""Start OAuth flow with the named provider (microsoft|google).
Disabled providers redirect to /login with a French flash. Authenticated
users are redirected to the app to avoid an infinite OAuth bounce.
"""
if not is_oauth_provider_enabled(provider):
flash(
f"La connexion via {get_oauth_provider_display_name(provider)} n'est pas activée.",
'danger',
)
return redirect(url_for('auth.login'))
if current_user.is_authenticated:
return redirect(url_for('recordings.index'))
redirect_uri = url_for(
'auth.oauth_provider_callback', provider=provider, _external=True
)
return get_oauth_client(provider).authorize_redirect(redirect_uri)
@auth_bp.route('/auth/oauth/<provider>/callback')
def oauth_provider_callback(provider):
"""Receive OAuth callback.
- Existing user (matched by sso_subject OR email): log in directly.
- New user: store userinfo in session and redirect to /auth/oauth/finish-signup
for Loi 25 consent capture BEFORE creating the User row.
"""
if not is_oauth_provider_enabled(provider):
flash("Fournisseur OAuth indisponible.", 'danger')
return redirect(url_for('auth.login'))
try:
token = get_oauth_client(provider).authorize_access_token()
except Exception as e:
current_app.logger.warning('OAuth callback failed for %s: %s', provider, e)
flash("La connexion a échoué. Réessayez.", 'danger')
return redirect(url_for('auth.login'))
userinfo = token.get('userinfo') or {}
subject = userinfo.get('sub')
email = (userinfo.get('email') or '').lower().strip()
if not subject or not email:
flash(
"Le fournisseur n'a pas retourné d'identifiant ou de courriel.",
'danger',
)
return redirect(url_for('auth.login'))
user = find_user_by_oauth(provider, subject, email)
if user:
login_user(user)
audit_sso_login(user.id, details={'provider': provider})
return redirect(url_for('recordings.index'))
# New user — defer creation until Loi 25 consents are captured.
session['oauth_signup_pending'] = {
'provider': provider,
'subject': subject,
'userinfo': {
'email': email,
'name': userinfo.get('name', ''),
'given_name': userinfo.get('given_name', ''),
'family_name': userinfo.get('family_name', ''),
},
}
return redirect(url_for('auth.oauth_finish_signup'))
@auth_bp.route('/auth/oauth/finish-signup', methods=['GET', 'POST'])
@rate_limit("10 per minute")
def oauth_finish_signup():
"""Capture Loi 25 consents for OAuth signups before creating the User row.
Falls back to /signup if no pending OAuth session exists. Refuses without
CGU + politique de confidentialité (hard-required by Loi 25 art. 14).
"""
pending = session.get('oauth_signup_pending')
if not pending:
return redirect(url_for('auth.signup'))
if current_user.is_authenticated:
session.pop('oauth_signup_pending', None)
return redirect(url_for('recordings.index'))
if request.method == 'POST':
consent_cgu = request.form.get('consent_cgu') == 'y'
consent_confidentialite = request.form.get('consent_confidentialite') == 'y'
consent_marketing = request.form.get('consent_marketing') == 'y'
consent_analytics = request.form.get('consent_analytics') == 'y'
errors = {}
if not consent_cgu:
errors['consent_cgu'] = (
"Vous devez accepter les conditions d'utilisation pour créer un compte."
)
if not consent_confidentialite:
errors['consent_confidentialite'] = (
"Vous devez accepter la politique de confidentialité pour créer un compte."
)
if errors:
return render_template(
'auth/oauth_finish_signup.html',
title='Finaliser votre inscription DictIA',
provider=pending['provider'],
provider_display=get_oauth_provider_display_name(pending['provider']),
userinfo=pending['userinfo'],
errors=errors,
), 400
consents = {
'cgu': consent_cgu,
'confidentialite': consent_confidentialite,
'marketing': consent_marketing,
'analytics': consent_analytics,
}
ip = _client_ip()
ua = (request.headers.get('User-Agent') or '')[:500]
try:
user = create_oauth_user_with_consent(
provider=pending['provider'],
subject=pending['subject'],
userinfo=pending['userinfo'],
consents=consents,
ip=ip,
ua=ua,
legal_version=SIGNUP_LEGAL_VERSION,
)
except ValueError as e:
current_app.logger.warning('OAuth signup failed: %s', e)
flash(
"L'inscription via OAuth a échoué. Réessayez ou utilisez le formulaire d'inscription.",
'danger',
)
session.pop('oauth_signup_pending', None)
return redirect(url_for('auth.signup'))
session.pop('oauth_signup_pending', None)
login_user(user)
audit_register(user.id)
audit_sso_login(user.id, details={'provider': pending['provider']})
flash('Bienvenue chez DictIA !', 'success')
return redirect(url_for('recordings.index'))
return render_template(
'auth/oauth_finish_signup.html',
title='Finaliser votre inscription DictIA',
provider=pending['provider'],
provider_display=get_oauth_provider_display_name(pending['provider']),
userinfo=pending['userinfo'],
errors={},
)
@auth_bp.route('/auth/magic-link', methods=['GET', 'POST'])
@rate_limit("5 per minute")
def magic_link_request():
"""Request a magic-link email.
Anti-enumeration (Loi 25 / OWASP A01): same generic French message
whether the email matches a real account or not, and whether the
account is verified or not. The email is only sent for known
+ verified users with SMTP configured.
"""
if current_user.is_authenticated:
return redirect(url_for('recordings.index'))
if request.method == 'POST':
email = (request.form.get('email') or '').lower().strip()
if not email:
flash("L'adresse courriel est requise.", 'danger')
return render_template(
'auth/magic_link_request.html',
title='Lien de connexion DictIA',
), 400
# Anti-enumeration: lookup user but always show the same flash
user = User.query.filter_by(email=email).first()
if user and user.email_verified and is_smtp_configured():
token = generate_magic_link_token(user.id)
magic_url = url_for(
'auth.magic_link_consume', token=token, _external=True
)
send_magic_link_email(user, magic_url)
flash(
"Si un compte vérifié existe pour ce courriel, un lien de "
"connexion vous a été envoyé. Le lien expire dans 15 minutes.",
'info',
)
return render_template(
'auth/check_email.html',
title='Vérifiez votre courriel',
email=email,
action='magic_link',
)
return render_template(
'auth/magic_link_request.html',
title='Lien de connexion DictIA',
)
@auth_bp.route('/auth/magic-link/<token>')
@rate_limit("10 per minute")
def magic_link_consume(token):
"""Consume a magic-link token. Logs in the user on success.
Rejects invalid, expired, and unverified-user tokens with the same
generic French flash to avoid leaking which one of those failure
modes occurred.
"""
if current_user.is_authenticated:
return redirect(url_for('recordings.index'))
user_id = consume_magic_link_token(token)
if not user_id:
flash("Le lien de connexion est invalide ou expiré.", 'danger')
return redirect(url_for('auth.magic_link_request'))
user = db.session.get(User, user_id)
if not user or not user.email_verified:
flash("Le lien de connexion est invalide ou expiré.", 'danger')
return redirect(url_for('auth.magic_link_request'))
login_user(user)
audit_login(user.id)
return redirect(url_for('recordings.index'))
@auth_bp.route('/logout') @auth_bp.route('/logout')
@csrf_exempt @csrf_exempt
def logout(): def logout():

View File

@@ -647,6 +647,12 @@ app.register_blueprint(marketing_bp)
app.register_blueprint(billing_bp) app.register_blueprint(billing_bp)
app.register_blueprint(legal_bp) app.register_blueprint(legal_bp)
# Initialize Microsoft + Google OAuth providers (B-2.4) — no-op if env vars absent.
# Must run AFTER blueprints are registered (Authlib's OAuth object needs to be
# attached to the running app instance).
from src.auth.oauth_providers import init_oauth_providers as _init_oauth_providers
_init_oauth_providers(app)
# File monitor and scheduler initialization functions below # File monitor and scheduler initialization functions below
# Startup functions (extracted to src/config/startup.py) # Startup functions (extracted to src/config/startup.py)

40
src/auth/magic_link.py Normal file
View File

@@ -0,0 +1,40 @@
"""Magic link login (B-2.4).
Stateless tokens via ``itsdangerous`` (no DB column). Same pattern as
``src/services/email.py:generate_verification_token`` — token contains
the user_id; ``max_age`` is 15 minutes.
The compatibility-audit (C2) explicitly forbids new User columns
(no ``magic_link_token``, no ``magic_link_sent_at``). Single-use enforcement
is intentionally NOT implemented at this layer because the cost of a
short-window replay (≤15 min, requires the user's email) is acceptable
for the threat model — the user opened the email and clicked the link.
If single-use becomes a hard requirement later, add an ip + sent_at index
to a separate magic-link audit table without touching User.
"""
from typing import Optional
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
from flask import current_app
MAGIC_LINK_EXPIRY_SECONDS = 15 * 60 # 15 minutes
_SALT = 'magic-link-login'
def _serializer() -> URLSafeTimedSerializer:
"""Build a fresh serializer per call (cheap; reads SECRET_KEY from app config)."""
secret_key = current_app.config.get('SECRET_KEY', 'default-dev-key')
return URLSafeTimedSerializer(secret_key, salt=_SALT)
def generate_magic_link_token(user_id: int) -> str:
"""Sign a magic-link token containing the user_id."""
return _serializer().dumps(user_id)
def consume_magic_link_token(token: str) -> Optional[int]:
"""Return user_id if token is valid and unexpired, else None."""
try:
return _serializer().loads(token, max_age=MAGIC_LINK_EXPIRY_SECONDS)
except (SignatureExpired, BadSignature):
return None

200
src/auth/oauth_providers.py Normal file
View File

@@ -0,0 +1,200 @@
"""Microsoft 365 + Google OAuth providers (B-2.4).
Adds two named OAuth clients alongside the existing generic SSO at
``src/auth/sso.py``. Patterns match sso.py: env-var gated, separate
OAuth instance from the generic SSO, but with **Loi 25 consent capture
deferred** to ``/auth/oauth/finish-signup`` for new users (existing
users by sso_subject or email skip the consent page and log in directly).
The compatibility-audit (C2) explicitly forbids creating an
``src/auth_extended/`` directory or new User columns — we reuse
``User.sso_provider`` (max 100) and ``User.sso_subject`` (max 255, unique)
to store the provider name (``'microsoft'`` | ``'google'``) and the OAuth
``sub`` claim.
"""
import os
from typing import Dict, Optional
from authlib.integrations.flask_client import OAuth
from src.database import db
from src.models import User
# Single OAuth instance shared across providers — kept separate from
# src/auth/sso.py's _oauth (which serves the legacy generic SSO).
_oauth: Optional[OAuth] = None
# Provider configuration — server_metadata_url + scope baseline.
_PROVIDER_CONFIG = {
'microsoft': {
'env_client_id': 'MS_CLIENT_ID',
'env_client_secret': 'MS_CLIENT_SECRET',
'server_metadata_url': (
'https://login.microsoftonline.com/common/v2.0/'
'.well-known/openid-configuration'
),
'scope': 'openid email profile',
'display_name': 'Microsoft 365',
},
'google': {
'env_client_id': 'GOOGLE_CLIENT_ID',
'env_client_secret': 'GOOGLE_CLIENT_SECRET',
'server_metadata_url': (
'https://accounts.google.com/.well-known/openid-configuration'
),
'scope': 'openid email profile',
'display_name': 'Google',
},
}
def is_oauth_provider_enabled(provider: str) -> bool:
"""Return True if the provider has client_id AND client_secret in env."""
cfg = _PROVIDER_CONFIG.get(provider)
if cfg is None:
return False
return bool(os.environ.get(cfg['env_client_id'])) and bool(
os.environ.get(cfg['env_client_secret'])
)
def get_oauth_provider_display_name(provider: str) -> str:
"""User-facing label for the provider (Microsoft 365 / Google)."""
cfg = _PROVIDER_CONFIG.get(provider)
return cfg['display_name'] if cfg else provider
def init_oauth_providers(app) -> Optional[OAuth]:
"""Register Microsoft + Google OAuth clients. Idempotent — call once at startup.
Returns the OAuth instance, or None if no provider is enabled.
"""
global _oauth
enabled_providers = [p for p in _PROVIDER_CONFIG if is_oauth_provider_enabled(p)]
if not enabled_providers:
return None
if _oauth is None:
_oauth = OAuth(app)
for provider in enabled_providers:
cfg = _PROVIDER_CONFIG[provider]
# Authlib's register() is idempotent for the same name; safe to call
# again if already registered (no-op on duplicate).
try:
_oauth.register(
name=provider,
client_id=os.environ[cfg['env_client_id']],
client_secret=os.environ[cfg['env_client_secret']],
server_metadata_url=cfg['server_metadata_url'],
client_kwargs={'scope': cfg['scope']},
)
except Exception:
# Already-registered — Authlib raises on duplicate. Acceptable
# for idempotent app boot.
pass
app.logger.info(
'OAuth providers initialized: %s', ', '.join(enabled_providers)
)
return _oauth
def get_oauth_client(provider: str):
"""Return the OAuth client for `provider`, or raise if not enabled."""
if _oauth is None or not is_oauth_provider_enabled(provider):
raise RuntimeError(f"OAuth provider {provider!r} is not enabled")
return getattr(_oauth, provider)
def find_user_by_oauth(
provider: str, subject: str, email: Optional[str]
) -> Optional[User]:
"""Lookup an existing user by sso_subject, then email (link path).
Returns:
- User object: known account (login directly).
- None: brand-new account — caller should defer to consent page.
On the email-match path, the OAuth identity is bound to the existing
account on first login. This is safe because the OAuth provider has
already verified the email; we are not granting access to anyone who
couldn't already prove control of the address.
"""
user = User.query.filter_by(sso_subject=subject, sso_provider=provider).first()
if user:
return user
if email:
existing_email_user = User.query.filter_by(email=email.lower().strip()).first()
if existing_email_user:
existing_email_user.sso_provider = provider
existing_email_user.sso_subject = subject
db.session.commit()
return existing_email_user
return None
def create_oauth_user_with_consent(
provider: str,
subject: str,
userinfo: Dict[str, str],
consents: Dict[str, bool],
ip: str,
ua: str,
legal_version: str,
) -> User:
"""Create a new User from OAuth claims AFTER Loi 25 consents are granted.
Used by the ``/auth/oauth/finish-signup`` POST handler — never call from
the OAuth callback (consent capture must precede User row creation per
Loi 25 art. 14).
Always writes 4 ConsentLog rows (one per consent_type), recording
explicit refusal as ``granted=False`` for the audit trail.
"""
from src.models.consent import ConsentLog
from src.auth.sso import generate_unique_username
from sqlalchemy.exc import IntegrityError
email = (userinfo.get('email') or '').lower().strip()
if not email:
raise ValueError('OAuth userinfo missing email')
name = (userinfo.get('name') or '').strip()
if not name:
first = (userinfo.get('given_name') or '').strip()
last = (userinfo.get('family_name') or '').strip()
name = f'{first} {last}'.strip()
preferred_username = email.split('@', 1)[0]
max_attempts = 5
user = None
for attempt in range(max_attempts):
username = generate_unique_username(preferred_username)
user = User(
username=username,
email=email,
password=None,
sso_provider=provider,
sso_subject=subject,
name=name or None,
email_verified=True, # OAuth provider already verified the email
)
db.session.add(user)
try:
db.session.flush()
break
except IntegrityError:
db.session.rollback()
if attempt == max_attempts - 1:
raise
# 4 ConsentLog rows — one per Loi 25 consent_type (granular, art. 14).
for ctype in ('cgu', 'confidentialite', 'marketing', 'analytics'):
db.session.add(ConsentLog(
user_id=user.id,
consent_type=ctype,
version=legal_version,
granted=bool(consents.get(ctype, False)),
ip_address=ip,
user_agent=ua,
))
db.session.commit()
return user

View File

@@ -430,6 +430,75 @@ Si vous n'avez pas demandé de réinitialisation, ignorez ce courriel — votre
return _send_email(user.email, subject, html_body, text_body) return _send_email(user.email, subject, html_body, text_body)
def send_magic_link_email(user, magic_url: str) -> bool:
"""Send a magic-link login email (B-2.4).
Args:
user: User model instance (must have .email; .name preferred for display).
magic_url: Absolute URL to the magic-link consume endpoint.
The token itself is generated by ``src.auth.magic_link.generate_magic_link_token``
and embedded in ``magic_url`` by the caller — this function only renders
+ sends the email. Stateless tokens (no DB column).
Returns True if the email was sent successfully, False otherwise.
"""
if not is_smtp_configured():
logger.warning("Cannot send magic-link email: SMTP not configured")
return False
# Display name preferred over username; fallback chain handles None/empty
# name AND the schema-improbable case where username is also missing.
# HTML body MUST escape user-controlled name to prevent stored XSS;
# text body uses raw string (plaintext has no XSS surface).
raw_display_name = (
(getattr(user, 'name', None) or '').strip()
or user.username
or 'utilisateur'
).strip()
display_name_html = html_escape(raw_display_name)
display_name_text = raw_display_name
subject = "Votre lien de connexion DictIA"
content_html = f"""
<h2 style="color: #060d1a; margin: 0 0 24px 0; font-size: 24px; font-weight: 700;">Votre lien de connexion</h2>
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Bonjour {display_name_html},</p>
<p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;">
Cliquez sur le bouton ci-dessous pour vous connecter à DictIA sans mot de passe. Ce lien est à usage personnel et expire rapidement.
</p>
<div style="text-align: center; margin: 32px 0;">
<a href="{magic_url}" style="display: inline-block; background-color: #0062ff; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Se connecter à DictIA</a>
</div>
<p style="color: #4b5563; font-size: 14px; margin: 24px 0 8px 0;">Ou copiez-collez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; color: #0062ff; font-size: 14px; margin: 0; padding: 12px; background-color: #f7f9fc; border-radius: 6px;">{magic_url}</p>
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e6ebf2;">
<p style="color: #4b5563; font-size: 13px; margin: 0;">
<strong>Ce lien expire dans 15&nbsp;minutes.</strong><br>
Si vous n'avez pas demandé ce lien de connexion, ignorez ce courriel — votre compte reste sécurisé.
</p>
</div>
"""
content_text = f"""Bonjour {display_name_text},
Cliquez sur le lien ci-dessous pour vous connecter à DictIA sans mot de passe :
{magic_url}
Ce lien expire dans 15 minutes.
Si vous n'avez pas demandé ce lien de connexion, ignorez ce courriel — votre compte reste sécurisé."""
html_body, text_body = _get_email_template(content_html, content_text, subject)
return _send_email(user.email, subject, html_body, text_body)
def can_resend_verification(user) -> tuple[bool, Optional[int]]: def can_resend_verification(user) -> tuple[bool, Optional[int]]:
""" """
Check if a verification email can be resent. Check if a verification email can be resent.

View File

@@ -546,6 +546,9 @@
.my-2 { .my-2 {
margin-block: calc(var(--spacing) * 2); margin-block: calc(var(--spacing) * 2);
} }
.my-3 {
margin-block: calc(var(--spacing) * 3);
}
.my-4 { .my-4 {
margin-block: calc(var(--spacing) * 4); margin-block: calc(var(--spacing) * 4);
} }
@@ -1443,9 +1446,6 @@
.border-\[var\(--border-accent\)\] { .border-\[var\(--border-accent\)\] {
border-color: var(--border-accent); border-color: var(--border-accent);
} }
.border-\[var\(--border-danger\)\] {
border-color: var(--border-danger);
}
.border-\[var\(--border-focus\)\] { .border-\[var\(--border-focus\)\] {
border-color: var(--border-focus); border-color: var(--border-focus);
} }

View File

@@ -1,6 +1,6 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
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'], content: ['./templates/marketing/**/*.html', './templates/legal/**/*.html', './templates/billing/**/*.html', './templates/macros/**/*.html', './templates/auth/**/*.html', './templates/register.html', './templates/login.html', './src/marketing/**/*.py', './src/legal/**/*.py', './src/billing/**/*.py'],
darkMode: 'class', darkMode: 'class',
theme: { theme: {
extend: { extend: {

View File

@@ -1,6 +1,6 @@
{% extends 'marketing/base.html' %} {% extends 'marketing/base.html' %}
{% block title %}{% if action == 'password_reset' %}Vérifiez votre courriel — DictIA{% else %}Confirmez votre courriel — DictIA{% endif %}{% endblock %} {% block title %}{% if action == 'password_reset' %}Vérifiez votre courriel — DictIA{% elif action == 'magic_link' %}Lien de connexion envoyé — DictIA{% else %}Confirmez votre courriel — DictIA{% endif %}{% endblock %}
{% block description %}Un courriel vous a été envoyé. Suivez le lien pour activer votre compte DictIA.{% endblock %} {% block description %}Un courriel vous a été envoyé. Suivez le lien pour activer votre compte DictIA.{% endblock %}
{% block content %} {% block content %}
@@ -11,6 +11,7 @@
<h1 id="check-email-title" class="text-2xl font-black text-brand-navy mb-2"> <h1 id="check-email-title" class="text-2xl font-black text-brand-navy mb-2">
{% if action == 'password_reset' %}Vérifiez votre courriel {% if action == 'password_reset' %}Vérifiez votre courriel
{% elif action == 'verification_required' %}Vérification requise {% elif action == 'verification_required' %}Vérification requise
{% elif action == 'magic_link' %}Lien de connexion envoyé
{% else %}Confirmez votre courriel{% endif %} {% else %}Confirmez votre courriel{% endif %}
</h1> </h1>
@@ -19,6 +20,8 @@
Si un compte DictIA existe pour <strong>{{ email }}</strong>, vous recevrez un courriel avec un lien pour réinitialiser votre mot de passe. Le lien expire dans 1&#160;heure. Si un compte DictIA existe pour <strong>{{ email }}</strong>, vous recevrez un courriel avec un lien pour réinitialiser votre mot de passe. Le lien expire dans 1&#160;heure.
{% elif action == 'verification_required' %} {% elif action == 'verification_required' %}
Vérifiez votre boîte de réception à <strong>{{ email }}</strong>. Si vous ne recevez rien, demandez un nouveau courriel ci-dessous. Vérifiez votre boîte de réception à <strong>{{ email }}</strong>. Si vous ne recevez rien, demandez un nouveau courriel ci-dessous.
{% elif action == 'magic_link' %}
Si un compte vérifié existe pour <strong>{{ email }}</strong>, vous recevrez un courriel avec un lien de connexion. Le lien expire dans {{ "15&nbsp;minutes" | safe }}.
{% else %} {% else %}
Nous avons envoyé un lien de vérification à <strong>{{ email }}</strong>. Cliquez dessus pour activer votre compte. Le lien expire dans 24&#160;heures. Nous avons envoyé un lien de vérification à <strong>{{ email }}</strong>. Cliquez dessus pour activer votre compte. Le lien expire dans 24&#160;heures.
{% endif %} {% endif %}

View File

@@ -0,0 +1,50 @@
{% extends 'marketing/base.html' %}
{% block title %}Lien de connexion DictIA{% endblock %}
{% block description %}Recevez un lien magique pour vous connecter à DictIA sans mot de passe.{% endblock %}
{% block content %}
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="magic-title">
<div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
<h1 id="magic-title" class="text-3xl font-black text-brand-navy mb-2">Lien de connexion</h1>
<p class="text-sm text-brand-navy/70 mb-6">{{ "Recevez un lien par courriel pour vous connecter sans mot de passe. Le lien expire dans 15&nbsp;minutes." | safe }}</p>
{% 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-900 border border-red-200
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.magic_link_request') }}" class="space-y-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<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>
<input type="email" id="email" name="email" autocomplete="email" required 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"
placeholder="vous@cabinet.qc.ca">
</div>
<button type="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">
{{ "Recevoir le lien (expire dans 15&nbsp;minutes)" | safe }}
</button>
</form>
<p class="text-xs text-brand-navy/70 mt-4">
Pour des raisons de sécurité, le lien n'est envoyé qu'aux comptes dont le courriel est vérifié. Si vous ne recevez rien, vérifiez vos pourriels (spam).
</p>
<p class="text-center text-sm text-brand-navy/70 mt-6">
<a href="{{ url_for('auth.login') }}" class="grad-text font-semibold hover:underline">&larr; Retour à la connexion</a>
</p>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% extends 'marketing/base.html' %}
{% block title %}Finaliser votre inscription DictIA{% endblock %}
{% block description %}Finalisez votre inscription DictIA — consentements Loi&nbsp;25 requis pour créer votre compte.{% endblock %}
{% block content %}
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="finish-title">
<div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
<h1 id="finish-title" class="text-3xl font-black text-brand-navy mb-2">Finaliser votre inscription</h1>
<p class="text-sm text-brand-navy/70 mb-6">
Vous vous inscrivez via <strong>{{ provider_display or provider | capitalize }}</strong>. Avant de créer votre compte DictIA, nous devons obtenir vos consentements conformément à la {{ "Loi&nbsp;25" | safe }} du Québec.
</p>
{% 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-900 border border-red-200
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{# Pre-filled email from OAuth provider — display only, not editable #}
<div class="bg-brand-bg border border-brand-border rounded-[0.5rem] p-3 mb-6 text-sm">
<p class="text-brand-navy/70 mb-1">Compte fédéré :</p>
<p class="text-brand-navy font-semibold break-all">{{ userinfo.email }}</p>
{% if userinfo.name %}<p class="text-brand-navy/80 text-xs mt-1">{{ userinfo.name }}</p>{% endif %}
</div>
<form method="POST" action="{{ url_for('auth.oauth_finish_signup') }}" class="space-y-4" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# 4 SEPARATE consent checkboxes — Loi 25 art. 14 (consent must be granular, free, informed) #}
<fieldset class="space-y-3 pt-2">
<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">
<input type="checkbox" id="consent_cgu" name="consent_cgu" value="y" required 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>
{% if errors.consent_cgu %}<p class="text-xs text-red-900 mt-1" role="alert">{{ errors.consent_cgu }}</p>{% endif %}
<label for="consent_confidentialite" class="flex items-start gap-2 text-sm text-brand-navy/90">
<input type="checkbox" id="consent_confidentialite" name="consent_confidentialite" value="y" required 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>
{% if errors.consent_confidentialite %}<p class="text-xs text-red-900 mt-1" role="alert">{{ errors.consent_confidentialite }}</p>{% endif %}
<label for="consent_marketing" class="flex items-start gap-2 text-sm text-brand-navy/90">
<input type="checkbox" id="consent_marketing" name="consent_marketing" value="y"
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">
<input type="checkbox" id="consent_analytics" name="consent_analytics" value="y"
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>
<button type="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">
Créer mon compte DictIA
</button>
</form>
<p class="text-center text-sm text-brand-navy/70 mt-6">
Vous voulez utiliser un autre courriel&nbsp;?
<a href="{{ url_for('auth.signup') }}" class="grad-text font-semibold hover:underline">Inscription manuelle</a>
</p>
</div>
</section>
{% endblock %}

View File

@@ -1,178 +1,120 @@
<!DOCTYPE html> {% extends 'marketing/base.html' %}
<html lang="fr">
<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 %}Connexion — DictIA{% endblock %}
{% include 'includes/loading_overlay.html' %} {% block description %}Connectez-vous à votre compte DictIA. Microsoft 365, Google, lien magique ou mot de passe.{% 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="login-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="login-title" class="text-3xl font-black text-brand-navy mb-2">Connexion</h1>
if (!document.documentElement) return; <p class="text-sm text-brand-navy/70 mb-6">{{ "Bienvenue sur DictIA — la transcription IA conforme à la Loi&nbsp;25." | 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-900 border border-red-200
} else { {% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
document.documentElement.classList.remove('dark'); {% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
} {% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
// Apply color scheme {# OAuth providers (Microsoft 365 + Google) — rendered only if env-enabled #}
const savedScheme = localStorage.getItem('colorScheme') || 'blue'; {% if oauth_microsoft_enabled or oauth_google_enabled or sso_enabled %}
const isDark = document.documentElement.classList.contains('dark'); <div class="space-y-3 mb-6" aria-label="Connexion fédérée">
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-'; {% if oauth_microsoft_enabled %}
<a href="{{ url_for('auth.oauth_provider_login', provider='microsoft') }}"
// Remove all other theme classes class="w-full inline-flex items-center justify-center gap-3 px-4 py-3 bg-white border border-brand-border rounded-[0.75rem] text-brand-navy font-semibold hover:bg-brand-bg transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal']; {# Official Microsoft 4-square logo #}
themeClasses.forEach(theme => { <svg width="20" height="20" viewBox="0 0 21 21" aria-hidden="true" focusable="false">
document.documentElement.classList.remove(`theme-light-${theme}`); <rect x="1" y="1" width="9" height="9" fill="#F25022"/>
document.documentElement.classList.remove(`theme-dark-${theme}`); <rect x="11" y="1" width="9" height="9" fill="#7FBA00"/>
}); <rect x="1" y="11" width="9" height="9" fill="#00A4EF"/>
<rect x="11" y="11" width="9" height="9" fill="#FFB900"/>
</svg>
<span>Continuer avec Microsoft 365</span>
</a>
{% endif %}
// Add the correct theme class {% if oauth_google_enabled %}
if (savedScheme !== 'blue') { <a href="{{ url_for('auth.oauth_provider_login', provider='google') }}"
document.documentElement.classList.add(themePrefix + savedScheme); class="w-full inline-flex items-center justify-center gap-3 px-4 py-3 bg-white border border-brand-border rounded-[0.75rem] text-brand-navy font-semibold hover:bg-brand-bg transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
} {# Official Google "G" logo #}
} <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
applyTheme(); <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.76h3.56c2.08-1.92 3.28-4.74 3.28-8.09Z"/>
</script> <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.56-2.76c-.99.66-2.25 1.06-3.72 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z"/>
</head> <path fill="#FBBC05" d="M5.84 14.11A6.6 6.6 0 0 1 5.5 12c0-.73.13-1.44.34-2.11V7.05H2.18a11 11 0 0 0 0 9.9l3.66-2.84Z"/>
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]"> <path fill="#EA4335" d="M12 5.38c1.62 0 3.07.56 4.21 1.64l3.16-3.16C17.46 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.05l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38Z"/>
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen"> </svg>
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]"> <span>Continuer avec Google</span>
<h1 class="text-3xl font-bold text-[var(--text-primary)]"> </a>
<a href="{{ url_for('recordings.index') }}" class="flex items-center"> {% endif %}
<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"> {% if sso_enabled %}
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]"> <a href="{{ url_for('auth.sso_login') }}"
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6 text-center">Connexion</h2> class="w-full inline-flex items-center justify-center gap-3 px-4 py-3 bg-white border border-brand-border rounded-[0.75rem] text-brand-navy font-semibold hover:bg-brand-bg transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
{% with messages = get_flashed_messages(with_categories=true) %} <rect x="3" y="11" width="18" height="11" rx="2"/>
{% if messages %} <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
{% for category, message in messages %} </svg>
<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 %}"> <span>Se connecter avec {{ sso_provider_name }}</span>
{{ message }} </a>
</div> {% endif %}
{% endfor %}
{% endif %}
{% endwith %}
{% if sso_enabled %}
<div class="flex flex-col space-y-3 {% if not password_login_disabled %}mb-6{% endif %}">
<a href="{{ url_for('auth.sso_login') }}" class="w-full inline-flex items-center justify-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
<i class="fas fa-cloud mr-2"></i> Se connecter avec {{ sso_provider_name }}
</a>
{% if not password_login_disabled %}
<div class="flex items-center text-xs text-[var(--text-muted)]">
<span class="flex-grow border-t border-[var(--border-secondary)]"></span>
<span class="mx-3 uppercase tracking-wide">ou</span>
<span class="flex-grow border-t border-[var(--border-secondary)]"></span>
</div>
{% endif %}
</div>
{% endif %}
{% if password_login_disabled %} {% if not password_login_disabled %}
<div class="mt-4 text-center"> <div class="flex items-center text-xs uppercase tracking-wide text-brand-navy/60 my-3">
<button type="button" onclick="document.getElementById('admin-login-form').classList.toggle('hidden')" class="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)]"> <span class="flex-grow border-t border-brand-border"></span>
<i class="fas fa-lock mr-1"></i> Connexion administrateur <span class="mx-3">ou</span>
</button> <span class="flex-grow border-t border-brand-border"></span>
</div> </div>
<form id="admin-login-form" method="POST" action="{{ url_for('auth.login') }}" class="hidden mt-4"> {% endif %}
{{ form.hidden_tag() }} </div>
<div class="mb-4"> {% endif %}
{{ 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)]", placeholder="Email administrateur") }}
</div>
<div class="mb-4">
{{ 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)]", placeholder="Mot de passe") }}
</div>
<button type="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)]">
Se connecter
</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('auth.login') }}">
{{ form.hidden_tag() }}
<div class="mb-4"> {% if not password_login_disabled %}
{{ form.email.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} <form method="POST" action="{{ url_for('auth.login') }}" class="space-y-4" novalidate>
{% if form.email.errors %} {{ form.hidden_tag() }}
{{ 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"> <div>
{{ form.password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }} <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>
{% if form.password.errors %} {{ 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'}) }}
{{ 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)]") }} {% if form.email.errors %}<p class="text-xs text-red-700 mt-1" role="alert">{{ form.email.errors[0] }}</p>{% endif %}
<div class="text-[var(--text-danger)] text-xs mt-1"> </div>
{% 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 %}
</div>
<div class="flex items-center justify-between mb-6"> <div>
<div class="flex items-center"> <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.remember(class="h-4 w-4 text-[var(--text-accent)] focus:ring-[var(--ring-focus)] border-[var(--border-secondary)] rounded") }} {{ form.password(id='password', autocomplete='current-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'}) }}
{{ form.remember.label(class="ml-2 block text-sm text-[var(--text-secondary)]") }} {% if form.password.errors %}<p class="text-xs text-red-700 mt-1" role="alert">{{ form.password.errors[0] }}</p>{% endif %}
</div> </div>
<a href="{{ url_for('auth.forgot_password') }}" class="text-sm text-[var(--text-accent)] hover:underline">Mot de passe oublié ?</a>
</div>
<div class="flex flex-col space-y-4"> <div class="flex items-center justify-between text-sm">
{{ 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="remember" class="flex items-center gap-2 text-brand-navy/90 cursor-pointer">
{{ form.remember(id='remember', **{'class':'focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
<span>Se souvenir de moi</span>
</label>
<a href="{{ url_for('auth.forgot_password') }}" class="grad-text font-semibold hover:underline">Mot de passe oublié&nbsp;?</a>
</div>
<div class="text-center text-sm text-[var(--text-muted)]"> <button type="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">
<span>Pas encore de compte ?</span> Se connecter
<a href="{{ url_for('auth.register') }}" class="font-medium text-[var(--text-accent)] hover:underline">S'inscrire</a> </button>
</div> </form>
</div>
</form>
{% endif %}
</div>
</main>
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]"> <p class="text-center text-sm mt-4">
<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> <a href="{{ url_for('auth.magic_link_request') }}" class="grad-text font-semibold hover:underline">
</footer> {{ "Recevoir un lien de connexion par courriel (sans mot de passe)" | safe }}
</div> </a>
</p>
{% endif %}
<script> <p class="text-center text-sm text-brand-navy/70 mt-6">
// Hide loading overlay when page is ready Pas encore de compte&nbsp;?
document.addEventListener('DOMContentLoaded', function() { <a href="{{ url_for('auth.signup') }}" class="grad-text font-semibold hover:underline">Créer un compte</a>
if (window.AppLoader) { </p>
AppLoader.waitForReady(); </div>
} </section>
}); {% endblock %}
</script>
</body>
</html>

View File

@@ -0,0 +1,81 @@
"""Windows manual driver for tests/test_oauth_magic_link.py.
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
before src.app gets imported, then run each test_* function and report.
Run from the repo root:
py -3 tests/_run_oauth_magic_link_windows.py
This script is local-dev only (not picked up by pytest collection).
"""
import os
import sys
import types
import traceback
# 1) Stub fcntl BEFORE any import of src.* happens.
if 'fcntl' not in sys.modules:
fcntl_stub = types.ModuleType('fcntl')
fcntl_stub.LOCK_EX = 2
fcntl_stub.LOCK_NB = 4
fcntl_stub.LOCK_UN = 8
fcntl_stub.LOCK_SH = 1
fcntl_stub.flock = lambda *_args, **_kw: None
fcntl_stub.fcntl = lambda *_args, **_kw: 0
sys.modules['fcntl'] = fcntl_stub
# 2) Make repo root importable
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, REPO)
# 3) Set test config
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
os.environ.setdefault('SECRET_KEY', 'test-secret-key-oauth')
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
# Pre-set OAuth env vars so init_oauth_providers registers clients at app boot.
os.environ.setdefault('MS_CLIENT_ID', 'test-ms-client-id')
os.environ.setdefault('MS_CLIENT_SECRET', 'test-ms-client-secret')
os.environ.setdefault('GOOGLE_CLIENT_ID', 'test-google-client-id')
os.environ.setdefault('GOOGLE_CLIENT_SECRET', 'test-google-client-secret')
# Force UTF-8 stdout so src.app's emoji prints don't crash on cp1252 Windows.
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
# 4) Import the test module and run every test_* function it defines
import importlib.util # noqa: E402
spec = importlib.util.spec_from_file_location(
'test_oauth_magic_link',
os.path.join(HERE, 'test_oauth_magic_link.py'),
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
tests = [(name, fn) for name, fn in vars(mod).items()
if name.startswith('test_') and callable(fn)]
passed = 0
failed = []
for name, fn in tests:
try:
fn()
print(f' PASS {name}')
passed += 1
except Exception as e: # noqa: BLE001
print(f' FAIL {name}: {type(e).__name__}: {e}')
failed.append((name, traceback.format_exc()))
total = len(tests)
print()
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
if failed:
print('\n--- Failures ---\n')
for name, tb in failed:
print(f'### {name}\n{tb}\n')
sys.exit(0 if not failed else 1)

View File

@@ -0,0 +1,614 @@
"""Tests for B-2.4 — OAuth Microsoft 365 + Google + magic-link login.
Covers:
- is_oauth_provider_enabled() reads MS/GOOGLE env vars correctly.
- /auth/oauth/<provider>/login redirects to /login when provider disabled.
- /auth/oauth/<provider>/callback for new user → /auth/oauth/finish-signup
with session['oauth_signup_pending'] populated.
- /auth/oauth/<provider>/callback for existing user (sso_subject) → logs in.
- /auth/oauth/<provider>/callback for existing user (email match) → links.
- /auth/oauth/finish-signup requires CGU + confidentialité consents.
- /auth/oauth/finish-signup creates User + 4 ConsentLog rows on success.
- /auth/magic-link returns generic flash for unknown email (anti-enumeration).
- /auth/magic-link sends email for known verified user.
- /auth/magic-link skips unverified users silently (anti-enumeration).
- /auth/magic-link/<token> logs in user with valid token.
- /auth/magic-link/<token> rejects expired tokens.
- /auth/magic-link/<token> rejects tampered tokens.
- /auth/magic-link/<token> rejects unverified users.
Note: pytest cannot collect this file on Windows native because src/init_db.py
imports `fcntl` (POSIX-only). Use tests/_run_oauth_magic_link_windows.py.
"""
import os
import sys
from unittest.mock import patch, MagicMock
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-oauth')
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
# Pre-set OAuth env vars so init_oauth_providers registers clients at app boot.
os.environ.setdefault('MS_CLIENT_ID', 'test-ms-client-id')
os.environ.setdefault('MS_CLIENT_SECRET', 'test-ms-client-secret')
os.environ.setdefault('GOOGLE_CLIENT_ID', 'test-google-client-id')
os.environ.setdefault('GOOGLE_CLIENT_SECRET', 'test-google-client-secret')
from src.app import app, db # noqa: E402
from src.models.user import User # noqa: E402
from src.models.consent import ConsentLog # noqa: E402
def _disable_csrf():
app.config['WTF_CSRF_ENABLED'] = False
def _set_oauth_env():
"""Ensure OAuth env vars are set (some tests clear them)."""
os.environ['MS_CLIENT_ID'] = 'test-ms-client-id'
os.environ['MS_CLIENT_SECRET'] = 'test-ms-client-secret'
os.environ['GOOGLE_CLIENT_ID'] = 'test-google-client-id'
os.environ['GOOGLE_CLIENT_SECRET'] = 'test-google-client-secret'
def _clear_oauth_env():
for k in ('MS_CLIENT_ID', 'MS_CLIENT_SECRET', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'):
os.environ.pop(k, None)
# ----------------------------------------------------------------------
# 1. is_oauth_provider_enabled — env var detection
# ----------------------------------------------------------------------
def test_oauth_provider_enabled_when_env_vars_present():
"""Microsoft is enabled when MS_CLIENT_ID + MS_CLIENT_SECRET are both set."""
_set_oauth_env()
from src.auth.oauth_providers import is_oauth_provider_enabled
assert is_oauth_provider_enabled('microsoft') is True
assert is_oauth_provider_enabled('google') is True
def test_oauth_provider_disabled_when_env_vars_missing():
"""Provider is disabled if either CLIENT_ID or CLIENT_SECRET is missing."""
_clear_oauth_env()
try:
from src.auth.oauth_providers import is_oauth_provider_enabled
assert is_oauth_provider_enabled('microsoft') is False
assert is_oauth_provider_enabled('google') is False
assert is_oauth_provider_enabled('unknown') is False
finally:
_set_oauth_env()
# ----------------------------------------------------------------------
# 2. /auth/oauth/<provider>/login — disabled provider
# ----------------------------------------------------------------------
def test_oauth_login_redirects_when_provider_disabled():
"""GET /auth/oauth/microsoft/login without env vars → 302 to /login + French flash."""
with app.app_context():
_disable_csrf()
_clear_oauth_env()
db.create_all()
try:
client = app.test_client()
resp = client.get('/auth/oauth/microsoft/login', follow_redirects=False)
assert resp.status_code == 302
assert '/login' in resp.headers['Location']
# Flash assertion — follow redirect and inspect body
with client.session_transaction() as sess:
flashes = sess.get('_flashes', [])
assert any("n'est pas activée" in msg for _cat, msg in flashes), (
f'expected French flash about provider disabled, got: {flashes}'
)
finally:
db.session.rollback()
db.drop_all()
_set_oauth_env()
# ----------------------------------------------------------------------
# 3. /auth/oauth/<provider>/callback — new user → finish-signup
# ----------------------------------------------------------------------
def test_oauth_callback_creates_session_for_new_user():
"""Callback for a NEW user stores userinfo in session + redirects to finish-signup."""
with app.app_context():
_disable_csrf()
_set_oauth_env()
db.create_all()
try:
mock_token = {
'userinfo': {
'sub': 'ms-sub-new-123',
'email': 'newuser@example.qc.ca',
'name': 'New User',
'given_name': 'New',
'family_name': 'User',
}
}
with patch('src.api.auth.get_oauth_client') as mock_get_client:
client_mock = MagicMock()
client_mock.authorize_access_token.return_value = mock_token
mock_get_client.return_value = client_mock
with app.test_client() as test_client:
resp = test_client.get('/auth/oauth/microsoft/callback')
assert resp.status_code == 302, f'Expected 302, got {resp.status_code} body={resp.data[:200]!r}'
assert '/auth/oauth/finish-signup' in resp.headers['Location']
with test_client.session_transaction() as sess:
assert 'oauth_signup_pending' in sess
pending = sess['oauth_signup_pending']
assert pending['provider'] == 'microsoft'
assert pending['subject'] == 'ms-sub-new-123'
assert pending['userinfo']['email'] == 'newuser@example.qc.ca'
assert pending['userinfo']['name'] == 'New User'
# No User row created yet
assert User.query.filter_by(email='newuser@example.qc.ca').first() is None
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 4. /auth/oauth/<provider>/callback — existing user by sso_subject
# ----------------------------------------------------------------------
def test_oauth_callback_logs_in_existing_user_by_subject():
"""User with matching sso_subject is logged in directly without consent flow."""
with app.app_context():
_disable_csrf()
_set_oauth_env()
db.create_all()
try:
existing = User(
username='existing1',
email='existing@example.qc.ca',
password=None,
sso_provider='microsoft',
sso_subject='ms-sub-existing-456',
email_verified=True,
)
db.session.add(existing)
db.session.commit()
mock_token = {
'userinfo': {
'sub': 'ms-sub-existing-456',
'email': 'existing@example.qc.ca',
'name': 'Existing User',
}
}
with patch('src.api.auth.get_oauth_client') as mock_get_client:
client_mock = MagicMock()
client_mock.authorize_access_token.return_value = mock_token
mock_get_client.return_value = client_mock
with app.test_client() as test_client:
resp = test_client.get('/auth/oauth/microsoft/callback')
assert resp.status_code == 302
assert '/auth/oauth/finish-signup' not in resp.headers['Location']
with test_client.session_transaction() as sess:
# User is logged in
assert '_user_id' in sess
assert sess['_user_id'] == str(existing.id)
assert 'oauth_signup_pending' not in sess
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 5. /auth/oauth/<provider>/callback — existing user by email
# ----------------------------------------------------------------------
def test_oauth_callback_links_existing_user_by_email():
"""User with matching email but no sso_subject gets linked + logged in."""
with app.app_context():
_disable_csrf()
_set_oauth_env()
db.create_all()
try:
existing = User(
username='emaillink',
email='emaillink@example.qc.ca',
password='$2b$12$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMN',
email_verified=True,
)
db.session.add(existing)
db.session.commit()
existing_id = existing.id
mock_token = {
'userinfo': {
'sub': 'google-new-sub-789',
'email': 'emaillink@example.qc.ca',
'name': 'Email Link User',
}
}
with patch('src.api.auth.get_oauth_client') as mock_get_client:
client_mock = MagicMock()
client_mock.authorize_access_token.return_value = mock_token
mock_get_client.return_value = client_mock
with app.test_client() as test_client:
resp = test_client.get('/auth/oauth/google/callback')
assert resp.status_code == 302
# Refresh to read updated columns
refreshed = db.session.get(User, existing_id)
assert refreshed.sso_subject == 'google-new-sub-789'
assert refreshed.sso_provider == 'google'
with test_client.session_transaction() as sess:
assert sess.get('_user_id') == str(existing_id)
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 6. /auth/oauth/finish-signup — required consents
# ----------------------------------------------------------------------
def test_finish_signup_requires_cgu_and_confidentialite():
"""POST without CGU+confidentialite returns 400; no User created; session preserved."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
with app.test_client() as client:
with client.session_transaction() as sess:
sess['oauth_signup_pending'] = {
'provider': 'microsoft',
'subject': 'ms-sub-pending-001',
'userinfo': {
'email': 'pending@example.qc.ca',
'name': 'Pending User',
'given_name': 'Pending',
'family_name': 'User',
},
}
resp = client.post('/auth/oauth/finish-signup', data={
# No consents
})
assert resp.status_code == 400
body = resp.data.decode('utf-8').lower()
assert "conditions d'utilisation" in body or 'cgu' in body
assert 'confidentialit' in body
assert User.query.filter_by(email='pending@example.qc.ca').first() is None
# Session still has the pending entry (so user can retry)
with client.session_transaction() as sess:
assert 'oauth_signup_pending' in sess
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 7. /auth/oauth/finish-signup — success path
# ----------------------------------------------------------------------
def test_finish_signup_creates_user_and_4_consent_logs():
"""POST with all consents → User created with 4 ConsentLog rows + login."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
with app.test_client() as client:
with client.session_transaction() as sess:
sess['oauth_signup_pending'] = {
'provider': 'google',
'subject': 'google-sub-success-002',
'userinfo': {
'email': 'success@example.qc.ca',
'name': 'Success User',
'given_name': 'Success',
'family_name': 'User',
},
}
resp = client.post('/auth/oauth/finish-signup', data={
'consent_cgu': 'y',
'consent_confidentialite': 'y',
'consent_marketing': 'y',
# analytics absent → recorded as False
}, headers={'CF-Connecting-IP': '198.51.100.7', 'User-Agent': 'TestUA/1.0'})
assert resp.status_code == 302
user = User.query.filter_by(email='success@example.qc.ca').first()
assert user is not None
assert user.sso_provider == 'google'
assert user.sso_subject == 'google-sub-success-002'
assert user.password is None
assert user.email_verified is True
assert user.name == 'Success User'
consents = ConsentLog.query.filter_by(user_id=user.id).all()
assert len(consents) == 4
state = {c.consent_type: c.granted for c in consents}
assert state == {
'cgu': True,
'confidentialite': True,
'marketing': True,
'analytics': False,
}
assert all(c.ip_address == '198.51.100.7' for c in consents)
assert all(c.user_agent == 'TestUA/1.0' for c in consents)
# Session cleaned + logged in
with client.session_transaction() as sess:
assert 'oauth_signup_pending' not in sess
assert sess.get('_user_id') == str(user.id)
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 8. /auth/magic-link — generic flash for unknown email
# ----------------------------------------------------------------------
def test_magic_link_request_returns_generic_message_for_unknown_email():
"""Unknown email → generic flash; no email sent; no error leak."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
with patch('src.services.email._send_email') as mock_send:
with app.test_client() as client:
resp = client.post('/auth/magic-link', data={'email': 'doesnotexist@example.qc.ca'})
assert resp.status_code in (200, 302)
assert mock_send.call_count == 0
body = resp.data.decode('utf-8').lower()
# Generic French message — no leak
assert 'doesnotexist@example.qc.ca' in body # display the email
# No leak language like "introuvable" / "n'existe pas"
assert 'introuvable' not in body
assert "n'existe pas" not in body
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 9. /auth/magic-link — sends email for known verified user
# ----------------------------------------------------------------------
def test_magic_link_request_sends_email_for_known_verified_user():
"""Known + verified user triggers _send_email exactly once with the magic URL."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
user = User(
username='magicverified',
email='magic@example.qc.ca',
password='$2b$12$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMN',
email_verified=True,
)
db.session.add(user)
db.session.commit()
# Force SMTP-configured branch for the test (both the route gate AND
# the inner gate inside src.services.email.send_magic_link_email).
with patch('src.api.auth.is_smtp_configured', return_value=True), \
patch('src.services.email.is_smtp_configured', return_value=True), \
patch('src.services.email._send_email') as mock_send:
mock_send.return_value = True
with app.test_client() as client:
resp = client.post('/auth/magic-link', data={'email': 'magic@example.qc.ca'})
assert resp.status_code in (200, 302)
assert mock_send.call_count == 1
# Email body should contain the magic URL
args, _kwargs = mock_send.call_args
# _send_email(to_email, subject, html_body, text_body)
to_email, subject, html_body, text_body = args
assert to_email == 'magic@example.qc.ca'
assert 'DictIA' in subject
assert '/auth/magic-link/' in html_body
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 10. /auth/magic-link — skips unverified users silently
# ----------------------------------------------------------------------
def test_magic_link_request_skips_unverified_user_silently():
"""Unverified user → email NOT sent, but flash STILL generic (anti-enumeration)."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
user = User(
username='magicunverif',
email='unverif@example.qc.ca',
password='$2b$12$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMN',
email_verified=False,
)
db.session.add(user)
db.session.commit()
with patch('src.api.auth.is_smtp_configured', return_value=True), \
patch('src.services.email._send_email') as mock_send:
with app.test_client() as client:
resp = client.post('/auth/magic-link', data={'email': 'unverif@example.qc.ca'})
assert resp.status_code in (200, 302)
assert mock_send.call_count == 0
body = resp.data.decode('utf-8').lower()
# No leak: same message as for unknown emails
assert 'non vérifié' not in body
assert 'unverified' not in body
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 11. /auth/magic-link/<token> — valid token logs in
# ----------------------------------------------------------------------
def test_magic_link_consume_logs_in_user_with_valid_token():
"""Valid token → user logged in + redirect to recordings.index."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
user = User(
username='consumeok',
email='consumeok@example.qc.ca',
password='$2b$12$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMN',
email_verified=True,
)
db.session.add(user)
db.session.commit()
from src.auth.magic_link import generate_magic_link_token
token = generate_magic_link_token(user.id)
with app.test_client() as client:
resp = client.get(f'/auth/magic-link/{token}', follow_redirects=False)
assert resp.status_code == 302
assert '/auth/magic-link' not in resp.headers['Location']
with client.session_transaction() as sess:
assert sess.get('_user_id') == str(user.id)
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 12. /auth/magic-link/<token> — expired token rejected
# ----------------------------------------------------------------------
def test_magic_link_consume_rejects_expired_token():
"""Expired token → invalid flash + redirect to /auth/magic-link."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
user = User(
username='expired',
email='expired@example.qc.ca',
password='x' * 60,
email_verified=True,
)
db.session.add(user)
db.session.commit()
from itsdangerous import SignatureExpired
with patch('src.auth.magic_link._serializer') as mock_ser:
inst = MagicMock()
inst.loads.side_effect = SignatureExpired('expired')
mock_ser.return_value = inst
with app.test_client() as client:
resp = client.get('/auth/magic-link/dummy-token', follow_redirects=False)
assert resp.status_code == 302
assert '/auth/magic-link' in resp.headers['Location']
with client.session_transaction() as sess:
flashes = sess.get('_flashes', [])
assert any('invalide' in msg.lower() or 'expir' in msg.lower()
for _cat, msg in flashes), f'expected expiry flash, got {flashes}'
assert sess.get('_user_id') is None
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 13. /auth/magic-link/<token> — tampered token rejected
# ----------------------------------------------------------------------
def test_magic_link_consume_rejects_invalid_signature():
"""Garbage token → BadSignature → invalid flash + redirect."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
with app.test_client() as client:
resp = client.get('/auth/magic-link/garbage.token.value', follow_redirects=False)
assert resp.status_code == 302
assert '/auth/magic-link' in resp.headers['Location']
with client.session_transaction() as sess:
flashes = sess.get('_flashes', [])
assert any('invalide' in msg.lower() or 'expir' in msg.lower()
for _cat, msg in flashes), f'expected invalid flash, got {flashes}'
assert sess.get('_user_id') is None
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 14. /auth/magic-link/<token> — unverified user rejected
# ----------------------------------------------------------------------
def test_magic_link_consume_rejects_unverified_user():
"""Token for unverified user → invalid flash (no leak that token was valid)."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
user = User(
username='unverifconsume',
email='unverifconsume@example.qc.ca',
password='x' * 60,
email_verified=False,
)
db.session.add(user)
db.session.commit()
from src.auth.magic_link import generate_magic_link_token
token = generate_magic_link_token(user.id)
with app.test_client() as client:
resp = client.get(f'/auth/magic-link/{token}', follow_redirects=False)
assert resp.status_code == 302
assert '/auth/magic-link' in resp.headers['Location']
with client.session_transaction() as sess:
assert sess.get('_user_id') is None
flashes = sess.get('_flashes', [])
# Flash should look the same as the bad-signature path
assert any('invalide' in msg.lower() or 'expir' in msg.lower()
for _cat, msg in flashes)
finally:
db.session.rollback()
db.drop_all()
# ----------------------------------------------------------------------
# 15. send_magic_link_email helper produces French DictIA-branded email
# ----------------------------------------------------------------------
def test_send_magic_link_email_french_branded():
"""send_magic_link_email() produces French + DictIA-branded HTML body."""
with app.app_context():
_disable_csrf()
db.create_all()
try:
user = User(
username='emailtest',
email='emailtest@example.qc.ca',
password='x' * 60,
name='Marie <script>', # XSS canary
email_verified=True,
)
db.session.add(user)
db.session.commit()
with patch('src.services.email.is_smtp_configured', return_value=True), \
patch('src.services.email._send_email') as mock_send:
mock_send.return_value = True
from src.services.email import send_magic_link_email
ok = send_magic_link_email(user, 'https://example.com/auth/magic-link/abc123')
assert ok is True
args, _kwargs = mock_send.call_args
to_email, subject, html_body, text_body = args
assert to_email == 'emailtest@example.qc.ca'
assert 'DictIA' in subject
assert 'connexion' in subject.lower() or 'lien' in subject.lower()
assert 'https://example.com/auth/magic-link/abc123' in html_body
assert 'https://example.com/auth/magic-link/abc123' in text_body
# XSS canary: raw <script> must NOT appear in HTML body (must be escaped)
assert '<script>' not in html_body
assert '&lt;script&gt;' in html_body or 'Marie' in html_body
# 15 minutes mention
assert '15' in html_body
finally:
db.session.rollback()
db.drop_all()