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:
69
config/env.oauth.example
Normal file
69
config/env.oauth.example
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
###############################################################################
|
||||||
|
# OAuth Providers — Microsoft 365 + Google (B-2.4)
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# These providers complement (do NOT replace) the generic OIDC SSO at
|
||||||
|
# config/env.sso.example. Both can be enabled simultaneously: users see
|
||||||
|
# Microsoft 365, Google, and SSO buttons on /login, plus the magic-link
|
||||||
|
# fallback that does not require any OAuth provider.
|
||||||
|
#
|
||||||
|
# IMPORTANT — Loi 25 art. 14 (consent must be granular, free, informed):
|
||||||
|
# OAuth signups still require Loi 25 consent capture via
|
||||||
|
# /auth/oauth/finish-signup BEFORE the User row is created. Existing
|
||||||
|
# users (matched by sso_subject or email) skip the consent page and log
|
||||||
|
# in directly.
|
||||||
|
#
|
||||||
|
# Magic-link login (/auth/magic-link, /auth/magic-link/<token>) reuses
|
||||||
|
# the SMTP settings from env.email.example — no additional env vars needed.
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Microsoft 365 (Microsoft Entra ID, formerly Azure AD)
|
||||||
|
###############################################################################
|
||||||
|
# 1. Register a new app at https://entra.microsoft.com
|
||||||
|
# > Identity > Applications > App registrations > New registration
|
||||||
|
# 2. Set the redirect URI to:
|
||||||
|
# https://your-domain.example/auth/oauth/microsoft/callback
|
||||||
|
# 3. Generate a client secret under Certificates & secrets > Client secrets
|
||||||
|
# 4. Set MS_CLIENT_ID to the Application (client) ID
|
||||||
|
# 5. Set MS_CLIENT_SECRET to the secret VALUE (NOT the secret ID)
|
||||||
|
#
|
||||||
|
# Tenant restriction: by default the OAuth flow accepts users from any
|
||||||
|
# Microsoft tenant (server_metadata_url uses /common/). To restrict to a
|
||||||
|
# specific organization, edit src/auth/oauth_providers.py and replace
|
||||||
|
# /common/ with your tenant ID (e.g. /your-tenant-id-guid/).
|
||||||
|
#
|
||||||
|
# MS_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
|
# MS_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Google (Google Cloud Console)
|
||||||
|
###############################################################################
|
||||||
|
# 1. Create an OAuth client at https://console.cloud.google.com
|
||||||
|
# > APIs & Services > Credentials > Create Credentials > OAuth client ID
|
||||||
|
# Application type: "Web application"
|
||||||
|
# 2. Set the redirect URI to:
|
||||||
|
# https://your-domain.example/auth/oauth/google/callback
|
||||||
|
# 3. Configure the OAuth consent screen in the same console
|
||||||
|
# (must be in "Production" status to accept users outside the test list)
|
||||||
|
# 4. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from the credentials page
|
||||||
|
#
|
||||||
|
# GOOGLE_CLIENT_ID=xxxxxxxxxxxx-xxxxxxxxxxxx.apps.googleusercontent.com
|
||||||
|
# GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Notes
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# Token storage:
|
||||||
|
# - sso_provider stores the literal string "microsoft" or "google"
|
||||||
|
# - sso_subject stores the OAuth `sub` claim (provider-issued user ID)
|
||||||
|
# - email_verified is set to True automatically (the provider has
|
||||||
|
# already verified the email address)
|
||||||
|
# - password is NULL for OAuth-only accounts; users can set a password
|
||||||
|
# later via /forgot-password if they want a fallback login method
|
||||||
|
#
|
||||||
|
# Magic-link tokens:
|
||||||
|
# - Stateless via itsdangerous.URLSafeTimedSerializer
|
||||||
|
# - 15-minute expiry, signed with SECRET_KEY + salt 'magic-link-login'
|
||||||
|
# - No DB column — tokens are not single-use within the 15-min window
|
||||||
|
# - SMTP must be configured (see env.email.example) for the link to send
|
||||||
260
src/api/auth.py
260
src/api/auth.py
@@ -43,11 +43,23 @@ from src.services.email import (
|
|||||||
is_smtp_configured,
|
is_smtp_configured,
|
||||||
send_verification_email,
|
send_verification_email,
|
||||||
send_password_reset_email,
|
send_password_reset_email,
|
||||||
|
send_magic_link_email,
|
||||||
verify_email_token,
|
verify_email_token,
|
||||||
verify_reset_token,
|
verify_reset_token,
|
||||||
can_resend_verification,
|
can_resend_verification,
|
||||||
can_resend_password_reset,
|
can_resend_password_reset,
|
||||||
)
|
)
|
||||||
|
from src.auth.oauth_providers import (
|
||||||
|
is_oauth_provider_enabled,
|
||||||
|
get_oauth_client,
|
||||||
|
find_user_by_oauth,
|
||||||
|
create_oauth_user_with_consent,
|
||||||
|
get_oauth_provider_display_name,
|
||||||
|
)
|
||||||
|
from src.auth.magic_link import (
|
||||||
|
generate_magic_link_token,
|
||||||
|
consume_magic_link_token,
|
||||||
|
)
|
||||||
|
|
||||||
# Create blueprint
|
# Create blueprint
|
||||||
auth_bp = Blueprint('auth', __name__)
|
auth_bp = Blueprint('auth', __name__)
|
||||||
@@ -363,14 +375,14 @@ def login():
|
|||||||
if user and user.password:
|
if user and user.password:
|
||||||
# Check if password login is disabled for non-admins
|
# Check if password login is disabled for non-admins
|
||||||
if password_login_disabled and not user.is_admin:
|
if password_login_disabled and not user.is_admin:
|
||||||
flash('Password login is disabled. Please sign in with SSO.', 'warning')
|
flash('La connexion par mot de passe est désactivée. Utilisez le SSO.', 'warning')
|
||||||
elif bcrypt.check_password_hash(user.password, form.password.data):
|
elif bcrypt.check_password_hash(user.password, form.password.data):
|
||||||
# Check email verification if required
|
# Check email verification if required
|
||||||
if is_email_verification_required() and not user.email_verified:
|
if is_email_verification_required() and not user.email_verified:
|
||||||
# Store user email in session for resend functionality
|
# Store user email in session for resend functionality
|
||||||
session['unverified_email'] = user.email
|
session['unverified_email'] = user.email
|
||||||
return render_template('auth/check_email.html',
|
return render_template('auth/check_email.html',
|
||||||
title='Email Verification Required',
|
title='Vérification du courriel requise',
|
||||||
email=user.email,
|
email=user.email,
|
||||||
action='verification_required',
|
action='verification_required',
|
||||||
show_resend=True)
|
show_resend=True)
|
||||||
@@ -384,23 +396,25 @@ def login():
|
|||||||
else:
|
else:
|
||||||
_email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
|
_email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
|
||||||
audit_failed_login(details={'email_hash': _email_hash, 'reason': 'wrong_password'})
|
audit_failed_login(details={'email_hash': _email_hash, 'reason': 'wrong_password'})
|
||||||
flash('Login unsuccessful. Please check email and password.', 'danger')
|
flash('Connexion impossible. Vérifiez votre courriel et votre mot de passe.', 'danger')
|
||||||
elif user and not user.password:
|
elif user and not user.password:
|
||||||
_email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
|
_email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
|
||||||
audit_failed_login(details={'email_hash': _email_hash, 'reason': 'sso_only_account'})
|
audit_failed_login(details={'email_hash': _email_hash, 'reason': 'sso_only_account'})
|
||||||
flash('This account uses SSO login. Please sign in with SSO.', 'warning')
|
flash("Ce compte utilise une connexion fédérée (SSO ou Microsoft/Google). Utilisez l'un des boutons ci-dessus.", 'warning')
|
||||||
else:
|
else:
|
||||||
_email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
|
_email_hash = hashlib.sha256(form.email.data.lower().encode()).hexdigest()[:16]
|
||||||
audit_failed_login(details={'email_hash': _email_hash, 'reason': 'user_not_found'})
|
audit_failed_login(details={'email_hash': _email_hash, 'reason': 'user_not_found'})
|
||||||
flash('Login unsuccessful. Please check email and password.', 'danger')
|
flash('Connexion impossible. Vérifiez votre courriel et votre mot de passe.', 'danger')
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'login.html',
|
'login.html',
|
||||||
title='Login',
|
title='Connexion',
|
||||||
form=form,
|
form=form,
|
||||||
sso_enabled=sso_enabled,
|
sso_enabled=sso_enabled,
|
||||||
sso_provider_name=sso_config.get('provider_name', 'SSO'),
|
sso_provider_name=sso_config.get('provider_name', 'SSO'),
|
||||||
password_login_disabled=password_login_disabled
|
password_login_disabled=password_login_disabled,
|
||||||
|
oauth_microsoft_enabled=is_oauth_provider_enabled('microsoft'),
|
||||||
|
oauth_google_enabled=is_oauth_provider_enabled('google'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -529,6 +543,238 @@ def sso_unlink():
|
|||||||
return redirect(url_for('auth.account'))
|
return redirect(url_for('auth.account'))
|
||||||
|
|
||||||
|
|
||||||
|
# --- B-2.4: Microsoft 365 + Google OAuth + Magic Link ---
|
||||||
|
|
||||||
|
@auth_bp.route('/auth/oauth/<provider>/login')
|
||||||
|
@rate_limit("10 per minute")
|
||||||
|
def oauth_provider_login(provider):
|
||||||
|
"""Start OAuth flow with the named provider (microsoft|google).
|
||||||
|
|
||||||
|
Disabled providers redirect to /login with a French flash. Authenticated
|
||||||
|
users are redirected to the app to avoid an infinite OAuth bounce.
|
||||||
|
"""
|
||||||
|
if not is_oauth_provider_enabled(provider):
|
||||||
|
flash(
|
||||||
|
f"La connexion via {get_oauth_provider_display_name(provider)} n'est pas activée.",
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('recordings.index'))
|
||||||
|
redirect_uri = url_for(
|
||||||
|
'auth.oauth_provider_callback', provider=provider, _external=True
|
||||||
|
)
|
||||||
|
return get_oauth_client(provider).authorize_redirect(redirect_uri)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/auth/oauth/<provider>/callback')
|
||||||
|
def oauth_provider_callback(provider):
|
||||||
|
"""Receive OAuth callback.
|
||||||
|
|
||||||
|
- Existing user (matched by sso_subject OR email): log in directly.
|
||||||
|
- New user: store userinfo in session and redirect to /auth/oauth/finish-signup
|
||||||
|
for Loi 25 consent capture BEFORE creating the User row.
|
||||||
|
"""
|
||||||
|
if not is_oauth_provider_enabled(provider):
|
||||||
|
flash("Fournisseur OAuth indisponible.", 'danger')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
try:
|
||||||
|
token = get_oauth_client(provider).authorize_access_token()
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.warning('OAuth callback failed for %s: %s', provider, e)
|
||||||
|
flash("La connexion a échoué. Réessayez.", 'danger')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
userinfo = token.get('userinfo') or {}
|
||||||
|
subject = userinfo.get('sub')
|
||||||
|
email = (userinfo.get('email') or '').lower().strip()
|
||||||
|
if not subject or not email:
|
||||||
|
flash(
|
||||||
|
"Le fournisseur n'a pas retourné d'identifiant ou de courriel.",
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
user = find_user_by_oauth(provider, subject, email)
|
||||||
|
if user:
|
||||||
|
login_user(user)
|
||||||
|
audit_sso_login(user.id, details={'provider': provider})
|
||||||
|
return redirect(url_for('recordings.index'))
|
||||||
|
|
||||||
|
# New user — defer creation until Loi 25 consents are captured.
|
||||||
|
session['oauth_signup_pending'] = {
|
||||||
|
'provider': provider,
|
||||||
|
'subject': subject,
|
||||||
|
'userinfo': {
|
||||||
|
'email': email,
|
||||||
|
'name': userinfo.get('name', ''),
|
||||||
|
'given_name': userinfo.get('given_name', ''),
|
||||||
|
'family_name': userinfo.get('family_name', ''),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return redirect(url_for('auth.oauth_finish_signup'))
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/auth/oauth/finish-signup', methods=['GET', 'POST'])
|
||||||
|
@rate_limit("10 per minute")
|
||||||
|
def oauth_finish_signup():
|
||||||
|
"""Capture Loi 25 consents for OAuth signups before creating the User row.
|
||||||
|
|
||||||
|
Falls back to /signup if no pending OAuth session exists. Refuses without
|
||||||
|
CGU + politique de confidentialité (hard-required by Loi 25 art. 14).
|
||||||
|
"""
|
||||||
|
pending = session.get('oauth_signup_pending')
|
||||||
|
if not pending:
|
||||||
|
return redirect(url_for('auth.signup'))
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
session.pop('oauth_signup_pending', None)
|
||||||
|
return redirect(url_for('recordings.index'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
consent_cgu = request.form.get('consent_cgu') == 'y'
|
||||||
|
consent_confidentialite = request.form.get('consent_confidentialite') == 'y'
|
||||||
|
consent_marketing = request.form.get('consent_marketing') == 'y'
|
||||||
|
consent_analytics = request.form.get('consent_analytics') == 'y'
|
||||||
|
|
||||||
|
errors = {}
|
||||||
|
if not consent_cgu:
|
||||||
|
errors['consent_cgu'] = (
|
||||||
|
"Vous devez accepter les conditions d'utilisation pour créer un compte."
|
||||||
|
)
|
||||||
|
if not consent_confidentialite:
|
||||||
|
errors['consent_confidentialite'] = (
|
||||||
|
"Vous devez accepter la politique de confidentialité pour créer un compte."
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
return render_template(
|
||||||
|
'auth/oauth_finish_signup.html',
|
||||||
|
title='Finaliser votre inscription DictIA',
|
||||||
|
provider=pending['provider'],
|
||||||
|
provider_display=get_oauth_provider_display_name(pending['provider']),
|
||||||
|
userinfo=pending['userinfo'],
|
||||||
|
errors=errors,
|
||||||
|
), 400
|
||||||
|
|
||||||
|
consents = {
|
||||||
|
'cgu': consent_cgu,
|
||||||
|
'confidentialite': consent_confidentialite,
|
||||||
|
'marketing': consent_marketing,
|
||||||
|
'analytics': consent_analytics,
|
||||||
|
}
|
||||||
|
ip = _client_ip()
|
||||||
|
ua = (request.headers.get('User-Agent') or '')[:500]
|
||||||
|
try:
|
||||||
|
user = create_oauth_user_with_consent(
|
||||||
|
provider=pending['provider'],
|
||||||
|
subject=pending['subject'],
|
||||||
|
userinfo=pending['userinfo'],
|
||||||
|
consents=consents,
|
||||||
|
ip=ip,
|
||||||
|
ua=ua,
|
||||||
|
legal_version=SIGNUP_LEGAL_VERSION,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
current_app.logger.warning('OAuth signup failed: %s', e)
|
||||||
|
flash(
|
||||||
|
"L'inscription via OAuth a échoué. Réessayez ou utilisez le formulaire d'inscription.",
|
||||||
|
'danger',
|
||||||
|
)
|
||||||
|
session.pop('oauth_signup_pending', None)
|
||||||
|
return redirect(url_for('auth.signup'))
|
||||||
|
|
||||||
|
session.pop('oauth_signup_pending', None)
|
||||||
|
login_user(user)
|
||||||
|
audit_register(user.id)
|
||||||
|
audit_sso_login(user.id, details={'provider': pending['provider']})
|
||||||
|
flash('Bienvenue chez DictIA !', 'success')
|
||||||
|
return redirect(url_for('recordings.index'))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'auth/oauth_finish_signup.html',
|
||||||
|
title='Finaliser votre inscription DictIA',
|
||||||
|
provider=pending['provider'],
|
||||||
|
provider_display=get_oauth_provider_display_name(pending['provider']),
|
||||||
|
userinfo=pending['userinfo'],
|
||||||
|
errors={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/auth/magic-link', methods=['GET', 'POST'])
|
||||||
|
@rate_limit("5 per minute")
|
||||||
|
def magic_link_request():
|
||||||
|
"""Request a magic-link email.
|
||||||
|
|
||||||
|
Anti-enumeration (Loi 25 / OWASP A01): same generic French message
|
||||||
|
whether the email matches a real account or not, and whether the
|
||||||
|
account is verified or not. The email is only sent for known
|
||||||
|
+ verified users with SMTP configured.
|
||||||
|
"""
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('recordings.index'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
email = (request.form.get('email') or '').lower().strip()
|
||||||
|
if not email:
|
||||||
|
flash("L'adresse courriel est requise.", 'danger')
|
||||||
|
return render_template(
|
||||||
|
'auth/magic_link_request.html',
|
||||||
|
title='Lien de connexion DictIA',
|
||||||
|
), 400
|
||||||
|
|
||||||
|
# Anti-enumeration: lookup user but always show the same flash
|
||||||
|
user = User.query.filter_by(email=email).first()
|
||||||
|
if user and user.email_verified and is_smtp_configured():
|
||||||
|
token = generate_magic_link_token(user.id)
|
||||||
|
magic_url = url_for(
|
||||||
|
'auth.magic_link_consume', token=token, _external=True
|
||||||
|
)
|
||||||
|
send_magic_link_email(user, magic_url)
|
||||||
|
|
||||||
|
flash(
|
||||||
|
"Si un compte vérifié existe pour ce courriel, un lien de "
|
||||||
|
"connexion vous a été envoyé. Le lien expire dans 15 minutes.",
|
||||||
|
'info',
|
||||||
|
)
|
||||||
|
return render_template(
|
||||||
|
'auth/check_email.html',
|
||||||
|
title='Vérifiez votre courriel',
|
||||||
|
email=email,
|
||||||
|
action='magic_link',
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'auth/magic_link_request.html',
|
||||||
|
title='Lien de connexion DictIA',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/auth/magic-link/<token>')
|
||||||
|
@rate_limit("10 per minute")
|
||||||
|
def magic_link_consume(token):
|
||||||
|
"""Consume a magic-link token. Logs in the user on success.
|
||||||
|
|
||||||
|
Rejects invalid, expired, and unverified-user tokens with the same
|
||||||
|
generic French flash to avoid leaking which one of those failure
|
||||||
|
modes occurred.
|
||||||
|
"""
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('recordings.index'))
|
||||||
|
|
||||||
|
user_id = consume_magic_link_token(token)
|
||||||
|
if not user_id:
|
||||||
|
flash("Le lien de connexion est invalide ou expiré.", 'danger')
|
||||||
|
return redirect(url_for('auth.magic_link_request'))
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if not user or not user.email_verified:
|
||||||
|
flash("Le lien de connexion est invalide ou expiré.", 'danger')
|
||||||
|
return redirect(url_for('auth.magic_link_request'))
|
||||||
|
|
||||||
|
login_user(user)
|
||||||
|
audit_login(user.id)
|
||||||
|
return redirect(url_for('recordings.index'))
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/logout')
|
@auth_bp.route('/logout')
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def logout():
|
def logout():
|
||||||
|
|||||||
@@ -647,6 +647,12 @@ app.register_blueprint(marketing_bp)
|
|||||||
app.register_blueprint(billing_bp)
|
app.register_blueprint(billing_bp)
|
||||||
app.register_blueprint(legal_bp)
|
app.register_blueprint(legal_bp)
|
||||||
|
|
||||||
|
# Initialize Microsoft + Google OAuth providers (B-2.4) — no-op if env vars absent.
|
||||||
|
# Must run AFTER blueprints are registered (Authlib's OAuth object needs to be
|
||||||
|
# attached to the running app instance).
|
||||||
|
from src.auth.oauth_providers import init_oauth_providers as _init_oauth_providers
|
||||||
|
_init_oauth_providers(app)
|
||||||
|
|
||||||
# File monitor and scheduler initialization functions below
|
# File monitor and scheduler initialization functions below
|
||||||
|
|
||||||
# Startup functions (extracted to src/config/startup.py)
|
# Startup functions (extracted to src/config/startup.py)
|
||||||
|
|||||||
40
src/auth/magic_link.py
Normal file
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)
|
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]]:
|
def can_resend_verification(user) -> tuple[bool, Optional[int]]:
|
||||||
"""
|
"""
|
||||||
Check if a verification email can be resent.
|
Check if a verification email can be resent.
|
||||||
|
|||||||
@@ -546,6 +546,9 @@
|
|||||||
.my-2 {
|
.my-2 {
|
||||||
margin-block: calc(var(--spacing) * 2);
|
margin-block: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.my-3 {
|
||||||
|
margin-block: calc(var(--spacing) * 3);
|
||||||
|
}
|
||||||
.my-4 {
|
.my-4 {
|
||||||
margin-block: calc(var(--spacing) * 4);
|
margin-block: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -1443,9 +1446,6 @@
|
|||||||
.border-\[var\(--border-accent\)\] {
|
.border-\[var\(--border-accent\)\] {
|
||||||
border-color: var(--border-accent);
|
border-color: var(--border-accent);
|
||||||
}
|
}
|
||||||
.border-\[var\(--border-danger\)\] {
|
|
||||||
border-color: var(--border-danger);
|
|
||||||
}
|
|
||||||
.border-\[var\(--border-focus\)\] {
|
.border-\[var\(--border-focus\)\] {
|
||||||
border-color: var(--border-focus);
|
border-color: var(--border-focus);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ['./templates/marketing/**/*.html', './templates/legal/**/*.html', './templates/billing/**/*.html', './templates/macros/**/*.html', './templates/auth/**/*.html', './templates/register.html', './src/marketing/**/*.py', './src/legal/**/*.py', './src/billing/**/*.py'],
|
content: ['./templates/marketing/**/*.html', './templates/legal/**/*.html', './templates/billing/**/*.html', './templates/macros/**/*.html', './templates/auth/**/*.html', './templates/register.html', './templates/login.html', './src/marketing/**/*.py', './src/legal/**/*.py', './src/billing/**/*.py'],
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends 'marketing/base.html' %}
|
{% extends 'marketing/base.html' %}
|
||||||
|
|
||||||
{% block title %}{% if action == 'password_reset' %}Vérifiez votre courriel — DictIA{% else %}Confirmez votre courriel — DictIA{% endif %}{% endblock %}
|
{% block title %}{% if action == 'password_reset' %}Vérifiez votre courriel — DictIA{% elif action == 'magic_link' %}Lien de connexion envoyé — DictIA{% else %}Confirmez votre courriel — DictIA{% endif %}{% endblock %}
|
||||||
{% block description %}Un courriel vous a été envoyé. Suivez le lien pour activer votre compte DictIA.{% endblock %}
|
{% block description %}Un courriel vous a été envoyé. Suivez le lien pour activer votre compte DictIA.{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<h1 id="check-email-title" class="text-2xl font-black text-brand-navy mb-2">
|
<h1 id="check-email-title" class="text-2xl font-black text-brand-navy mb-2">
|
||||||
{% if action == 'password_reset' %}Vérifiez votre courriel
|
{% if action == 'password_reset' %}Vérifiez votre courriel
|
||||||
{% elif action == 'verification_required' %}Vérification requise
|
{% elif action == 'verification_required' %}Vérification requise
|
||||||
|
{% elif action == 'magic_link' %}Lien de connexion envoyé
|
||||||
{% else %}Confirmez votre courriel{% endif %}
|
{% else %}Confirmez votre courriel{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@
|
|||||||
Si un compte DictIA existe pour <strong>{{ email }}</strong>, vous recevrez un courriel avec un lien pour réinitialiser votre mot de passe. Le lien expire dans 1 heure.
|
Si un compte DictIA existe pour <strong>{{ email }}</strong>, vous recevrez un courriel avec un lien pour réinitialiser votre mot de passe. Le lien expire dans 1 heure.
|
||||||
{% elif action == 'verification_required' %}
|
{% elif action == 'verification_required' %}
|
||||||
Vérifiez votre boîte de réception à <strong>{{ email }}</strong>. Si vous ne recevez rien, demandez un nouveau courriel ci-dessous.
|
Vérifiez votre boîte de réception à <strong>{{ email }}</strong>. Si vous ne recevez rien, demandez un nouveau courriel ci-dessous.
|
||||||
|
{% elif action == 'magic_link' %}
|
||||||
|
Si un compte vérifié existe pour <strong>{{ email }}</strong>, vous recevrez un courriel avec un lien de connexion. Le lien expire dans {{ "15 minutes" | safe }}.
|
||||||
{% else %}
|
{% else %}
|
||||||
Nous avons envoyé un lien de vérification à <strong>{{ email }}</strong>. Cliquez dessus pour activer votre compte. Le lien expire dans 24 heures.
|
Nous avons envoyé un lien de vérification à <strong>{{ email }}</strong>. Cliquez dessus pour activer votre compte. Le lien expire dans 24 heures.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
50
templates/auth/magic_link_request.html
Normal file
50
templates/auth/magic_link_request.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends 'marketing/base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Lien de connexion DictIA{% endblock %}
|
||||||
|
{% block description %}Recevez un lien magique pour vous connecter à DictIA sans mot de passe.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="magic-title">
|
||||||
|
<div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
|
||||||
|
<h1 id="magic-title" class="text-3xl font-black text-brand-navy mb-2">Lien de connexion</h1>
|
||||||
|
<p class="text-sm text-brand-navy/70 mb-6">{{ "Recevez un lien par courriel pour vous connecter sans mot de passe. Le lien expire dans 15 minutes." | safe }}</p>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div role="alert" class="mb-3 p-3 rounded-lg text-sm
|
||||||
|
{% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
|
||||||
|
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
|
||||||
|
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
|
||||||
|
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.magic_link_request') }}" class="space-y-4" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-brand-navy mb-1">Courriel <span class="text-red-600" aria-hidden="true">*</span></label>
|
||||||
|
<input type="email" id="email" name="email" autocomplete="email" required aria-required="true"
|
||||||
|
class="w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
|
||||||
|
placeholder="vous@cabinet.qc.ca">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
|
{{ "Recevoir le lien (expire dans 15 minutes)" | safe }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-xs text-brand-navy/70 mt-4">
|
||||||
|
Pour des raisons de sécurité, le lien n'est envoyé qu'aux comptes dont le courriel est vérifié. Si vous ne recevez rien, vérifiez vos pourriels (spam).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-brand-navy/70 mt-6">
|
||||||
|
<a href="{{ url_for('auth.login') }}" class="grad-text font-semibold hover:underline">← Retour à la connexion</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
80
templates/auth/oauth_finish_signup.html
Normal file
80
templates/auth/oauth_finish_signup.html
Normal file
@@ -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 %}
|
||||||
|
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="finish-title">
|
||||||
|
<div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
|
||||||
|
<h1 id="finish-title" class="text-3xl font-black text-brand-navy mb-2">Finaliser votre inscription</h1>
|
||||||
|
<p class="text-sm text-brand-navy/70 mb-6">
|
||||||
|
Vous vous inscrivez via <strong>{{ provider_display or provider | capitalize }}</strong>. Avant de créer votre compte DictIA, nous devons obtenir vos consentements conformément à la {{ "Loi 25" | safe }} du Québec.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div role="alert" class="mb-3 p-3 rounded-lg text-sm
|
||||||
|
{% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
|
||||||
|
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
|
||||||
|
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
|
||||||
|
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{# Pre-filled email from OAuth provider — display only, not editable #}
|
||||||
|
<div class="bg-brand-bg border border-brand-border rounded-[0.5rem] p-3 mb-6 text-sm">
|
||||||
|
<p class="text-brand-navy/70 mb-1">Compte fédéré :</p>
|
||||||
|
<p class="text-brand-navy font-semibold break-all">{{ userinfo.email }}</p>
|
||||||
|
{% if userinfo.name %}<p class="text-brand-navy/80 text-xs mt-1">{{ userinfo.name }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.oauth_finish_signup') }}" class="space-y-4" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
{# 4 SEPARATE consent checkboxes — Loi 25 art. 14 (consent must be granular, free, informed) #}
|
||||||
|
<fieldset class="space-y-3 pt-2">
|
||||||
|
<legend class="text-xs font-semibold text-brand-navy uppercase tracking-wide mb-1">{{ "Consentements — Loi 25" | safe }}</legend>
|
||||||
|
|
||||||
|
<label for="consent_cgu" class="flex items-start gap-2 text-sm text-brand-navy/90">
|
||||||
|
<input type="checkbox" id="consent_cgu" name="consent_cgu" value="y" required aria-required="true"
|
||||||
|
class="mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
|
<span>J'accepte les <a href="/legal/conditions" target="_blank" rel="noopener" class="grad-text underline">conditions d'utilisation</a>. <span class="text-red-600" aria-hidden="true">*</span></span>
|
||||||
|
</label>
|
||||||
|
{% if errors.consent_cgu %}<p class="text-xs text-red-900 mt-1" role="alert">{{ errors.consent_cgu }}</p>{% endif %}
|
||||||
|
|
||||||
|
<label for="consent_confidentialite" class="flex items-start gap-2 text-sm text-brand-navy/90">
|
||||||
|
<input type="checkbox" id="consent_confidentialite" name="consent_confidentialite" value="y" required aria-required="true"
|
||||||
|
class="mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
|
<span>J'accepte la <a href="/legal/confidentialite" target="_blank" rel="noopener" class="grad-text underline">politique de confidentialité</a>. <span class="text-red-600" aria-hidden="true">*</span></span>
|
||||||
|
</label>
|
||||||
|
{% if errors.consent_confidentialite %}<p class="text-xs text-red-900 mt-1" role="alert">{{ errors.consent_confidentialite }}</p>{% endif %}
|
||||||
|
|
||||||
|
<label for="consent_marketing" class="flex items-start gap-2 text-sm text-brand-navy/90">
|
||||||
|
<input type="checkbox" id="consent_marketing" name="consent_marketing" value="y"
|
||||||
|
class="mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
|
<span>J'accepte de recevoir des communications marketing (optionnel, désactivable à tout moment).</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label for="consent_analytics" class="flex items-start gap-2 text-sm text-brand-navy/90">
|
||||||
|
<input type="checkbox" id="consent_analytics" name="consent_analytics" value="y"
|
||||||
|
class="mt-1 focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
|
<span>J'accepte les statistiques d'usage anonymisées (optionnel, désactivable à tout moment).</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
|
Créer mon compte DictIA
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-brand-navy/70 mt-6">
|
||||||
|
Vous voulez utiliser un autre courriel ?
|
||||||
|
<a href="{{ url_for('auth.signup') }}" class="grad-text font-semibold hover:underline">Inscription manuelle</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,178 +1,120 @@
|
|||||||
<!DOCTYPE html>
|
{% extends 'marketing/base.html' %}
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
|
||||||
<title>{{ title }} - DictIA</title>
|
|
||||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
|
|
||||||
<!-- All dependencies bundled locally for offline support -->
|
|
||||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
|
||||||
<!-- All dependencies bundled locally for offline support -->
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
|
||||||
|
|
||||||
<!-- Loading overlay to prevent FOUC -->
|
{% block title %}Connexion — DictIA{% endblock %}
|
||||||
{% include 'includes/loading_overlay.html' %}
|
{% block description %}Connectez-vous à votre compte DictIA. Microsoft 365, Google, lien magique ou mot de passe.{% endblock %}
|
||||||
|
|
||||||
<script>
|
{% block content %}
|
||||||
// Function to apply the theme based on localStorage
|
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="login-title">
|
||||||
function applyTheme() {
|
<div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
|
||||||
// Guard against early execution
|
<h1 id="login-title" class="text-3xl font-black text-brand-navy mb-2">Connexion</h1>
|
||||||
if (!document.documentElement) return;
|
<p class="text-sm text-brand-navy/70 mb-6">{{ "Bienvenue sur DictIA — la transcription IA conforme à la Loi 25." | safe }}</p>
|
||||||
|
|
||||||
// Apply dark mode
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
const savedMode = localStorage.getItem('darkMode');
|
{% if messages %}
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
{% for category, message in messages %}
|
||||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
<div role="alert" class="mb-3 p-3 rounded-lg text-sm
|
||||||
document.documentElement.classList.add('dark');
|
{% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
|
||||||
} else {
|
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
|
||||||
document.documentElement.classList.remove('dark');
|
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
|
||||||
}
|
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
// Apply color scheme
|
{# OAuth providers (Microsoft 365 + Google) — rendered only if env-enabled #}
|
||||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
{% if oauth_microsoft_enabled or oauth_google_enabled or sso_enabled %}
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
<div class="space-y-3 mb-6" aria-label="Connexion fédérée">
|
||||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
{% if oauth_microsoft_enabled %}
|
||||||
|
<a href="{{ url_for('auth.oauth_provider_login', provider='microsoft') }}"
|
||||||
// Remove all other theme classes
|
class="w-full inline-flex items-center justify-center gap-3 px-4 py-3 bg-white border border-brand-border rounded-[0.75rem] text-brand-navy font-semibold hover:bg-brand-bg transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
|
{# Official Microsoft 4-square logo #}
|
||||||
themeClasses.forEach(theme => {
|
<svg width="20" height="20" viewBox="0 0 21 21" aria-hidden="true" focusable="false">
|
||||||
document.documentElement.classList.remove(`theme-light-${theme}`);
|
<rect x="1" y="1" width="9" height="9" fill="#F25022"/>
|
||||||
document.documentElement.classList.remove(`theme-dark-${theme}`);
|
<rect x="11" y="1" width="9" height="9" fill="#7FBA00"/>
|
||||||
});
|
<rect x="1" y="11" width="9" height="9" fill="#00A4EF"/>
|
||||||
|
<rect x="11" y="11" width="9" height="9" fill="#FFB900"/>
|
||||||
|
</svg>
|
||||||
|
<span>Continuer avec Microsoft 365</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
// Add the correct theme class
|
{% if oauth_google_enabled %}
|
||||||
if (savedScheme !== 'blue') {
|
<a href="{{ url_for('auth.oauth_provider_login', provider='google') }}"
|
||||||
document.documentElement.classList.add(themePrefix + savedScheme);
|
class="w-full inline-flex items-center justify-center gap-3 px-4 py-3 bg-white border border-brand-border rounded-[0.75rem] text-brand-navy font-semibold hover:bg-brand-bg transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
}
|
{# Official Google "G" logo #}
|
||||||
}
|
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
applyTheme();
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.76h3.56c2.08-1.92 3.28-4.74 3.28-8.09Z"/>
|
||||||
</script>
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.56-2.76c-.99.66-2.25 1.06-3.72 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z"/>
|
||||||
</head>
|
<path fill="#FBBC05" d="M5.84 14.11A6.6 6.6 0 0 1 5.5 12c0-.73.13-1.44.34-2.11V7.05H2.18a11 11 0 0 0 0 9.9l3.66-2.84Z"/>
|
||||||
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.07.56 4.21 1.64l3.16-3.16C17.46 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.05l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38Z"/>
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
|
</svg>
|
||||||
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
|
<span>Continuer avec Google</span>
|
||||||
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
|
</a>
|
||||||
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
|
{% endif %}
|
||||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
|
|
||||||
DictIA
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="flex-grow flex items-center justify-center">
|
{% if sso_enabled %}
|
||||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
<a href="{{ url_for('auth.sso_login') }}"
|
||||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-6 text-center">Connexion</h2>
|
class="w-full inline-flex items-center justify-center gap-3 px-4 py-3 bg-white border border-brand-border rounded-[0.75rem] text-brand-navy font-semibold hover:bg-brand-bg transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
<rect x="3" y="11" width="18" height="11" rx="2"/>
|
||||||
{% if messages %}
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
{% for category, message in messages %}
|
</svg>
|
||||||
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
|
<span>Se connecter avec {{ sso_provider_name }}</span>
|
||||||
{{ message }}
|
</a>
|
||||||
</div>
|
{% endif %}
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
{% if sso_enabled %}
|
|
||||||
<div class="flex flex-col space-y-3 {% if not password_login_disabled %}mb-6{% endif %}">
|
|
||||||
<a href="{{ url_for('auth.sso_login') }}" class="w-full inline-flex items-center justify-center px-4 py-2 bg-[var(--bg-button)] text-[var(--text-button)] rounded-md hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
|
|
||||||
<i class="fas fa-cloud mr-2"></i> Se connecter avec {{ sso_provider_name }}
|
|
||||||
</a>
|
|
||||||
{% if not password_login_disabled %}
|
|
||||||
<div class="flex items-center text-xs text-[var(--text-muted)]">
|
|
||||||
<span class="flex-grow border-t border-[var(--border-secondary)]"></span>
|
|
||||||
<span class="mx-3 uppercase tracking-wide">ou</span>
|
|
||||||
<span class="flex-grow border-t border-[var(--border-secondary)]"></span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if password_login_disabled %}
|
{% if not password_login_disabled %}
|
||||||
<div class="mt-4 text-center">
|
<div class="flex items-center text-xs uppercase tracking-wide text-brand-navy/60 my-3">
|
||||||
<button type="button" onclick="document.getElementById('admin-login-form').classList.toggle('hidden')" class="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
|
<span class="flex-grow border-t border-brand-border"></span>
|
||||||
<i class="fas fa-lock mr-1"></i> Connexion administrateur
|
<span class="mx-3">ou</span>
|
||||||
</button>
|
<span class="flex-grow border-t border-brand-border"></span>
|
||||||
</div>
|
</div>
|
||||||
<form id="admin-login-form" method="POST" action="{{ url_for('auth.login') }}" class="hidden mt-4">
|
{% endif %}
|
||||||
{{ form.hidden_tag() }}
|
</div>
|
||||||
<div class="mb-4">
|
{% endif %}
|
||||||
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]", placeholder="Email administrateur") }}
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]", placeholder="Mot de passe") }}
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]">
|
|
||||||
Se connecter
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
|
||||||
{{ form.hidden_tag() }}
|
|
||||||
|
|
||||||
<div class="mb-4">
|
{% if not password_login_disabled %}
|
||||||
{{ form.email.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-4" novalidate>
|
||||||
{% if form.email.errors %}
|
{{ form.hidden_tag() }}
|
||||||
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
|
||||||
<div class="text-[var(--text-danger)] text-xs mt-1">
|
|
||||||
{% for error in form.email.errors %}
|
|
||||||
<span>{{ error }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
{{ form.email(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
<div>
|
||||||
{{ form.password.label(class="block text-sm font-medium text-[var(--text-secondary)] mb-1") }}
|
<label for="email" class="block text-sm font-medium text-brand-navy mb-1">Courriel <span class="text-red-600" aria-hidden="true">*</span></label>
|
||||||
{% if form.password.errors %}
|
{{ form.email(id='email', type='email', autocomplete='email', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
|
||||||
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-danger)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
{% if form.email.errors %}<p class="text-xs text-red-700 mt-1" role="alert">{{ form.email.errors[0] }}</p>{% endif %}
|
||||||
<div class="text-[var(--text-danger)] text-xs mt-1">
|
</div>
|
||||||
{% for error in form.password.errors %}
|
|
||||||
<span>{{ error }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
{{ form.password(class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]") }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div>
|
||||||
<div class="flex items-center">
|
<label for="password" class="block text-sm font-medium text-brand-navy mb-1">Mot de passe <span class="text-red-600" aria-hidden="true">*</span></label>
|
||||||
{{ form.remember(class="h-4 w-4 text-[var(--text-accent)] focus:ring-[var(--ring-focus)] border-[var(--border-secondary)] rounded") }}
|
{{ form.password(id='password', autocomplete='current-password', required=true, **{'aria-required':'true', 'class':'w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
|
||||||
{{ form.remember.label(class="ml-2 block text-sm text-[var(--text-secondary)]") }}
|
{% if form.password.errors %}<p class="text-xs text-red-700 mt-1" role="alert">{{ form.password.errors[0] }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ url_for('auth.forgot_password') }}" class="text-sm text-[var(--text-accent)] hover:underline">Mot de passe oublié ?</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col space-y-4">
|
<div class="flex items-center justify-between text-sm">
|
||||||
{{ form.submit(class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]") }}
|
<label for="remember" class="flex items-center gap-2 text-brand-navy/90 cursor-pointer">
|
||||||
|
{{ form.remember(id='remember', **{'class':'focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2'}) }}
|
||||||
|
<span>Se souvenir de moi</span>
|
||||||
|
</label>
|
||||||
|
<a href="{{ url_for('auth.forgot_password') }}" class="grad-text font-semibold hover:underline">Mot de passe oublié ?</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="text-center text-sm text-[var(--text-muted)]">
|
<button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
<span>Pas encore de compte ?</span>
|
Se connecter
|
||||||
<a href="{{ url_for('auth.register') }}" class="font-medium text-[var(--text-accent)] hover:underline">S'inscrire</a>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
<p class="text-center text-sm mt-4">
|
||||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
<a href="{{ url_for('auth.magic_link_request') }}" class="grad-text font-semibold hover:underline">
|
||||||
</footer>
|
{{ "Recevoir un lien de connexion par courriel (sans mot de passe)" | safe }}
|
||||||
</div>
|
</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<p class="text-center text-sm text-brand-navy/70 mt-6">
|
||||||
// Hide loading overlay when page is ready
|
Pas encore de compte ?
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
<a href="{{ url_for('auth.signup') }}" class="grad-text font-semibold hover:underline">Créer un compte</a>
|
||||||
if (window.AppLoader) {
|
</p>
|
||||||
AppLoader.waitForReady();
|
</div>
|
||||||
}
|
</section>
|
||||||
});
|
{% endblock %}
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
81
tests/_run_oauth_magic_link_windows.py
Normal file
81
tests/_run_oauth_magic_link_windows.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Windows manual driver for tests/test_oauth_magic_link.py.
|
||||||
|
|
||||||
|
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
|
||||||
|
before src.app gets imported, then run each test_* function and report.
|
||||||
|
|
||||||
|
Run from the repo root:
|
||||||
|
py -3 tests/_run_oauth_magic_link_windows.py
|
||||||
|
|
||||||
|
This script is local-dev only (not picked up by pytest collection).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
# 1) Stub fcntl BEFORE any import of src.* happens.
|
||||||
|
if 'fcntl' not in sys.modules:
|
||||||
|
fcntl_stub = types.ModuleType('fcntl')
|
||||||
|
fcntl_stub.LOCK_EX = 2
|
||||||
|
fcntl_stub.LOCK_NB = 4
|
||||||
|
fcntl_stub.LOCK_UN = 8
|
||||||
|
fcntl_stub.LOCK_SH = 1
|
||||||
|
fcntl_stub.flock = lambda *_args, **_kw: None
|
||||||
|
fcntl_stub.fcntl = lambda *_args, **_kw: 0
|
||||||
|
sys.modules['fcntl'] = fcntl_stub
|
||||||
|
|
||||||
|
# 2) Make repo root importable
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO = os.path.dirname(HERE)
|
||||||
|
sys.path.insert(0, REPO)
|
||||||
|
|
||||||
|
# 3) Set test config
|
||||||
|
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
|
||||||
|
os.environ.setdefault('SECRET_KEY', 'test-secret-key-oauth')
|
||||||
|
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
|
||||||
|
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
|
||||||
|
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
|
||||||
|
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
|
||||||
|
# Pre-set OAuth env vars so init_oauth_providers registers clients at app boot.
|
||||||
|
os.environ.setdefault('MS_CLIENT_ID', 'test-ms-client-id')
|
||||||
|
os.environ.setdefault('MS_CLIENT_SECRET', 'test-ms-client-secret')
|
||||||
|
os.environ.setdefault('GOOGLE_CLIENT_ID', 'test-google-client-id')
|
||||||
|
os.environ.setdefault('GOOGLE_CLIENT_SECRET', 'test-google-client-secret')
|
||||||
|
# Force UTF-8 stdout so src.app's emoji prints don't crash on cp1252 Windows.
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4) Import the test module and run every test_* function it defines
|
||||||
|
import importlib.util # noqa: E402
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
'test_oauth_magic_link',
|
||||||
|
os.path.join(HERE, 'test_oauth_magic_link.py'),
|
||||||
|
)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
|
||||||
|
tests = [(name, fn) for name, fn in vars(mod).items()
|
||||||
|
if name.startswith('test_') and callable(fn)]
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
failed = []
|
||||||
|
for name, fn in tests:
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print(f' PASS {name}')
|
||||||
|
passed += 1
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f' FAIL {name}: {type(e).__name__}: {e}')
|
||||||
|
failed.append((name, traceback.format_exc()))
|
||||||
|
|
||||||
|
total = len(tests)
|
||||||
|
print()
|
||||||
|
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
|
||||||
|
if failed:
|
||||||
|
print('\n--- Failures ---\n')
|
||||||
|
for name, tb in failed:
|
||||||
|
print(f'### {name}\n{tb}\n')
|
||||||
|
sys.exit(0 if not failed else 1)
|
||||||
614
tests/test_oauth_magic_link.py
Normal file
614
tests/test_oauth_magic_link.py
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
"""Tests for B-2.4 — OAuth Microsoft 365 + Google + magic-link login.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- is_oauth_provider_enabled() reads MS/GOOGLE env vars correctly.
|
||||||
|
- /auth/oauth/<provider>/login redirects to /login when provider disabled.
|
||||||
|
- /auth/oauth/<provider>/callback for new user → /auth/oauth/finish-signup
|
||||||
|
with session['oauth_signup_pending'] populated.
|
||||||
|
- /auth/oauth/<provider>/callback for existing user (sso_subject) → logs in.
|
||||||
|
- /auth/oauth/<provider>/callback for existing user (email match) → links.
|
||||||
|
- /auth/oauth/finish-signup requires CGU + confidentialité consents.
|
||||||
|
- /auth/oauth/finish-signup creates User + 4 ConsentLog rows on success.
|
||||||
|
- /auth/magic-link returns generic flash for unknown email (anti-enumeration).
|
||||||
|
- /auth/magic-link sends email for known verified user.
|
||||||
|
- /auth/magic-link skips unverified users silently (anti-enumeration).
|
||||||
|
- /auth/magic-link/<token> logs in user with valid token.
|
||||||
|
- /auth/magic-link/<token> rejects expired tokens.
|
||||||
|
- /auth/magic-link/<token> rejects tampered tokens.
|
||||||
|
- /auth/magic-link/<token> rejects unverified users.
|
||||||
|
|
||||||
|
Note: pytest cannot collect this file on Windows native because src/init_db.py
|
||||||
|
imports `fcntl` (POSIX-only). Use tests/_run_oauth_magic_link_windows.py.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
|
||||||
|
os.environ.setdefault('SECRET_KEY', 'test-secret-key-oauth')
|
||||||
|
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
|
||||||
|
os.environ.setdefault('REQUIRE_EMAIL_VERIFICATION', 'false')
|
||||||
|
# Pre-set OAuth env vars so init_oauth_providers registers clients at app boot.
|
||||||
|
os.environ.setdefault('MS_CLIENT_ID', 'test-ms-client-id')
|
||||||
|
os.environ.setdefault('MS_CLIENT_SECRET', 'test-ms-client-secret')
|
||||||
|
os.environ.setdefault('GOOGLE_CLIENT_ID', 'test-google-client-id')
|
||||||
|
os.environ.setdefault('GOOGLE_CLIENT_SECRET', 'test-google-client-secret')
|
||||||
|
|
||||||
|
from src.app import app, db # noqa: E402
|
||||||
|
from src.models.user import User # noqa: E402
|
||||||
|
from src.models.consent import ConsentLog # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_csrf():
|
||||||
|
app.config['WTF_CSRF_ENABLED'] = False
|
||||||
|
|
||||||
|
|
||||||
|
def _set_oauth_env():
|
||||||
|
"""Ensure OAuth env vars are set (some tests clear them)."""
|
||||||
|
os.environ['MS_CLIENT_ID'] = 'test-ms-client-id'
|
||||||
|
os.environ['MS_CLIENT_SECRET'] = 'test-ms-client-secret'
|
||||||
|
os.environ['GOOGLE_CLIENT_ID'] = 'test-google-client-id'
|
||||||
|
os.environ['GOOGLE_CLIENT_SECRET'] = 'test-google-client-secret'
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_oauth_env():
|
||||||
|
for k in ('MS_CLIENT_ID', 'MS_CLIENT_SECRET', 'GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'):
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 1. is_oauth_provider_enabled — env var detection
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_oauth_provider_enabled_when_env_vars_present():
|
||||||
|
"""Microsoft is enabled when MS_CLIENT_ID + MS_CLIENT_SECRET are both set."""
|
||||||
|
_set_oauth_env()
|
||||||
|
from src.auth.oauth_providers import is_oauth_provider_enabled
|
||||||
|
assert is_oauth_provider_enabled('microsoft') is True
|
||||||
|
assert is_oauth_provider_enabled('google') is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_provider_disabled_when_env_vars_missing():
|
||||||
|
"""Provider is disabled if either CLIENT_ID or CLIENT_SECRET is missing."""
|
||||||
|
_clear_oauth_env()
|
||||||
|
try:
|
||||||
|
from src.auth.oauth_providers import is_oauth_provider_enabled
|
||||||
|
assert is_oauth_provider_enabled('microsoft') is False
|
||||||
|
assert is_oauth_provider_enabled('google') is False
|
||||||
|
assert is_oauth_provider_enabled('unknown') is False
|
||||||
|
finally:
|
||||||
|
_set_oauth_env()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 2. /auth/oauth/<provider>/login — disabled provider
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_oauth_login_redirects_when_provider_disabled():
|
||||||
|
"""GET /auth/oauth/microsoft/login without env vars → 302 to /login + French flash."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
_clear_oauth_env()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
resp = client.get('/auth/oauth/microsoft/login', follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert '/login' in resp.headers['Location']
|
||||||
|
# Flash assertion — follow redirect and inspect body
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
flashes = sess.get('_flashes', [])
|
||||||
|
assert any("n'est pas activée" in msg for _cat, msg in flashes), (
|
||||||
|
f'expected French flash about provider disabled, got: {flashes}'
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
_set_oauth_env()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 3. /auth/oauth/<provider>/callback — new user → finish-signup
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_oauth_callback_creates_session_for_new_user():
|
||||||
|
"""Callback for a NEW user stores userinfo in session + redirects to finish-signup."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
_set_oauth_env()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
mock_token = {
|
||||||
|
'userinfo': {
|
||||||
|
'sub': 'ms-sub-new-123',
|
||||||
|
'email': 'newuser@example.qc.ca',
|
||||||
|
'name': 'New User',
|
||||||
|
'given_name': 'New',
|
||||||
|
'family_name': 'User',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch('src.api.auth.get_oauth_client') as mock_get_client:
|
||||||
|
client_mock = MagicMock()
|
||||||
|
client_mock.authorize_access_token.return_value = mock_token
|
||||||
|
mock_get_client.return_value = client_mock
|
||||||
|
with app.test_client() as test_client:
|
||||||
|
resp = test_client.get('/auth/oauth/microsoft/callback')
|
||||||
|
assert resp.status_code == 302, f'Expected 302, got {resp.status_code} body={resp.data[:200]!r}'
|
||||||
|
assert '/auth/oauth/finish-signup' in resp.headers['Location']
|
||||||
|
with test_client.session_transaction() as sess:
|
||||||
|
assert 'oauth_signup_pending' in sess
|
||||||
|
pending = sess['oauth_signup_pending']
|
||||||
|
assert pending['provider'] == 'microsoft'
|
||||||
|
assert pending['subject'] == 'ms-sub-new-123'
|
||||||
|
assert pending['userinfo']['email'] == 'newuser@example.qc.ca'
|
||||||
|
assert pending['userinfo']['name'] == 'New User'
|
||||||
|
# No User row created yet
|
||||||
|
assert User.query.filter_by(email='newuser@example.qc.ca').first() is None
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 4. /auth/oauth/<provider>/callback — existing user by sso_subject
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_oauth_callback_logs_in_existing_user_by_subject():
|
||||||
|
"""User with matching sso_subject is logged in directly without consent flow."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
_set_oauth_env()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
existing = User(
|
||||||
|
username='existing1',
|
||||||
|
email='existing@example.qc.ca',
|
||||||
|
password=None,
|
||||||
|
sso_provider='microsoft',
|
||||||
|
sso_subject='ms-sub-existing-456',
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
|
db.session.add(existing)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
mock_token = {
|
||||||
|
'userinfo': {
|
||||||
|
'sub': 'ms-sub-existing-456',
|
||||||
|
'email': 'existing@example.qc.ca',
|
||||||
|
'name': 'Existing User',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch('src.api.auth.get_oauth_client') as mock_get_client:
|
||||||
|
client_mock = MagicMock()
|
||||||
|
client_mock.authorize_access_token.return_value = mock_token
|
||||||
|
mock_get_client.return_value = client_mock
|
||||||
|
with app.test_client() as test_client:
|
||||||
|
resp = test_client.get('/auth/oauth/microsoft/callback')
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert '/auth/oauth/finish-signup' not in resp.headers['Location']
|
||||||
|
with test_client.session_transaction() as sess:
|
||||||
|
# User is logged in
|
||||||
|
assert '_user_id' in sess
|
||||||
|
assert sess['_user_id'] == str(existing.id)
|
||||||
|
assert 'oauth_signup_pending' not in sess
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 5. /auth/oauth/<provider>/callback — existing user by email
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_oauth_callback_links_existing_user_by_email():
|
||||||
|
"""User with matching email but no sso_subject gets linked + logged in."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
_set_oauth_env()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
existing = User(
|
||||||
|
username='emaillink',
|
||||||
|
email='emaillink@example.qc.ca',
|
||||||
|
password='$2b$12$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMN',
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
|
db.session.add(existing)
|
||||||
|
db.session.commit()
|
||||||
|
existing_id = existing.id
|
||||||
|
|
||||||
|
mock_token = {
|
||||||
|
'userinfo': {
|
||||||
|
'sub': 'google-new-sub-789',
|
||||||
|
'email': 'emaillink@example.qc.ca',
|
||||||
|
'name': 'Email Link User',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch('src.api.auth.get_oauth_client') as mock_get_client:
|
||||||
|
client_mock = MagicMock()
|
||||||
|
client_mock.authorize_access_token.return_value = mock_token
|
||||||
|
mock_get_client.return_value = client_mock
|
||||||
|
with app.test_client() as test_client:
|
||||||
|
resp = test_client.get('/auth/oauth/google/callback')
|
||||||
|
assert resp.status_code == 302
|
||||||
|
# Refresh to read updated columns
|
||||||
|
refreshed = db.session.get(User, existing_id)
|
||||||
|
assert refreshed.sso_subject == 'google-new-sub-789'
|
||||||
|
assert refreshed.sso_provider == 'google'
|
||||||
|
with test_client.session_transaction() as sess:
|
||||||
|
assert sess.get('_user_id') == str(existing_id)
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 6. /auth/oauth/finish-signup — required consents
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_finish_signup_requires_cgu_and_confidentialite():
|
||||||
|
"""POST without CGU+confidentialite returns 400; no User created; session preserved."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
with app.test_client() as client:
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess['oauth_signup_pending'] = {
|
||||||
|
'provider': 'microsoft',
|
||||||
|
'subject': 'ms-sub-pending-001',
|
||||||
|
'userinfo': {
|
||||||
|
'email': 'pending@example.qc.ca',
|
||||||
|
'name': 'Pending User',
|
||||||
|
'given_name': 'Pending',
|
||||||
|
'family_name': 'User',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp = client.post('/auth/oauth/finish-signup', data={
|
||||||
|
# No consents
|
||||||
|
})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
body = resp.data.decode('utf-8').lower()
|
||||||
|
assert "conditions d'utilisation" in body or 'cgu' in body
|
||||||
|
assert 'confidentialit' in body
|
||||||
|
assert User.query.filter_by(email='pending@example.qc.ca').first() is None
|
||||||
|
# Session still has the pending entry (so user can retry)
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
assert 'oauth_signup_pending' in sess
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 7. /auth/oauth/finish-signup — success path
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_finish_signup_creates_user_and_4_consent_logs():
|
||||||
|
"""POST with all consents → User created with 4 ConsentLog rows + login."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
with app.test_client() as client:
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess['oauth_signup_pending'] = {
|
||||||
|
'provider': 'google',
|
||||||
|
'subject': 'google-sub-success-002',
|
||||||
|
'userinfo': {
|
||||||
|
'email': 'success@example.qc.ca',
|
||||||
|
'name': 'Success User',
|
||||||
|
'given_name': 'Success',
|
||||||
|
'family_name': 'User',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp = client.post('/auth/oauth/finish-signup', data={
|
||||||
|
'consent_cgu': 'y',
|
||||||
|
'consent_confidentialite': 'y',
|
||||||
|
'consent_marketing': 'y',
|
||||||
|
# analytics absent → recorded as False
|
||||||
|
}, headers={'CF-Connecting-IP': '198.51.100.7', 'User-Agent': 'TestUA/1.0'})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
|
||||||
|
user = User.query.filter_by(email='success@example.qc.ca').first()
|
||||||
|
assert user is not None
|
||||||
|
assert user.sso_provider == 'google'
|
||||||
|
assert user.sso_subject == 'google-sub-success-002'
|
||||||
|
assert user.password is None
|
||||||
|
assert user.email_verified is True
|
||||||
|
assert user.name == 'Success User'
|
||||||
|
|
||||||
|
consents = ConsentLog.query.filter_by(user_id=user.id).all()
|
||||||
|
assert len(consents) == 4
|
||||||
|
state = {c.consent_type: c.granted for c in consents}
|
||||||
|
assert state == {
|
||||||
|
'cgu': True,
|
||||||
|
'confidentialite': True,
|
||||||
|
'marketing': True,
|
||||||
|
'analytics': False,
|
||||||
|
}
|
||||||
|
assert all(c.ip_address == '198.51.100.7' for c in consents)
|
||||||
|
assert all(c.user_agent == 'TestUA/1.0' for c in consents)
|
||||||
|
|
||||||
|
# Session cleaned + logged in
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
assert 'oauth_signup_pending' not in sess
|
||||||
|
assert sess.get('_user_id') == str(user.id)
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 8. /auth/magic-link — generic flash for unknown email
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_magic_link_request_returns_generic_message_for_unknown_email():
|
||||||
|
"""Unknown email → generic flash; no email sent; no error leak."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
with patch('src.services.email._send_email') as mock_send:
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.post('/auth/magic-link', data={'email': 'doesnotexist@example.qc.ca'})
|
||||||
|
assert resp.status_code in (200, 302)
|
||||||
|
assert mock_send.call_count == 0
|
||||||
|
body = resp.data.decode('utf-8').lower()
|
||||||
|
# Generic French message — no leak
|
||||||
|
assert 'doesnotexist@example.qc.ca' in body # display the email
|
||||||
|
# No leak language like "introuvable" / "n'existe pas"
|
||||||
|
assert 'introuvable' not in body
|
||||||
|
assert "n'existe pas" not in body
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 9. /auth/magic-link — sends email for known verified user
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_magic_link_request_sends_email_for_known_verified_user():
|
||||||
|
"""Known + verified user triggers _send_email exactly once with the magic URL."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
user = User(
|
||||||
|
username='magicverified',
|
||||||
|
email='magic@example.qc.ca',
|
||||||
|
password='$2b$12$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMN',
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Force SMTP-configured branch for the test (both the route gate AND
|
||||||
|
# the inner gate inside src.services.email.send_magic_link_email).
|
||||||
|
with patch('src.api.auth.is_smtp_configured', return_value=True), \
|
||||||
|
patch('src.services.email.is_smtp_configured', return_value=True), \
|
||||||
|
patch('src.services.email._send_email') as mock_send:
|
||||||
|
mock_send.return_value = True
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.post('/auth/magic-link', data={'email': 'magic@example.qc.ca'})
|
||||||
|
assert resp.status_code in (200, 302)
|
||||||
|
assert mock_send.call_count == 1
|
||||||
|
# Email body should contain the magic URL
|
||||||
|
args, _kwargs = mock_send.call_args
|
||||||
|
# _send_email(to_email, subject, html_body, text_body)
|
||||||
|
to_email, subject, html_body, text_body = args
|
||||||
|
assert to_email == 'magic@example.qc.ca'
|
||||||
|
assert 'DictIA' in subject
|
||||||
|
assert '/auth/magic-link/' in html_body
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 10. /auth/magic-link — skips unverified users silently
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_magic_link_request_skips_unverified_user_silently():
|
||||||
|
"""Unverified user → email NOT sent, but flash STILL generic (anti-enumeration)."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
user = User(
|
||||||
|
username='magicunverif',
|
||||||
|
email='unverif@example.qc.ca',
|
||||||
|
password='$2b$12$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMN',
|
||||||
|
email_verified=False,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
with patch('src.api.auth.is_smtp_configured', return_value=True), \
|
||||||
|
patch('src.services.email._send_email') as mock_send:
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.post('/auth/magic-link', data={'email': 'unverif@example.qc.ca'})
|
||||||
|
assert resp.status_code in (200, 302)
|
||||||
|
assert mock_send.call_count == 0
|
||||||
|
body = resp.data.decode('utf-8').lower()
|
||||||
|
# No leak: same message as for unknown emails
|
||||||
|
assert 'non vérifié' not in body
|
||||||
|
assert 'unverified' not in body
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 11. /auth/magic-link/<token> — valid token logs in
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_magic_link_consume_logs_in_user_with_valid_token():
|
||||||
|
"""Valid token → user logged in + redirect to recordings.index."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
user = User(
|
||||||
|
username='consumeok',
|
||||||
|
email='consumeok@example.qc.ca',
|
||||||
|
password='$2b$12$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMN',
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
from src.auth.magic_link import generate_magic_link_token
|
||||||
|
token = generate_magic_link_token(user.id)
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.get(f'/auth/magic-link/{token}', follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert '/auth/magic-link' not in resp.headers['Location']
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
assert sess.get('_user_id') == str(user.id)
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 12. /auth/magic-link/<token> — expired token rejected
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_magic_link_consume_rejects_expired_token():
|
||||||
|
"""Expired token → invalid flash + redirect to /auth/magic-link."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
user = User(
|
||||||
|
username='expired',
|
||||||
|
email='expired@example.qc.ca',
|
||||||
|
password='x' * 60,
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
from itsdangerous import SignatureExpired
|
||||||
|
with patch('src.auth.magic_link._serializer') as mock_ser:
|
||||||
|
inst = MagicMock()
|
||||||
|
inst.loads.side_effect = SignatureExpired('expired')
|
||||||
|
mock_ser.return_value = inst
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.get('/auth/magic-link/dummy-token', follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert '/auth/magic-link' in resp.headers['Location']
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
flashes = sess.get('_flashes', [])
|
||||||
|
assert any('invalide' in msg.lower() or 'expir' in msg.lower()
|
||||||
|
for _cat, msg in flashes), f'expected expiry flash, got {flashes}'
|
||||||
|
assert sess.get('_user_id') is None
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 13. /auth/magic-link/<token> — tampered token rejected
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_magic_link_consume_rejects_invalid_signature():
|
||||||
|
"""Garbage token → BadSignature → invalid flash + redirect."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.get('/auth/magic-link/garbage.token.value', follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert '/auth/magic-link' in resp.headers['Location']
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
flashes = sess.get('_flashes', [])
|
||||||
|
assert any('invalide' in msg.lower() or 'expir' in msg.lower()
|
||||||
|
for _cat, msg in flashes), f'expected invalid flash, got {flashes}'
|
||||||
|
assert sess.get('_user_id') is None
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 14. /auth/magic-link/<token> — unverified user rejected
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_magic_link_consume_rejects_unverified_user():
|
||||||
|
"""Token for unverified user → invalid flash (no leak that token was valid)."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
user = User(
|
||||||
|
username='unverifconsume',
|
||||||
|
email='unverifconsume@example.qc.ca',
|
||||||
|
password='x' * 60,
|
||||||
|
email_verified=False,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
from src.auth.magic_link import generate_magic_link_token
|
||||||
|
token = generate_magic_link_token(user.id)
|
||||||
|
with app.test_client() as client:
|
||||||
|
resp = client.get(f'/auth/magic-link/{token}', follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert '/auth/magic-link' in resp.headers['Location']
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
assert sess.get('_user_id') is None
|
||||||
|
flashes = sess.get('_flashes', [])
|
||||||
|
# Flash should look the same as the bad-signature path
|
||||||
|
assert any('invalide' in msg.lower() or 'expir' in msg.lower()
|
||||||
|
for _cat, msg in flashes)
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 15. send_magic_link_email helper produces French DictIA-branded email
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_send_magic_link_email_french_branded():
|
||||||
|
"""send_magic_link_email() produces French + DictIA-branded HTML body."""
|
||||||
|
with app.app_context():
|
||||||
|
_disable_csrf()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
user = User(
|
||||||
|
username='emailtest',
|
||||||
|
email='emailtest@example.qc.ca',
|
||||||
|
password='x' * 60,
|
||||||
|
name='Marie <script>', # XSS canary
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
with patch('src.services.email.is_smtp_configured', return_value=True), \
|
||||||
|
patch('src.services.email._send_email') as mock_send:
|
||||||
|
mock_send.return_value = True
|
||||||
|
from src.services.email import send_magic_link_email
|
||||||
|
ok = send_magic_link_email(user, 'https://example.com/auth/magic-link/abc123')
|
||||||
|
assert ok is True
|
||||||
|
args, _kwargs = mock_send.call_args
|
||||||
|
to_email, subject, html_body, text_body = args
|
||||||
|
assert to_email == 'emailtest@example.qc.ca'
|
||||||
|
assert 'DictIA' in subject
|
||||||
|
assert 'connexion' in subject.lower() or 'lien' in subject.lower()
|
||||||
|
assert 'https://example.com/auth/magic-link/abc123' in html_body
|
||||||
|
assert 'https://example.com/auth/magic-link/abc123' in text_body
|
||||||
|
# XSS canary: raw <script> must NOT appear in HTML body (must be escaped)
|
||||||
|
assert '<script>' not in html_body
|
||||||
|
assert '<script>' in html_body or 'Marie' in html_body
|
||||||
|
# 15 minutes mention
|
||||||
|
assert '15' in html_body
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
Reference in New Issue
Block a user