feat: аутентификация через Authentik OIDC вместо пароля
This commit is contained in:
@@ -691,47 +691,27 @@ def yookassa_webhook():
|
||||
# ====================================================
|
||||
# Admin Cabinet Views (ЛК Администратора)
|
||||
# ====================================================
|
||||
LOGIN_TEMPLATE = """
|
||||
|
||||
# Шаблон страницы ошибки доступа
|
||||
ACCESS_DENIED_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ИКП Панель | Авторизация</title>
|
||||
<title>ИКП Админ | Ошибка авторизации</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Montserrat:wght@700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; background-color: #0a0a0f; }
|
||||
.font-display { font-family: 'Montserrat', sans-serif; }
|
||||
</style>
|
||||
<style>body { font-family: 'Inter', sans-serif; background-color: #0a0a0f; }</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center relative overflow-hidden bg-[radial-gradient(circle_at_center,_rgba(124,58,237,0.1)_0%,_rgba(10,10,15,1)_100%)]">
|
||||
<div class="absolute -top-40 -left-40 w-96 h-96 bg-purple-600/10 rounded-full blur-[100px] pointer-events-none"></div>
|
||||
<div class="absolute -bottom-40 -right-40 w-96 h-96 bg-blue-600/10 rounded-full blur-[100px] pointer-events-none"></div>
|
||||
|
||||
<div class="w-full max-w-md p-8 bg-slate-900/60 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl relative z-10">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="font-display font-black text-3xl text-white tracking-wider uppercase mb-2">ИКП Админ</h2>
|
||||
<p class="text-xs font-mono text-purple-400 uppercase tracking-widest">Панель управления оплатами</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="mb-6 p-4 bg-red-950/50 border border-red-500/30 text-red-200 text-sm rounded-lg flex items-center gap-2">
|
||||
<span>⚠️</span> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ url_for('admin_login') }}" method="POST" class="space-y-6">
|
||||
<div>
|
||||
<label for="password" class="block text-xs font-bold text-blue-400 uppercase tracking-wider mb-2">Пароль администратора</label>
|
||||
<input type="password" id="password" name="password" required placeholder="••••••••"
|
||||
class="w-full px-4 py-3 bg-black/50 border border-white/10 rounded-xl text-white placeholder-slate-600 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full py-3 bg-purple-600 hover:bg-purple-500 text-white font-display font-black text-sm uppercase tracking-wider rounded-xl shadow-lg shadow-purple-600/20 hover:shadow-purple-600/40 transform hover:-translate-y-0.5 transition duration-200">
|
||||
Войти в кабинет
|
||||
</button>
|
||||
</form>
|
||||
<body class="min-h-screen flex items-center justify-center bg-[radial-gradient(circle_at_center,_rgba(124,58,237,0.1)_0%,_rgba(10,10,15,1)_100%)]">
|
||||
<div class="w-full max-w-md p-8 bg-slate-900/60 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl text-center">
|
||||
<div class="text-5xl mb-4">🔒</div>
|
||||
<h2 class="font-display font-black text-2xl text-white uppercase mb-2" style="font-family:'Montserrat',sans-serif">Ошибка доступа</h2>
|
||||
<p class="text-slate-400 text-sm mb-6">{{ error }}</p>
|
||||
<a href="{{ login_url }}" class="inline-block w-full py-3 bg-purple-600 hover:bg-purple-500 text-white font-bold text-sm uppercase tracking-wider rounded-xl transition">
|
||||
Войти через Authentik
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1227,98 +1207,108 @@ DASHBOARD_TEMPLATE = """
|
||||
@app.route('/admin', methods=['GET'])
|
||||
def admin_dashboard():
|
||||
if not session.get('logged_in'):
|
||||
return render_template_string(LOGIN_TEMPLATE)
|
||||
# Генерируем state для защиты от CSRF
|
||||
state = str(uuid.uuid4())
|
||||
session['oauth_state'] = state
|
||||
|
||||
# Fetch registrations and services
|
||||
base = config.AUTHENTIK_BASE_URL.rstrip('/')
|
||||
params = {
|
||||
'client_id': config.AUTHENTIK_CLIENT_ID,
|
||||
'redirect_uri': config.AUTHENTIK_REDIRECT_URI,
|
||||
'response_type': 'code',
|
||||
'scope': 'openid email profile',
|
||||
'state': state,
|
||||
}
|
||||
auth_url = f"{base}/application/o/authorize/?" + "&".join(f"{k}={v}" for k, v in params.items())
|
||||
return redirect(auth_url)
|
||||
|
||||
# Загрузить данные
|
||||
registrations = db.get_all_web_registrations()
|
||||
services = db.get_all_services_list()
|
||||
|
||||
# Calculate stats
|
||||
stats = {
|
||||
"total": len(registrations),
|
||||
"paid_count": sum(1 for r in registrations if r["payment_status"] == "paid"),
|
||||
"pending_count": sum(1 for r in registrations if r["payment_status"] != "paid"),
|
||||
"total_revenue": sum(r["amount"] for r in registrations if r["payment_status"] == "paid")
|
||||
}
|
||||
|
||||
return render_template_string(DASHBOARD_TEMPLATE, registrations=registrations, stats=stats, services=services)
|
||||
|
||||
@app.route('/admin/services/add', methods=['POST'])
|
||||
def admin_add_service():
|
||||
if not session.get('logged_in'):
|
||||
return jsonify({"status": "error", "message": "Не авторизован"}), 403
|
||||
|
||||
try:
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({"status": "error", "message": "Отсутствуют данные запроса"}), 400
|
||||
|
||||
service_id = data.get("id", "").strip()
|
||||
name = data.get("name", "").strip()
|
||||
price = data.get("price", "").strip()
|
||||
price_numeric = data.get("price_numeric")
|
||||
description = data.get("description", "").strip()
|
||||
|
||||
if not service_id or not name or not price or price_numeric is None:
|
||||
return jsonify({"status": "error", "message": "Заполните все обязательные поля"}), 400
|
||||
|
||||
try:
|
||||
price_numeric = float(price_numeric)
|
||||
except ValueError:
|
||||
return jsonify({"status": "error", "message": "Числовая цена должна быть числом"}), 400
|
||||
|
||||
success = db.add_service(service_id, name, price, price_numeric, description)
|
||||
if success:
|
||||
return jsonify({"status": "success", "message": "Услуга успешно добавлена"})
|
||||
else:
|
||||
return jsonify({"status": "error", "message": "Услуга с таким ID уже существует"}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
|
||||
@app.route('/admin/services/edit/<service_id>', methods=['POST'])
|
||||
def admin_edit_service(service_id):
|
||||
if not session.get('logged_in'):
|
||||
return jsonify({"status": "error", "message": "Не авторизован"}), 403
|
||||
|
||||
try:
|
||||
data = request.json
|
||||
if not data:
|
||||
return jsonify({"status": "error", "message": "Отсутствуют данные запроса"}), 400
|
||||
|
||||
name = data.get("name", "").strip()
|
||||
price = data.get("price", "").strip()
|
||||
price_numeric = data.get("price_numeric")
|
||||
description = data.get("description", "").strip()
|
||||
is_active = data.get("is_active", 1)
|
||||
|
||||
if not name or not price or price_numeric is None:
|
||||
return jsonify({"status": "error", "message": "Заполните все обязательные поля"}), 400
|
||||
|
||||
try:
|
||||
price_numeric = float(price_numeric)
|
||||
except ValueError:
|
||||
return jsonify({"status": "error", "message": "Числовая цена должна быть числом"}), 400
|
||||
|
||||
success = db.update_service(service_id, name, price, price_numeric, description, int(is_active))
|
||||
if success:
|
||||
return jsonify({"status": "success", "message": "Услуга успешно сохранена"})
|
||||
else:
|
||||
return jsonify({"status": "error", "message": "Не удалось обновить услугу"}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
@app.route('/admin/callback', methods=['GET'])
|
||||
def admin_callback():
|
||||
"""OIDC callback от Authentik."""
|
||||
error = request.args.get('error')
|
||||
if error:
|
||||
print(f"[OIDC] Ошибка авторизации: {error} — {request.args.get('error_description', '')}")
|
||||
base = config.AUTHENTIK_BASE_URL.rstrip('/')
|
||||
login_url = f"{base}/application/o/authorize/?client_id={config.AUTHENTIK_CLIENT_ID}&redirect_uri={config.AUTHENTIK_REDIRECT_URI}&response_type=code&scope=openid+email+profile"
|
||||
return render_template_string(ACCESS_DENIED_TEMPLATE,
|
||||
error=f"Ошибка от Authentik: {error}",
|
||||
login_url=login_url), 403
|
||||
|
||||
@app.route('/admin/login', methods=['POST'])
|
||||
# Проверка state против CSRF
|
||||
state = request.args.get('state')
|
||||
if not state or state != session.get('oauth_state'):
|
||||
print("[OIDC] Неверный state — возможная CSRF-атака")
|
||||
return "403 Invalid state", 403
|
||||
session.pop('oauth_state', None)
|
||||
|
||||
code = request.args.get('code')
|
||||
if not code:
|
||||
return "403 No code returned", 403
|
||||
|
||||
# Обмен кода на токен
|
||||
base = config.AUTHENTIK_BASE_URL.rstrip('/')
|
||||
token_url = f"{base}/application/o/token/"
|
||||
try:
|
||||
token_resp = requests.post(token_url, data={
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': config.AUTHENTIK_REDIRECT_URI,
|
||||
'client_id': config.AUTHENTIK_CLIENT_ID,
|
||||
'client_secret': config.AUTHENTIK_CLIENT_SECRET,
|
||||
}, timeout=15)
|
||||
token_data = token_resp.json()
|
||||
except Exception as e:
|
||||
print(f"[OIDC] Ошибка получения токена: {e}")
|
||||
return "500 Token exchange failed", 500
|
||||
|
||||
if 'error' in token_data:
|
||||
print(f"[OIDC] Ошибка токена: {token_data}")
|
||||
return f"403 {token_data.get('error_description', token_data['error'])}", 403
|
||||
|
||||
access_token = token_data.get('access_token')
|
||||
|
||||
# Получаем данные пользователя
|
||||
try:
|
||||
userinfo_resp = requests.get(f"{base}/application/o/userinfo/",
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
timeout=10)
|
||||
userinfo = userinfo_resp.json()
|
||||
except Exception as e:
|
||||
print(f"[OIDC] Ошибка получения userinfo: {e}")
|
||||
return "500 Userinfo request failed", 500
|
||||
|
||||
print(f"[OIDC] Успешный вход: {userinfo.get('email') or userinfo.get('preferred_username', '?')}")
|
||||
session['logged_in'] = True
|
||||
session['user_email'] = userinfo.get('email', '')
|
||||
session['user_name'] = userinfo.get('name') or userinfo.get('preferred_username', '')
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
|
||||
@app.route('/admin/login', methods=['POST', 'GET'])
|
||||
def admin_login():
|
||||
password = request.form.get("password")
|
||||
if password == config.ADMIN_PASSWORD:
|
||||
session['logged_in'] = True
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
else:
|
||||
return render_template_string(LOGIN_TEMPLATE, error="Неверный пароль администратора")
|
||||
"""Legacy-маршрут — перенаправляет на OIDC."""
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
|
||||
@app.route('/admin/logout', methods=['GET'])
|
||||
def admin_logout():
|
||||
session.pop('logged_in', None)
|
||||
session.clear()
|
||||
# Выход также из Authentik
|
||||
base = config.AUTHENTIK_BASE_URL.rstrip('/')
|
||||
if base:
|
||||
return redirect(f"{base}/application/o/ikp-admin/end-session/")
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
@app.route('/admin/check-payment/<reg_id>', methods=['POST'])
|
||||
|
||||
Reference in New Issue
Block a user