Files
ewoooc/auth.py

590 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")