From f6a7bf76c54d07a920a03437a395d02caea3cbcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Mon, 27 Jul 2026 22:56:51 +0400 Subject: [PATCH] =?UTF-8?q?v1.0.90:=20Steps=201-4=20=E2=80=94=20auth.py,?= =?UTF-8?q?=20dedup,=20httponly=20cookie,=20=5Fop=5Fresults=20TTL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/api/auth.py | 82 +++++++++++++++++++++++++++++++++++++++++ site/app.py | 2 +- site/routes/api_test.py | 74 +++++++++++++++++-------------------- site/routes/main.py | 52 +++----------------------- 4 files changed, 123 insertions(+), 87 deletions(-) create mode 100644 site/api/auth.py diff --git a/site/api/auth.py b/site/api/auth.py new file mode 100644 index 0000000..a7bb974 --- /dev/null +++ b/site/api/auth.py @@ -0,0 +1,82 @@ +""" +Единый модуль аутентификации и создания HTTP-клиента. + +Раньше _client(), _client_id(), _stand(), _token_info() были продублированы +в main.py и api_test.py с идентичным или почти идентичным кодом. +Теперь всё здесь — один источник правды. + +Функции: + get_token() — токен из cookie или env + get_client() — HttpClient с автоопределением стенда + get_client_id() — ClientID из JWT (base64, без проверки подписи) + get_stand() — "dev"/"test" по токену + get_token_info() — {email, company, client_id} из JWT + get_token_masked() — маскированный токен (abc...xyz) +""" + +import base64 +import json + +from flask import request, current_app +from api.http_client import HttpClient, detect_endpoint, stand_name + + +def get_token(): + """Токен: сначала из cookie, потом из env-переменной.""" + return request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"] + + +def get_client(): + """HttpClient с автоопределением стенда по токену. + + Использует detect_endpoint() — пробует dev→test стенды. + Если автоопределение не сработало — fallback на NUBES_API_ENDPOINT из конфига.""" + token = get_token() + endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"] + return HttpClient(endpoint, token) + + +def get_client_id(): + """Извлечение ClientID из payload JWT-токена (base64url, без проверки подписи).""" + token = get_token() + try: + parts = token.split(".") # header.payload.signature + if len(parts) >= 2: + payload = base64.urlsafe_b64decode(parts[1] + "==") # padding + return json.loads(payload).get("ClientID", "") + except Exception: + pass + return "" + + +def get_stand(): + """dev/test — по токену (detect_endpoint → stand_name).""" + token = get_token() + endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"] + return stand_name(endpoint) + + +def get_token_info(): + """{email, company, client_id} из JWT — для отображения в топбаре UI.""" + token = get_token() + try: + parts = token.split(".") + if len(parts) >= 2: + payload = base64.urlsafe_b64decode(parts[1] + "==") + d = json.loads(payload) + return { + "email": d.get("email", ""), + "company": d.get("company_name", ""), + "client_id": d.get("ClientID", ""), + } + except Exception: + pass + return {} + + +def get_token_masked(): + """Маскированный токен для placeholder: abc...xyz.""" + token = current_app.config["NUBES_API_TOKEN"] + if not token or len(token) < 8: + return "" + return token[:4] + "*" * (len(token) - 8) + token[-4:] diff --git a/site/app.py b/site/app.py index 1179fde..07661ca 100644 --- a/site/app.py +++ b/site/app.py @@ -18,7 +18,7 @@ from routes.api import bp as api_bp from routes.api_test import bp as api_test_bp # Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI. -VERSION = "1.0.89" +VERSION = "1.0.90" app = Flask(__name__, template_folder="templates", static_folder="static") app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc") diff --git a/site/routes/api_test.py b/site/routes/api_test.py index ba594da..bac9ebe 100644 --- a/site/routes/api_test.py +++ b/site/routes/api_test.py @@ -29,6 +29,7 @@ import os import fcntl from api.http_client import HttpClient, detect_endpoint, stand_name +from api.auth import get_token, get_client, get_client_id, get_stand from operations.get_services import get_services, get_service_detail from operations.get_instances import get_instances from operations.tracker import add as tracker_add, list_all as tracker_list @@ -92,37 +93,10 @@ def _unique_display_name(client, requested_name): return candidate -def _client(): - token = request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"] - endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"] - return HttpClient(endpoint, token) - - -def _client_id(): - """Извлечь ClientID из JWT токена (из cookie или env).""" - import base64, json - token = request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"] - try: - parts = token.split(".") - if len(parts) >= 2: - payload = base64.urlsafe_b64decode(parts[1] + "==") - return json.loads(payload).get("ClientID", "?") - except Exception: - pass - return "?" - - -def _stand(): - """dev/test — по токену.""" - token = request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"] - endpoint = detect_endpoint(token) or current_app.config["NUBES_API_ENDPOINT"] - return stand_name(endpoint) - - @bp.route("/api/services") def api_services(): try: - raw = get_services(_client()) + raw = get_services(get_client()) svc_list = [{"svcId": s["svcId"], "svc": s["svc"], "svcExtendedName": s.get("svcExtendedName", "")} for s in raw] svc_list.sort(key=lambda s: s["svcId"]) return jsonify(svc_list) @@ -133,7 +107,7 @@ def api_services(): @bp.route("/api/instances/list") def api_instances_list(): try: - raw = get_instances(_client()) + raw = get_instances(get_client()) inst = [{"instanceUid": i["instanceUid"], "displayName": i["displayName"], "serviceId": i["serviceId"], "svc": i["svc"], "explainedStatus": i.get("explainedStatus", "?")} for i in raw] return jsonify(inst) except Exception as e: @@ -143,13 +117,13 @@ def api_instances_list(): @bp.route("/api/operations/") def api_operations(svc_id): try: - detail = get_service_detail(_client(), svc_id) + detail = get_service_detail(get_client(), svc_id) ops = detail.get("operations", []) # только отслеживаемые инстансы этого сервиса - tracked = tracker_list(_client_id(), _stand()) + tracked = tracker_list(get_client_id(), get_stand()) tracked_by_uid = {t["instanceUid"]: t for t in tracked if t["svcId"] == svc_id} tracked_uids = set(tracked_by_uid.keys()) - instances = get_instances(_client()) + instances = get_instances(get_client()) nubes_uids = {i["instanceUid"] for i in instances} svc_instances = [i for i in instances if i.get("instanceUid") in tracked_uids @@ -182,7 +156,7 @@ def api_params(op_id): if instance_uid: # Текущие значения: GET /instances/{uid} → state.params + шаблон → слияние - result = get_params_with_current_values(_client(), op_id, instance_uid) + result = get_params_with_current_values(get_client(), op_id, instance_uid) _log(f"[api_params] MERGED returning {len(result)} params from state.params") for p in result: _log(f"[api_params] {p['name']}: defaultValue={p['defaultValue']!r}") @@ -190,7 +164,7 @@ def api_params(op_id): # Без instanceUid — шаблонные значения по умолчанию (CREATE) _log(f"[api_params] DEFAULT branch: template defaults") - data = _client().get(f"/instanceOperations/default/{op_id}") + data = get_client().get(f"/instanceOperations/default/{op_id}") params = data["svcOperation"]["cfsParams"] result = [] for p in params: @@ -226,7 +200,7 @@ def api_test(): instance_uid = data.get("instanceUid") display_name = data.get("displayName") or "" - client = _client() + client = get_client() try: if op_name == "create": @@ -248,7 +222,7 @@ def api_test(): # Записать в трекер СРАЗУ после получения instanceUid, до params/run # (если params упадут — инстанс уже отслежен, сирот не будет) try: - tracker_add(_client_id(), _stand(), instance_uid, svc_id, display_name) + tracker_add(get_client_id(), get_stand(), instance_uid, svc_id, display_name) except Exception as e: import traceback print(f"[TRACKER ERROR] add failed: {e}", flush=True) @@ -260,7 +234,7 @@ def api_test(): client.post(f"/instanceOperations/{op_uid}/run") # фоном ждать завершения - threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, True, _client_id(), _stand()), daemon=True).start() + threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, True, get_client_id(), get_stand()), daemon=True).start() return jsonify({"status": "RUNNING", "opUid": op_uid, "instanceUid": instance_uid, "displayName": display_name}) else: @@ -290,7 +264,7 @@ def api_test(): # Получить реальное имя инстанса из API (не UUID!) if not display_name: display_name = _get_instance_display_name(client, instance_uid) - threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, False, _client_id(), _stand(), is_delete), daemon=True).start() + threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, False, get_client_id(), get_stand(), is_delete), daemon=True).start() return jsonify({"status": "RUNNING", "opUid": op_uid, "instanceUid": instance_uid, "displayName": display_name}) except Exception as e: @@ -298,8 +272,25 @@ def api_test(): return jsonify({"status": "FAIL", "error": str(e) + " | " + traceback.format_exc()[-200:]}) -# Результаты фоновых операций: opUid → {status, error, stages, duration} +# Результаты фоновых операций: opUid → {status, error, stages, duration, _ts} +# _ts — timestamp добавления, для TTL-очистки (макс. 500 записей или старше 1 часа) _op_results = {} +_MAX_OP_RESULTS = 500 + + +def _cleanup_op_results(): + """Удалить старые записи: старше 1 часа или сверх лимита.""" + import time + now = time.time() + # Удалить старше 1 часа + stale = [k for k, v in _op_results.items() if now - v.get("_ts", 0) > 3600] + for k in stale: + del _op_results[k] + # Если всё ещё много — удалить самые старые + if len(_op_results) > _MAX_OP_RESULTS: + sorted_keys = sorted(_op_results.keys(), key=lambda k: _op_results[k].get("_ts", 0)) + for k in sorted_keys[:len(_op_results) - _MAX_OP_RESULTS]: + del _op_results[k] def _get_instance_display_name(client, instance_uid): @@ -315,6 +306,7 @@ def _get_instance_display_name(client, instance_uid): def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, client_id, stand, is_delete=False): """Фоном ждать dtFinish и сохранить результат.""" import time + _cleanup_op_results() # очистить старые записи перед добавлением новой t0 = time.time() deadline = t0 + 300 while time.time() < deadline: @@ -332,6 +324,7 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_ "displayName": display_name, "stages": op.get("stages", []), "duration": round(time.time() - t0, 1), + "_ts": time.time(), } if dt_finish and str(dt_finish).strip(): is_ok = op.get("isSuccessful") @@ -346,6 +339,7 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_ "error": str(err) if err else "", "stages": op.get("stages", []), "duration": round(time.time() - t0, 1), + "_ts": time.time(), } return time.sleep(5) @@ -353,7 +347,7 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_ import traceback print(f"[ERROR] _finish_op crashed: {traceback.format_exc()}", flush=True) time.sleep(5) - _op_results[op_uid] = {"status": "TIMEOUT", "displayName": display_name, "duration": round(time.time() - t0, 1)} + _op_results[op_uid] = {"status": "TIMEOUT", "displayName": display_name, "duration": round(time.time() - t0, 1), "_ts": time.time()} @bp.route("/api/test/status/") diff --git a/site/routes/main.py b/site/routes/main.py index 716e7f1..6dc8773 100644 --- a/site/routes/main.py +++ b/site/routes/main.py @@ -6,14 +6,12 @@ GET /api/operations/ — операции и autotest-инста Вспомогательные функции: _resolve_instance_status() — канонический статус (cloud или "creating" из трекера) - _mask() — маскировка токена (env: abc...xyz) - _client_id() — извлечение ClientID из JWT - _token_info() — email, компания, clientId из JWT """ from flask import Blueprint, current_app, render_template, request, make_response, jsonify, redirect from api.http_client import HttpClient, detect_endpoint, create_client, stand_name +from api.auth import get_token, get_client_id, get_token_info, get_token_masked from operations.get_instances import get_organization, get_instances from operations.get_services import get_services, get_service_detail from operations.tracker import list_all as tracker_list @@ -40,44 +38,6 @@ def _resolve_instance_status(instance, tracked=None): return "unknown" -def _mask(s): - """Маскировка токена для показа в placeholder: abc...xyz.""" - if not s or len(s) < 8: - return "" - return s[:4] + "*" * (len(s) - 8) + s[-4:] - - -def _client_id(token): - """Извлечение ClientID из payload JWT-токена (base64, без проверки подписи).""" - import base64, json - try: - parts = token.split(".") # header.payload.signature - if len(parts) >= 2: - payload = base64.urlsafe_b64decode(parts[1] + "==") # padding - return json.loads(payload).get("ClientID", "") - except Exception: - pass - return "" - - -def _token_info(token): - """Извлечение email, company, client_id из JWT для показа в топбаре.""" - import base64, json - try: - parts = token.split(".") - if len(parts) >= 2: - payload = base64.urlsafe_b64decode(parts[1] + "==") - d = json.loads(payload) - return { - "email": d.get("email", ""), - "company": d.get("company_name", ""), - "client_id": d.get("ClientID", ""), - } - except Exception: - pass - return {} - - @bp.route("/", methods=["GET", "POST"]) def index(): """Главная страница: организация, инфраструктура, сервисы, форма токена. @@ -106,10 +66,10 @@ def index(): ctx = { "organization": org, "error": error, - "env_token_masked": _mask(env_token), + "env_token_masked": get_token_masked(), "has_user_token": bool(user_token), - "client_id": _client_id(active_token), - "token_info": _token_info(active_token), + "client_id": get_client_id(), + "token_info": get_token_info(), "config": config, "stand": stand, "services": services, @@ -130,7 +90,7 @@ def index(): user_token = request.form.get("token", "") active_token = user_token or env_token resp = make_response() - resp.set_cookie("token", user_token, max_age=60*60*24*365) # 1 год + resp.set_cookie("token", user_token, max_age=60*60*24*365, httponly=True, samesite="Strict") # 1 год resp.headers["Location"] = "/" # редирект на GET (убирает POST из истории) resp.status_code = 302 return resp @@ -214,7 +174,7 @@ def api_operations(svc_id): ops = detail.get("operations", []) # Трекер: наши autotest-инстансы (изолирован по пользователю и стенду) - tracked = tracker_list(_client_id(active_token), stand_name(endpoint)) + tracked = tracker_list(get_client_id(), stand_name(endpoint)) tracked_by_uid = {t["instanceUid"]: t for t in tracked if t["svcId"] == svc_id} # Все инстансы из облака