feat(governance): automate identity and comparison coverage

This commit is contained in:
ogt
2026-07-14 16:15:41 +08:00
parent 2dcc2122c4
commit 4bfb9dbefd
31 changed files with 2763 additions and 578 deletions

783
auth.py
View File

@@ -1,400 +1,589 @@
"""Authentication, durable lockout, database identity and session governance."""
from __future__ import annotations
from datetime import datetime
from functools import wraps
import hmac
import os
import time
from flask import jsonify, render_template, redirect, url_for, request, session, flash
from functools import wraps
from werkzeug.security import check_password_hash
from config import LOGIN_PASSWORD
from datetime import datetime, timedelta
# ==========================================
# 🔓 登入功能開關
# ==========================================
# 登入繞過只允許測試環境明確開啟,避免 production 因單一 env 誤設而全站失守。
_DISABLE_LOGIN_REQUESTED = os.getenv('DISABLE_LOGIN', 'false').lower() == 'true'
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'
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("⚠️ 警告:登入驗證已關閉 (DISABLE_LOGIN=true)")
print("[Security] 登入驗證僅於明確測試模式停用")
elif _DISABLE_LOGIN_REQUESTED:
print("[Security] 已拒絕 DISABLE_LOGIN僅測試環境可使用登入繞過")
# ==========================================
# 🔒 登入失敗追蹤與帳號鎖定機制
# ==========================================
# 登入失敗記錄IP-based
# 格式:{ip: {'count': 失敗次數, 'locked_until': 鎖定到期時間, 'first_attempt': 首次失敗時間}}
LOGIN_ATTEMPTS = {}
def get_identity_session():
"""Late-bind the database session so tests and workers share one seam."""
from database.manager import get_session
# 鎖定設定
MAX_LOGIN_ATTEMPTS = 5 # 最多失敗次數
LOCKOUT_DURATION = 300 # 鎖定時長(秒)= 5 分鐘
ATTEMPT_RESET_TIME = 1800 # 失敗記錄重置時間(秒)= 30 分鐘
return get_session()
def get_client_ip():
"""
取得客戶端 IP 位址(支援代理伺服器)
"""
if request.headers.get('X-Forwarded-For'):
# 如果通過代理,取第一個 IP
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
elif request.headers.get('X-Real-IP'):
return request.headers.get('X-Real-IP')
else:
return request.remote_addr
def is_ip_locked(ip):
"""
檢查 IP 是否被鎖定
def get_client_ip() -> str:
return resolve_client_ip(
request.remote_addr,
request.headers.get("X-Forwarded-For"),
request.headers.get("X-Real-IP"),
)
Returns:
tuple: (is_locked, remaining_seconds)
"""
if ip not in LOGIN_ATTEMPTS:
return False, 0
attempt_data = LOGIN_ATTEMPTS[ip]
def _user_agent() -> str:
return (request.headers.get("User-Agent") or "")[:256]
# 檢查是否已鎖定
if 'locked_until' in attempt_data:
locked_until = attempt_data['locked_until']
current_time = time.time()
if current_time < locked_until:
# 仍在鎖定期間
remaining = int(locked_until - current_time)
return True, remaining
else:
# 鎖定期已過,清除記錄
del LOGIN_ATTEMPTS[ip]
return False, 0
def _lockout_status(username: str | None, client_ip: str) -> tuple[bool, int, int]:
from services.user_service import UserService
return False, 0
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 record_login_failure(ip):
"""
記錄登入失敗,並檢查是否需要鎖定
Returns:
bool: 是否已達到鎖定條件
"""
current_time = time.time()
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
if ip not in LOGIN_ATTEMPTS:
LOGIN_ATTEMPTS[ip] = {
'count': 1,
'first_attempt': current_time
}
return False
attempt_data = LOGIN_ATTEMPTS[ip]
def record_login_failure(ip: str) -> bool:
"""Compatibility wrapper; new login flow records username and audit detail."""
from services.user_service import UserService
# 檢查是否超過重置時間30分鐘無活動則重置計數
if current_time - attempt_data.get('first_attempt', 0) > ATTEMPT_RESET_TIME:
LOGIN_ATTEMPTS[ip] = {
'count': 1,
'first_attempt': current_time
}
return False
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()
# 增加失敗次數
attempt_data['count'] += 1
# 檢查是否達到鎖定條件
if attempt_data['count'] >= MAX_LOGIN_ATTEMPTS:
attempt_data['locked_until'] = current_time + LOCKOUT_DURATION
return True
def clear_login_attempts(_ip: str) -> None:
"""A durable success event supersedes older failures; no delete is required."""
return False
def clear_login_attempts(ip):
"""
清除登入失敗記錄(登入成功時調用)
"""
if ip in LOGIN_ATTEMPTS:
del LOGIN_ATTEMPTS[ip]
def validate_password_strength(password):
"""
驗證密碼強度
要求:
- 至少 8 個字元
- 包含英文字母
- 包含數字
Returns:
tuple: (is_valid, error_message)
"""
if not password:
return False, "密碼不能為空"
if len(password) < 8:
return False, "密碼長度至少需要 8 個字元"
has_letter = any(c.isalpha() for c in password)
has_digit = any(c.isdigit() for c in password)
if not has_letter:
if not any(c.isalpha() for c in password):
return False, "密碼必須包含英文字母"
if not has_digit:
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):
"""
權限驗證裝飾器:確保使用者必須登入才能存取特定路由。
若環境變數 DISABLE_LOGIN=true則跳過驗證直接放行。
"""
@wraps(f)
def decorated_view(*args, **kwargs):
# 如果登入功能已關閉,直接放行
if DISABLE_LOGIN:
return f(*args, **kwargs)
# 檢查 session 中是否有登入標記
if not session.get('logged_in'):
# 如果未登入,跳轉至登入頁面
return redirect(url_for('login'))
state = _session_state()
if not state["ok"]:
return _denied_response(state)
return f(*args, **kwargs)
return decorated_view
def require_authenticated_request():
"""供中央 HTTP policy 使用API 回 401頁面導向登入。"""
if DISABLE_LOGIN or session.get('logged_in'):
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
is_api_request = request.path.startswith('/api/') or '/api/' in request.path
if is_api_request:
return jsonify({
'success': False,
'error': 'authentication_required',
'message': '此端點需要登入。',
}), 401
return redirect(url_for('login'))
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()
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
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 jsonify({"success": False, "error": "invalid_internal_key"}), 401
return f(*args, **kwargs)
return decorated_view
def get_current_user():
"""
取得目前登入的使用者資訊
Returns:
dict: 包含使用者資訊的字典,如果未登入則返回 None
"""
if not session.get('logged_in'):
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': session.get('role', 'admin'), # 預設為 admin向後兼容
'username': session.get('username', 'admin'),
'auth_source': session.get('auth_source', 'legacy_shared_password'),
"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))}")
Args:
*roles: 允許的角色列表,例如 'admin', 'manager', 'user'
Usage:
@role_required('admin')
def admin_only_page():
...
@role_required('admin', 'manager')
def admin_or_manager_page():
...
若環境變數 DISABLE_LOGIN=true則跳過驗證直接放行。
"""
def decorator(f):
@wraps(f)
def decorated_view(*args, **kwargs):
# 如果登入功能已關閉,直接放行
if DISABLE_LOGIN:
return f(*args, **kwargs)
# 先檢查登入狀態
if not session.get('logged_in'):
return redirect(url_for('login'))
# 取得使用者角色(預設為 admin向後兼容
user_role = session.get('role', 'admin')
# 檢查角色權限
if user_role not in roles:
# 權限不足,返回 403
flash('您沒有權限存取此頁面', 'danger')
return redirect(url_for('dashboard.index'))
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):
"""
管理員權限驗證裝飾器:只允許 admin 角色存取
return role_required("admin")(f)
這是 role_required('admin') 的便捷寫法
若環境變數 DISABLE_LOGIN=true則跳過驗證直接放行。
"""
@wraps(f)
def decorated_view(*args, **kwargs):
# 如果登入功能已關閉,直接放行
if DISABLE_LOGIN:
return f(*args, **kwargs)
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),
})
# 先檢查登入狀態
if not session.get('logged_in'):
return redirect(url_for('login'))
# 取得使用者角色(預設為 admin向後兼容
user_role = session.get('role', 'admin')
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",
})
# 檢查是否為管理員
if user_role != 'admin':
flash('此功能僅限管理員使用', 'danger')
return redirect(url_for('dashboard'))
return f(*args, **kwargs)
return decorated_view
# ==========================================
# 路由初始化
# ==========================================
def init_auth_routes(app):
"""
初始化驗證相關路由:註冊 /login 與 /logout。
"""
"""Register database-first login and audited logout routes."""
@app.route('/login', methods=['GET', 'POST'])
@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"
if request.method == 'POST':
print(f"🔐 收到登入請求 | IP: {client_ip}")
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
# 1. 檢查 IP 是否被鎖定
is_locked, remaining_seconds = is_ip_locked(client_ip)
if is_locked:
minutes = remaining_seconds // 60
seconds = remaining_seconds % 60
error = f'登入失敗次數過多,帳號已鎖定。請在 {minutes}{seconds} 秒後再試。'
print(f"⚠️ 登入嘗試被拒絕 | IP: {client_ip} | 原因: 帳號鎖定")
return render_template('login.html', error=error), 429
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
# 2. 取得輸入的密碼
input_password = request.form.get('password')
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:
error = '請輸入密碼'
return render_template('login.html', error=error), 400
return render_template(
"login.html",
error="請輸入密碼",
auth_mode=mode,
username=username,
), 400
# 3. 驗證密碼
# 注意LOGIN_PASSWORD 可能是明文或雜湊值
# 首先檢查是否為雜湊值(以 pbkdf2:sha256 開頭)
is_password_valid = False
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
)
if LOGIN_PASSWORD.startswith('pbkdf2:sha256:'):
# 使用雜湊比對
is_password_valid = check_password_hash(LOGIN_PASSWORD, input_password)
else:
# 向後兼容:明文比對(僅用於過渡期)
is_password_valid = hmac.compare_digest(input_password, LOGIN_PASSWORD)
if is_password_valid:
print("⚠️ 警告:系統仍在使用明文密碼,請盡快執行密碼雜湊更新")
legacy_valid = (
legacy_auth_can_authorize(mode)
and _legacy_password_matches(input_password)
)
database_valid = bool(
db_user and database_auth_can_authorize(mode)
)
# 4. 驗證結果處理
if is_password_valid:
# 登入成功
session.clear()
session.permanent = True
session['logged_in'] = True
session['login_time'] = datetime.now().isoformat()
session['client_ip'] = client_ip
session['role'] = 'admin'
session['username'] = 'legacy-admin'
session['auth_source'] = 'legacy_shared_password'
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
# 清除失敗記錄
clear_login_attempts(client_ip)
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"))
print(f"✅ 登入成功 | IP: {client_ip}")
return redirect(url_for('dashboard.index'))
else:
# 登入失敗
is_now_locked = record_login_failure(client_ip)
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"))
if is_now_locked:
error = f'登入失敗次數過多,帳號已鎖定 {LOCKOUT_DURATION // 60} 分鐘。'
print(f"🔒 帳號已鎖定 | IP: {client_ip} | 原因: 連續 {MAX_LOGIN_ATTEMPTS} 次失敗")
else:
remaining_attempts = MAX_LOGIN_ATTEMPTS - LOGIN_ATTEMPTS.get(client_ip, {}).get('count', 0)
error = f'密碼錯誤,請重新輸入。(剩餘嘗試次數:{remaining_attempts}'
print(f"❌ 登入失敗 | IP: {client_ip} | 剩餘嘗試: {remaining_attempts}")
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
return render_template('login.html', error=error), 401
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
# GET 請求:檢查是否被鎖定
is_locked, remaining_seconds = is_ip_locked(client_ip)
if is_locked:
minutes = remaining_seconds // 60
seconds = remaining_seconds % 60
error = f'登入失敗次數過多,帳號已鎖定。請在 {minutes}{seconds} 秒後再試。'
return render_template('login.html', error=error), 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()
# 顯示登入頁面
return render_template('login.html', error=None)
@app.route('/logout')
@app.route("/logout")
def logout():
"""
登出路由:清除 session 並導回登入頁。
"""
client_ip = get_client_ip()
if session.get("logged_in"):
_audit_session_event(LoginHistory.STATUS_LOGOUT, "user_logout")
session.clear()
print(f"👋 使用者已登出 | IP: {client_ip}")
return redirect(url_for('login'))
return redirect(url_for("login"))
print("✅ Auth 模組已載入(增強安全版本)")
print("[Security] Auth identity governance module loaded")