From 12bee9dce4181ddaac65e7daf343ced6aeadff4f Mon Sep 17 00:00:00 2001 From: AGorokhov Date: Sun, 21 Jun 2026 20:57:00 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B0=D1=83=D1=82=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20Authentik=20OIDC=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 10 ++- server.py | 206 ++++++++++++++++++++++++++---------------------------- 2 files changed, 106 insertions(+), 110 deletions(-) diff --git a/config.py b/config.py index 1d442bd..241e9f2 100644 --- a/config.py +++ b/config.py @@ -48,8 +48,14 @@ SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") SMTP_FROM = os.getenv("SMTP_FROM", "") SMTP_TO = os.getenv("SMTP_TO", "") -# Admin Dashboard Password -ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") +# Admin Dashboard Password (устарело — используется OIDC через Authentik) +ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "") + +# Authentik OIDC Configuration +AUTHENTIK_BASE_URL = os.getenv("AUTHENTIK_BASE_URL", "") # например https://auth.infocyber.pro +AUTHENTIK_CLIENT_ID = os.getenv("AUTHENTIK_CLIENT_ID", "") +AUTHENTIK_CLIENT_SECRET = os.getenv("AUTHENTIK_CLIENT_SECRET", "") +AUTHENTIK_REDIRECT_URI = os.getenv("AUTHENTIK_REDIRECT_URI", "") # например https://infocyber.pro/admin/callback # Supplier / Agent Configuration for 54-FZ receipts (Agent Scheme) SUPPLIER_NAME = os.getenv("SUPPLIER_NAME", "ИП Груздев Артём Сергеевич") diff --git a/server.py b/server.py index a519d08..65f986c 100644 --- a/server.py +++ b/server.py @@ -691,47 +691,27 @@ def yookassa_webhook(): # ==================================================== # Admin Cabinet Views (ЛК Администратора) # ==================================================== -LOGIN_TEMPLATE = """ + +# Шаблон страницы ошибки доступа +ACCESS_DENIED_TEMPLATE = """ - ИКП Панель | Авторизация + ИКП Админ | Ошибка авторизации - + - -
-
- -
-
-

ИКП Админ

-

Панель управления оплатами

-
- - {% if error %} -
- ⚠️ {{ error }} -
- {% endif %} - -
-
- - -
- - -
+ +
+
🔒
+

Ошибка доступа

+

{{ error }}

+ + Войти через Authentik +
@@ -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/', 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/', methods=['POST'])