"""Authentication, durable lockout, database identity and session governance.""" from __future__ import annotations from datetime import datetime from functools import wraps import hmac import os from flask import ( current_app, flash, g, has_request_context, jsonify, redirect, render_template, request, session, url_for, ) from werkzeug.routing import BuildError from werkzeug.security import check_password_hash from config import LOGIN_PASSWORD from database.user_models import LoginHistory from services.auth_identity_service import ( KNOWN_ROLES, auth_auto_cutover_min_successes, database_auth_can_authorize, database_auth_is_evaluated, identity_version, legacy_auth_can_authorize, resolve_auth_identity_mode, resolve_effective_auth_identity_mode, resolve_client_ip, role_is_allowed, ) def _bounded_int(name: str, default: int, minimum: int, maximum: int) -> int: try: value = int(os.getenv(name, str(default))) except (TypeError, ValueError): value = default return max(minimum, min(value, maximum)) _DISABLE_LOGIN_REQUESTED = os.getenv("DISABLE_LOGIN", "false").lower() == "true" _ALLOW_INSECURE_LOGIN_BYPASS = ( os.getenv("MOMO_ALLOW_INSECURE_CONFIG_FOR_TESTS", "false").lower() == "true" ) DISABLE_LOGIN = _DISABLE_LOGIN_REQUESTED and _ALLOW_INSECURE_LOGIN_BYPASS MAX_LOGIN_ATTEMPTS = _bounded_int("MOMO_AUTH_MAX_ATTEMPTS", 5, 3, 20) LOCKOUT_DURATION = _bounded_int("MOMO_AUTH_LOCKOUT_SECONDS", 300, 30, 86400) ATTEMPT_RESET_TIME = _bounded_int("MOMO_AUTH_ATTEMPT_WINDOW_SECONDS", 1800, 60, 86400) if DISABLE_LOGIN: print("[Security] 登入驗證僅於明確測試模式停用") elif _DISABLE_LOGIN_REQUESTED: print("[Security] 已拒絕 DISABLE_LOGIN:僅測試環境可使用登入繞過") def get_identity_session(): """Late-bind the database session so tests and workers share one seam.""" from database.manager import get_session return get_session() def get_client_ip() -> str: return resolve_client_ip( request.remote_addr, request.headers.get("X-Forwarded-For"), request.headers.get("X-Real-IP"), ) def _user_agent() -> str: return (request.headers.get("User-Agent") or "")[:256] def _lockout_status(username: str | None, client_ip: str) -> tuple[bool, int, int]: from services.user_service import UserService db_session = get_identity_session() try: return UserService(db_session).get_lockout_status( username=username, ip_address=client_ip, max_attempts=MAX_LOGIN_ATTEMPTS, reset_seconds=ATTEMPT_RESET_TIME, lockout_seconds=LOCKOUT_DURATION, ) finally: db_session.close() def is_ip_locked(ip: str) -> tuple[bool, int]: """Compatibility wrapper backed by durable login_history state.""" locked, remaining, _ = _lockout_status(None, ip) return locked, remaining def record_login_failure(ip: str) -> bool: """Compatibility wrapper; new login flow records username and audit detail.""" from services.user_service import UserService db_session = get_identity_session() try: service = UserService(db_session) recorded = service.record_login( None, "legacy-shared", ip, _user_agent() if has_request_context() else None, LoginHistory.STATUS_FAILED, "invalid_credentials", ) if recorded is None: return True locked, _, _ = service.get_lockout_status( username="legacy-shared", ip_address=ip, max_attempts=MAX_LOGIN_ATTEMPTS, reset_seconds=ATTEMPT_RESET_TIME, lockout_seconds=LOCKOUT_DURATION, ) return locked finally: db_session.close() def clear_login_attempts(_ip: str) -> None: """A durable success event supersedes older failures; no delete is required.""" def validate_password_strength(password): if not password: return False, "密碼不能為空" if len(password) < 8: return False, "密碼長度至少需要 8 個字元" if not any(c.isalpha() for c in password): return False, "密碼必須包含英文字母" if not any(c.isdigit() for c in password): return False, "密碼必須包含數字" return True, None def _legacy_password_matches(input_password: str) -> bool: if not LOGIN_PASSWORD: return False if LOGIN_PASSWORD.startswith(("pbkdf2:", "scrypt:")): return check_password_hash(LOGIN_PASSWORD, input_password) return hmac.compare_digest(input_password, LOGIN_PASSWORD) def _effective_auth_identity_mode(service, configured_mode: str) -> str: if configured_mode != "auto": return configured_mode cutover = service.get_auth_auto_cutover_state( min_successes=auth_auto_cutover_min_successes() ) return resolve_effective_auth_identity_mode( configured_mode, auto_cutover_ready=cutover["ready"], ) def _audit_session_event(status: str, reason: str) -> None: """Best-effort audit for logout/denial; login itself remains fail-closed.""" from services.user_service import UserService db_session = None try: db_session = get_identity_session() UserService(db_session).record_login( session.get("user_id"), session.get("username") or "legacy-shared", get_client_ip(), _user_agent(), status, reason, ) except Exception as exc: print(f"[Security] audit event unavailable: {type(exc).__name__}") finally: if db_session is not None: db_session.close() def _session_state(required_roles=()) -> dict: cached = getattr(g, "_ewoooc_auth_session_state", None) if cached is not None and ( not required_roles or role_is_allowed(cached.get("role"), required_roles) ): return cached if DISABLE_LOGIN: state = {"ok": True, "reason": "test_bypass", "role": "admin"} g._ewoooc_auth_session_state = state return state if not session.get("logged_in"): return {"ok": False, "reason": "authentication_required", "role": None} try: configured_mode = resolve_auth_identity_mode() except ValueError: return {"ok": False, "reason": "identity_policy_invalid", "role": None} auth_source = session.get("auth_source") mode = configured_mode if configured_mode == "auto" and auth_source == "legacy_shared_password": from services.user_service import UserService db_session = None try: db_session = get_identity_session() mode = _effective_auth_identity_mode( UserService(db_session), configured_mode ) except Exception as exc: print(f"[Security] auto cutover validation unavailable: {type(exc).__name__}") return {"ok": False, "reason": "identity_store_unavailable", "role": None} finally: if db_session is not None: db_session.close() role = session.get("role") if auth_source == "database": from services.user_service import UserService db_session = None revoke_reason = None try: db_session = get_identity_session() user = UserService(db_session).get_user_by_id(session.get("user_id")) if not user or not user.is_active or user.role not in KNOWN_ROLES: revoke_reason = "identity_inactive_or_missing" elif session.get("identity_version") != identity_version(user): revoke_reason = "identity_version_changed" else: role = user.role session["role"] = user.role session["username"] = user.username except Exception as exc: print(f"[Security] identity validation unavailable: {type(exc).__name__}") return {"ok": False, "reason": "identity_store_unavailable", "role": None} finally: if db_session is not None: db_session.close() if revoke_reason: _audit_session_event(LoginHistory.STATUS_REVOKED, revoke_reason) session.clear() return {"ok": False, "reason": "session_revoked", "role": None} elif auth_source == "legacy_shared_password": if not legacy_auth_can_authorize(mode) or role != "admin": session.clear() return {"ok": False, "reason": "legacy_session_revoked", "role": None} elif current_app.testing: # Legacy fixtures may exercise business routes without constructing a # production identity cookie. This branch is impossible in production. role = session.get("role") or "admin" if role not in KNOWN_ROLES: session.clear() return {"ok": False, "reason": "unversioned_session", "role": None} else: # Pre-policy cookies never inherit admin privileges from a missing field. session.clear() return {"ok": False, "reason": "unversioned_session", "role": None} if required_roles and not role_is_allowed(role, required_roles): _audit_session_event(LoginHistory.STATUS_DENIED, "role_not_allowed") return {"ok": False, "reason": "authorization_denied", "role": role} state = {"ok": True, "reason": "authenticated", "role": role} g._ewoooc_auth_session_state = state return state def _is_api_request() -> bool: return request.path.startswith("/api/") or "/api/" in request.path def _denied_response(state: dict): reason = state.get("reason") or "authentication_required" if reason == "identity_store_unavailable": if _is_api_request(): return jsonify({"success": False, "error": reason}), 503 return render_template("login.html", error="身分服務暫時無法使用,請稍後再試。"), 503 if reason == "authorization_denied": if _is_api_request(): return jsonify({"success": False, "error": reason}), 403 flash("您沒有權限執行此操作", "danger") try: return redirect(url_for("dashboard.index")) except BuildError: return redirect(url_for("login")) if _is_api_request(): return jsonify({ "success": False, "error": "authentication_required", "message": "此端點需要登入。", }), 401 return redirect(url_for("login")) def login_required(f): @wraps(f) def decorated_view(*args, **kwargs): state = _session_state() if not state["ok"]: return _denied_response(state) return f(*args, **kwargs) return decorated_view def require_authenticated_request(*required_roles): """Central policy hook with API/page aware denial semantics.""" state = _session_state(required_roles) if state["ok"]: return None return _denied_response(state) def internal_key_required(f): """Require the shared internal transport key for non-session service calls.""" @wraps(f) def decorated_view(*args, **kwargs): expected = os.getenv("INTERNAL_API_KEY", "").strip() provided = request.headers.get("X-Internal-Key", "").strip() if not expected: return jsonify({"success": False, "error": "internal_auth_not_configured"}), 503 if not provided or not hmac.compare_digest(provided, expected): return jsonify({"success": False, "error": "invalid_internal_key"}), 401 return f(*args, **kwargs) return decorated_view def get_current_user(): state = _session_state() if not state["ok"]: return None return { "logged_in": True, "user_id": session.get("user_id"), "login_time": session.get("login_time"), "client_ip": session.get("client_ip"), "role": state.get("role"), "username": session.get("username"), "auth_source": session.get("auth_source"), } def role_required(*roles): unknown = set(roles) - KNOWN_ROLES if unknown: raise ValueError(f"Unknown roles: {', '.join(sorted(unknown))}") def decorator(f): @wraps(f) def decorated_view(*args, **kwargs): state = _session_state(roles) if not state["ok"]: return _denied_response(state) return f(*args, **kwargs) return decorated_view return decorator def admin_required(f): return role_required("admin")(f) def _set_database_session(user, client_ip: str, password_change_required: bool) -> None: session.clear() session.permanent = True session.update({ "logged_in": True, "login_time": datetime.now().isoformat(), "client_ip": client_ip, "user_id": user.id, "role": user.role, "username": user.username, "auth_source": "database", "identity_version": identity_version(user), "password_change_required": bool(password_change_required), }) def _set_legacy_session(client_ip: str) -> None: session.clear() session.permanent = True session.update({ "logged_in": True, "login_time": datetime.now().isoformat(), "client_ip": client_ip, "role": "admin", "username": "legacy-admin", "auth_source": "legacy_shared_password", }) def init_auth_routes(app): """Register database-first login and audited logout routes.""" @app.route("/login", methods=["GET", "POST"]) def login(): from services.user_service import UserService client_ip = get_client_ip() configured_mode = resolve_auth_identity_mode() mode = configured_mode username = (request.form.get("username") or "").strip() lockout_identity = username or "legacy-shared" db_session = None try: db_session = get_identity_session() service = UserService(db_session) mode = _effective_auth_identity_mode(service, configured_mode) locked, remaining_seconds, failure_count = service.get_lockout_status( username=lockout_identity, ip_address=client_ip, max_attempts=MAX_LOGIN_ATTEMPTS, reset_seconds=ATTEMPT_RESET_TIME, lockout_seconds=LOCKOUT_DURATION, ) except Exception as exc: print(f"[Security] durable lockout unavailable: {type(exc).__name__}") if db_session is not None: db_session.close() return render_template( "login.html", error="身分服務暫時無法使用,請稍後再試。", auth_mode=mode, username=username, ), 503 try: if locked: minutes, seconds = divmod(remaining_seconds, 60) error = f"登入嘗試已暫停,請在 {minutes} 分 {seconds} 秒後再試。" return render_template( "login.html", error=error, auth_mode=mode, username=username ), 429 if request.method == "GET": return render_template( "login.html", error=None, auth_mode=mode, username=username ) input_password = request.form.get("password") or "" if not input_password: return render_template( "login.html", error="請輸入密碼", auth_mode=mode, username=username, ), 400 db_user = None password_change_required = False if username and database_auth_is_evaluated(mode): db_user, _, password_change_required = service.authenticate( username, input_password ) legacy_valid = ( legacy_auth_can_authorize(mode) and _legacy_password_matches(input_password) ) database_valid = bool( db_user and database_auth_can_authorize(mode) ) if mode == "shadow" and username: shadow_event = service.record_login( db_user.id if db_user else None, username, client_ip, _user_agent(), LoginHistory.STATUS_SHADOW, "database_match" if db_user else "database_miss", ) if shadow_event is None: return render_template( "login.html", error="登入稽核暫時無法使用,請稍後再試。", auth_mode=mode, username=username, ), 503 if database_valid: history = service.record_login( db_user.id, db_user.username, client_ip, _user_agent(), LoginHistory.STATUS_SUCCESS, "auth_source=database", ) if history is None: return render_template( "login.html", error="登入稽核暫時無法使用,請稍後再試。", auth_mode=mode, username=username, ), 503 _set_database_session(db_user, client_ip, password_change_required) return redirect(url_for("dashboard.index")) if legacy_valid: history = service.record_login( None, "legacy-shared", client_ip, _user_agent(), LoginHistory.STATUS_SUCCESS, "auth_source=legacy_shared_password", ) if history is None: return render_template( "login.html", error="登入稽核暫時無法使用,請稍後再試。", auth_mode=mode, username=username, ), 503 _set_legacy_session(client_ip) return redirect(url_for("dashboard.index")) failure = service.record_login( db_user.id if db_user else None, lockout_identity, client_ip, _user_agent(), LoginHistory.STATUS_FAILED, "invalid_credentials", ) if failure is None: return render_template( "login.html", error="登入稽核暫時無法使用,請稍後再試。", auth_mode=mode, username=username, ), 503 now_locked, remaining_seconds, failure_count = service.get_lockout_status( username=lockout_identity, ip_address=client_ip, max_attempts=MAX_LOGIN_ATTEMPTS, reset_seconds=ATTEMPT_RESET_TIME, lockout_seconds=LOCKOUT_DURATION, ) if now_locked: service.record_login( None, lockout_identity, client_ip, _user_agent(), LoginHistory.STATUS_LOCKED, "max_attempts_reached", ) error = f"登入嘗試已暫停 {LOCKOUT_DURATION // 60} 分鐘。" return render_template( "login.html", error=error, auth_mode=mode, username=username ), 429 remaining_attempts = max(0, MAX_LOGIN_ATTEMPTS - failure_count) error = f"帳號或密碼錯誤。(剩餘嘗試次數:{remaining_attempts})" return render_template( "login.html", error=error, auth_mode=mode, username=username ), 401 finally: db_session.close() @app.route("/logout") def logout(): if session.get("logged_in"): _audit_session_event(LoginHistory.STATUS_LOGOUT, "user_logout") session.clear() return redirect(url_for("login")) print("[Security] Auth identity governance module loaded")