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:
260
src/api/auth.py
260
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/<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():
|
||||
|
||||
@@ -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
40
src/auth/magic_link.py
Normal 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
200
src/auth/oauth_providers.py
Normal 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
|
||||
@@ -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 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.
|
||||
|
||||
Reference in New Issue
Block a user