diff --git a/config/env.oauth.example b/config/env.oauth.example new file mode 100644 index 0000000..f24172b --- /dev/null +++ b/config/env.oauth.example @@ -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/) 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 diff --git a/src/api/auth.py b/src/api/auth.py index 14ca778..7d36ada 100644 --- a/src/api/auth.py +++ b/src/api/auth.py @@ -43,11 +43,23 @@ from src.services.email import ( is_smtp_configured, send_verification_email, send_password_reset_email, + send_magic_link_email, verify_email_token, verify_reset_token, can_resend_verification, 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 auth_bp = Blueprint('auth', __name__) @@ -363,14 +375,14 @@ def login(): if user and user.password: # Check if password login is disabled for non-admins 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): # Check email verification if required if is_email_verification_required() and not user.email_verified: # Store user email in session for resend functionality session['unverified_email'] = user.email return render_template('auth/check_email.html', - title='Email Verification Required', + title='Vérification du courriel requise', email=user.email, action='verification_required', show_resend=True) @@ -384,23 +396,25 @@ def login(): else: _email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16] 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: _email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16] 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: _email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16] 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( 'login.html', - title='Login', + title='Connexion', form=form, sso_enabled=sso_enabled, 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')) +# --- B-2.4: Microsoft 365 + Google OAuth + Magic Link --- + +@auth_bp.route('/auth/oauth//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//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/') +@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') @csrf_exempt def logout(): diff --git a/src/app.py b/src/app.py index 023773f..ba9c31b 100644 --- a/src/app.py +++ b/src/app.py @@ -647,6 +647,12 @@ app.register_blueprint(marketing_bp) app.register_blueprint(billing_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 # Startup functions (extracted to src/config/startup.py) diff --git a/src/auth/magic_link.py b/src/auth/magic_link.py new file mode 100644 index 0000000..127bc08 --- /dev/null +++ b/src/auth/magic_link.py @@ -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 diff --git a/src/auth/oauth_providers.py b/src/auth/oauth_providers.py new file mode 100644 index 0000000..9e70124 --- /dev/null +++ b/src/auth/oauth_providers.py @@ -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 diff --git a/src/services/email.py b/src/services/email.py index 621e04f..b3b6a3b 100644 --- a/src/services/email.py +++ b/src/services/email.py @@ -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) +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""" +

Votre lien de connexion

+ +

Bonjour {display_name_html},

+ +

+ Cliquez sur le bouton ci-dessous pour vous connecter à DictIA sans mot de passe. Ce lien est à usage personnel et expire rapidement. +

+ +
+ Se connecter à DictIA +
+ +

Ou copiez-collez ce lien dans votre navigateur :

+

{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é. +

+
+""" + + 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]]: """ Check if a verification email can be resent. diff --git a/static/css/marketing.css b/static/css/marketing.css index f0faa4a..425b054 100644 --- a/static/css/marketing.css +++ b/static/css/marketing.css @@ -546,6 +546,9 @@ .my-2 { margin-block: calc(var(--spacing) * 2); } + .my-3 { + margin-block: calc(var(--spacing) * 3); + } .my-4 { margin-block: calc(var(--spacing) * 4); } @@ -1443,9 +1446,6 @@ .border-\[var\(--border-accent\)\] { border-color: var(--border-accent); } - .border-\[var\(--border-danger\)\] { - border-color: var(--border-danger); - } .border-\[var\(--border-focus\)\] { border-color: var(--border-focus); } diff --git a/static/css/tailwind.config.js b/static/css/tailwind.config.js index c3bf169..b75a09d 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', './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', theme: { extend: { diff --git a/templates/auth/check_email.html b/templates/auth/check_email.html index d1e3ae0..5c5fcdd 100644 --- a/templates/auth/check_email.html +++ b/templates/auth/check_email.html @@ -1,6 +1,6 @@ {% 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 content %} @@ -11,6 +11,7 @@

{% if action == 'password_reset' %}Vérifiez votre courriel {% elif action == 'verification_required' %}Vérification requise + {% elif action == 'magic_link' %}Lien de connexion envoyé {% else %}Confirmez votre courriel{% endif %}

@@ -19,6 +20,8 @@ Si un compte DictIA existe pour {{ email }}, vous recevrez un courriel avec un lien pour réinitialiser votre mot de passe. Le lien expire dans 1 heure. {% elif action == 'verification_required' %} Vérifiez votre boîte de réception à {{ email }}. Si vous ne recevez rien, demandez un nouveau courriel ci-dessous. + {% elif action == 'magic_link' %} + Si un compte vérifié existe pour {{ email }}, vous recevrez un courriel avec un lien de connexion. Le lien expire dans {{ "15 minutes" | safe }}. {% else %} Nous avons envoyé un lien de vérification à {{ email }}. Cliquez dessus pour activer votre compte. Le lien expire dans 24 heures. {% endif %} diff --git a/templates/auth/magic_link_request.html b/templates/auth/magic_link_request.html new file mode 100644 index 0000000..266620e --- /dev/null +++ b/templates/auth/magic_link_request.html @@ -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 %} +
+
+

Lien de connexion

+

{{ "Recevez un lien par courriel pour vous connecter sans mot de passe. Le lien expire dans 15 minutes." | safe }}

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+ + +
+ + +
+ + +
+ +

+ 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). +

+ +

+ ← Retour à la connexion +

+
+
+{% endblock %} diff --git a/templates/auth/oauth_finish_signup.html b/templates/auth/oauth_finish_signup.html new file mode 100644 index 0000000..3a4276f --- /dev/null +++ b/templates/auth/oauth_finish_signup.html @@ -0,0 +1,80 @@ +{% extends 'marketing/base.html' %} + +{% block title %}Finaliser votre inscription DictIA{% endblock %} +{% block description %}Finalisez votre inscription DictIA — consentements Loi 25 requis pour créer votre compte.{% endblock %} + +{% block content %} +
+
+

Finaliser votre inscription

+

+ Vous vous inscrivez via {{ provider_display or provider | capitalize }}. Avant de créer votre compte DictIA, nous devons obtenir vos consentements conformément à la {{ "Loi 25" | safe }} du Québec. +

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {# Pre-filled email from OAuth provider — display only, not editable #} +
+

Compte fédéré :

+

{{ userinfo.email }}

+ {% if userinfo.name %}

{{ userinfo.name }}

{% endif %} +
+ +
+ + + {# 4 SEPARATE consent checkboxes — Loi 25 art. 14 (consent must be granular, free, informed) #} +
+ {{ "Consentements — Loi 25" | safe }} + + + {% if errors.consent_cgu %}{% endif %} + + + {% if errors.consent_confidentialite %}{% endif %} + + + + +
+ + +
+ +

+ Vous voulez utiliser un autre courriel ? + Inscription manuelle +

+
+
+{% endblock %} diff --git a/templates/login.html b/templates/login.html index a7084f7..6bd095d 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,178 +1,120 @@ - - - - - - - {{ title }} - DictIA - - - - - - +{% extends 'marketing/base.html' %} - - {% include 'includes/loading_overlay.html' %} +{% block title %}Connexion — DictIA{% endblock %} +{% block description %}Connectez-vous à votre compte DictIA. Microsoft 365, Google, lien magique ou mot de passe.{% endblock %} - - - -
-
-

- - DictIA - DictIA - -

-
+ {% if oauth_google_enabled %} + + {# Official Google "G" logo #} + + Continuer avec Google + + {% endif %} -
-
-

Connexion

- - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
- {{ message }} -
- {% endfor %} - {% endif %} - {% endwith %} - - {% if sso_enabled %} - - {% endif %} + {% if sso_enabled %} + + + Se connecter avec {{ sso_provider_name }} + + {% endif %} - {% if password_login_disabled %} -
- -
- - {% else %} -
- {{ form.hidden_tag() }} + {% if not password_login_disabled %} +
+ + ou + +
+ {% endif %} +
+ {% endif %} -
- {{ 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)]") }} -
- {% for error in form.email.errors %} - {{ error }} - {% endfor %} -
- {% 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 %} -
+ {% if not password_login_disabled %} + + {{ form.hidden_tag() }} -
- {{ 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)]") }} -
- {% for error in form.password.errors %} - {{ error }} - {% endfor %} -
- {% 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 %} -
+
+ + {{ 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 %}{% endif %} +
-
-
- {{ form.remember(class="h-4 w-4 text-[var(--text-accent)] focus:ring-[var(--ring-focus)] border-[var(--border-secondary)] rounded") }} - {{ form.remember.label(class="ml-2 block text-sm text-[var(--text-secondary)]") }} -
- Mot de passe oublié ? -
+
+ + {{ 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'}) }} + {% if form.password.errors %}{% endif %} +
-
- {{ 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)]") }} +
+ + Mot de passe oublié ? +
-
- Pas encore de compte ? - S'inscrire -
-
- - {% endif %} -
- + + - - +

+ + {{ "Recevoir un lien de connexion par courriel (sans mot de passe)" | safe }} + +

+ {% endif %} - - - +

+ Pas encore de compte ? + Créer un compte +

+ + +{% endblock %} diff --git a/tests/_run_oauth_magic_link_windows.py b/tests/_run_oauth_magic_link_windows.py new file mode 100644 index 0000000..df58c00 --- /dev/null +++ b/tests/_run_oauth_magic_link_windows.py @@ -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) diff --git a/tests/test_oauth_magic_link.py b/tests/test_oauth_magic_link.py new file mode 100644 index 0000000..091bfb9 --- /dev/null +++ b/tests/test_oauth_magic_link.py @@ -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//login redirects to /login when provider disabled. + - /auth/oauth//callback for new user → /auth/oauth/finish-signup + with session['oauth_signup_pending'] populated. + - /auth/oauth//callback for existing user (sso_subject) → logs in. + - /auth/oauth//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/ logs in user with valid token. + - /auth/magic-link/ rejects expired tokens. + - /auth/magic-link/ rejects tampered tokens. + - /auth/magic-link/ 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//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//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//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//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/ — 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/ — 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/ — 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/ — 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