import os from email_validator import EmailNotValidError, validate_email from flask import Blueprint, redirect, request, session from flask_login import current_user, login_required, login_user, logout_user from werkzeug.security import check_password_hash, generate_password_hash from src.auth import db, mailer, tokens from src.auth.models import User from src.auth.oauth import oauth from src.auth.setup import safe_next from src.utils import logger auth_bp = Blueprint("auth", __name__, url_prefix="/auth") def _post_login_url(user_id: int) -> str: try: from src.subscriptions import db as sub_db if sub_db.has_active_subscription(user_id): return "/compte/admin" except Exception: pass return "/compte/abonnement" MIN_PASSWORD_LENGTH = 8 # Hash bidon pré-calculé pour uniformiser le timing login _DUMMY_HASH = generate_password_hash("dummy-password-for-timing") def resolve_oauth_user( provider: str, subject: str, email: str, email_verified: bool ) -> User: identity = db.get_oauth_identity(provider, subject) if identity is not None: return User(db.get_user_by_id(identity["user_id"])) row = db.get_user_by_email(email) if row is not None: db.link_oauth_identity(provider, subject, row["id"]) if email_verified and not row["email_verified"]: db.set_email_verified(row["id"]) return User(db.get_user_by_id(row["id"])) user_id = db.create_oauth_user(email) db.link_oauth_identity(provider, subject, user_id) return User(db.get_user_by_id(user_id)) def _redirect_with_error(path: str, error: str, email: str | None = None): url = f"{path}?error={error}" if email: url += f"&email={email}" return redirect(url) @auth_bp.route("/signup", methods=["POST"]) def signup(): email = (request.form.get("email") or "").strip() password = request.form.get("password") or "" password_confirm = request.form.get("password_confirm") or "" try: valid = validate_email(email, check_deliverability=False) email = valid.normalized.lower() except EmailNotValidError: return _redirect_with_error("/inscription", "invalid_email", email) if len(password) < MIN_PASSWORD_LENGTH: return _redirect_with_error("/inscription", "password_too_short", email) if password != password_confirm: return _redirect_with_error("/inscription", "password_mismatch", email) if db.get_user_by_email(email) is not None: return _redirect_with_error("/inscription", "email_taken", email) user_id = db.create_user(email, generate_password_hash(password)) token = tokens.create_verification_token(user_id) try: mailer.send_verification_email(email, token) except Exception: logger.exception("Échec d'envoi de l'email de vérification") db.delete_user(user_id) return _redirect_with_error("/inscription", "email_send_failed", email) return redirect("/connexion?pending_verification=1") @auth_bp.route("/verify-email", methods=["GET"]) def verify_email(): token = request.args.get("token") or "" if not token: return redirect("/verification-email?error=invalid_token") user_id = tokens.consume_verification_token(token) if user_id is None: return redirect("/verification-email?error=invalid_token") db.set_email_verified(user_id) login_user(User(db.get_user_by_id(user_id)), remember=True) return redirect("/compte/abonnement/mes-infos") @auth_bp.route("/login", methods=["POST"]) def login(): email = (request.form.get("email") or "").strip().lower() password = request.form.get("password") or "" next_url = request.form.get("next") row = db.get_user_by_email(email) if row is None: check_password_hash(_DUMMY_HASH, password) # uniformiser le temps return _redirect_with_error("/connexion", "invalid_credentials", email) if row["password_hash"] is None or not check_password_hash( row["password_hash"], password ): return _redirect_with_error("/connexion", "invalid_credentials", email) if not row["email_verified"]: return _redirect_with_error("/connexion", "email_not_verified", email) login_user(User(row), remember=True) return redirect(safe_next(next_url, fallback=_post_login_url(row["id"]))) @auth_bp.route("/logout", methods=["POST"]) def logout(): logout_user() return redirect("/") @auth_bp.route("/request-password-reset", methods=["POST"]) def request_password_reset(): email = (request.form.get("email") or "").strip().lower() try: valid = validate_email(email, check_deliverability=False) email = valid.normalized.lower() except EmailNotValidError: return redirect("/mot-de-passe-oublie?pending=1") row = db.get_user_by_email(email) if row is None: return redirect("/mot-de-passe-oublie?pending=1") token = tokens.create_password_reset_token(row["id"]) try: mailer.send_reset_email(email, token) except Exception: logger.exception("Échec d'envoi de l'email de réinitialisation") return _redirect_with_error("/mot-de-passe-oublie", "email_send_failed", email) return redirect("/mot-de-passe-oublie?pending=1") @auth_bp.route("/reset-password", methods=["POST"]) def reset_password(): token = request.form.get("token") or "" password = request.form.get("password") or "" password_confirm = request.form.get("password_confirm") or "" user_id = tokens.validate_password_reset_token(token) if user_id is None: return redirect( f"/reinitialiser-mot-de-passe?token={token}&error=invalid_token" ) if len(password) < MIN_PASSWORD_LENGTH: return redirect( f"/reinitialiser-mot-de-passe?token={token}&error=password_too_short" ) if password != password_confirm: return redirect( f"/reinitialiser-mot-de-passe?token={token}&error=password_mismatch" ) consumed = tokens.consume_password_reset_token(token) if consumed is None: return redirect( f"/reinitialiser-mot-de-passe?token={token}&error=invalid_token" ) db.update_password_hash(consumed, generate_password_hash(password)) return redirect("/connexion?password_changed=1") @auth_bp.route("/change-password", methods=["POST"]) @login_required def change_password(): current_pw = request.form.get("current_password") or "" password = request.form.get("password") or "" password_confirm = request.form.get("password_confirm") or "" row = db.get_user_by_id(current_user.id) if row["password_hash"] is None: return _redirect_with_error("/compte/admin", "no_password_set") if not check_password_hash(row["password_hash"], current_pw): return _redirect_with_error("/compte/admin", "invalid_current_password") if len(password) < MIN_PASSWORD_LENGTH: return _redirect_with_error("/compte/admin", "password_too_short") if password != password_confirm: return _redirect_with_error("/compte/admin", "password_mismatch") db.update_password_hash(current_user.id, generate_password_hash(password)) return redirect("/compte/admin?password_changed=1") @auth_bp.route("/change-email", methods=["POST"]) @login_required def change_email(): email = (request.form.get("email") or "").strip() try: valid = validate_email(email, check_deliverability=False) email = valid.normalized.lower() except EmailNotValidError: return _redirect_with_error("/compte/admin", "invalid_email") if db.get_user_by_email(email) is not None: return _redirect_with_error("/compte/admin", "email_taken") db.set_pending_email(current_user.id, email) db.delete_email_verification_tokens_for_user(current_user.id) token = tokens.create_verification_token(current_user.id) try: mailer.send_email_change_email(email, token) except Exception: logger.exception("Échec d'envoi de l'email de changement d'adresse") return _redirect_with_error("/compte/admin", "email_send_failed") return redirect("/compte/admin?email_pending=1") @auth_bp.route("/confirm-email-change", methods=["GET"]) def confirm_email_change(): token = request.args.get("token") or "" user_id = tokens.consume_verification_token(token) if user_id is None: return redirect("/compte/admin?error=invalid_token") new_email = db.promote_pending_email(user_id) if new_email is None: return redirect("/compte/admin?error=invalid_token") return redirect("/compte/admin?email_changed=1") @auth_bp.route("/delete-account", methods=["POST"]) @login_required def delete_account(): current_pw = request.form.get("current_password") or "" row = db.get_user_by_id(current_user.id) if row["password_hash"] is not None and not check_password_hash( row["password_hash"], current_pw ): return _redirect_with_error("/compte/admin", "invalid_current_password") user_id = current_user.id db.delete_email_verification_tokens_for_user(user_id) db.delete_password_reset_tokens_for_user(user_id) db.delete_user(user_id) logout_user() return redirect("/?account_deleted=1") @auth_bp.route("/linkedin", methods=["GET"]) def linkedin_login(): session["oauth_next"] = safe_next( request.args.get("next"), fallback="/compte/admin" ) redirect_uri = f"{os.getenv('APP_BASE_URL', '')}/auth/linkedin/callback" return oauth.linkedin.authorize_redirect(redirect_uri) @auth_bp.route("/linkedin/callback", methods=["GET"]) def linkedin_callback(): oauth_next = session.pop("oauth_next", None) if request.args.get("error"): # L'utilisateur a refusé / annulé l'autorisation côté LinkedIn. return _redirect_with_error("/connexion", "oauth_cancelled") try: # LinkedIn ne retourne pas le nonce dans l'ID token (non-conformité OIDC) : # authorize_access_token() lève MissingClaimError("nonce") après avoir échangé # le code avec succès. Le token est déjà stocké dans oauth.linkedin.token à ce # moment, donc on capture cette erreur précise et on continue. try: oauth.linkedin.authorize_access_token() except Exception as exc: if "nonce" not in str(exc) or not oauth.linkedin.token: raise resp = oauth.linkedin.get("https://api.linkedin.com/v2/userinfo") resp.raise_for_status() userinfo = resp.json() except Exception: logger.exception("Échec de l'échange de token LinkedIn") return _redirect_with_error("/connexion", "oauth_failed") subject = userinfo.get("sub") email = (userinfo.get("email") or "").strip().lower() if not subject or not email: logger.error("Réponse LinkedIn sans sub/email : %s", userinfo) return _redirect_with_error("/connexion", "oauth_failed") user = resolve_oauth_user( "linkedin", subject, email, bool(userinfo.get("email_verified")) ) login_user(user, remember=True) return redirect(safe_next(oauth_next, fallback=_post_login_url(user.id)))