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

View File

@@ -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='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/<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')
@csrf_exempt
def logout():

View File

@@ -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)

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)
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]]:
"""
Check if a verification email can be resent.