fix(amas): complete missing TLS chain dynamically + hide raw errors from UI
eroom.e-social.gov.az serves an incomplete certificate chain (omits the intermediate CA that signs its leaf), so Python requests raised CERTIFICATE_VERIFY_FAILED on the ƏMAS wizard/employee-load step. Browsers paper over this via AIA; requests does not. Fix without vendoring any cert into the repo: read the AIA "CA Issuers" pointer out of the live leaf, download the missing intermediate(s) straight from the issuer, and cache them outside the repo. Route all AMAS/MyGovID calls through _amas_http, which self-heals across CA rotations by re-fetching and retrying once on SSLError. SSL verification stays ON (not verify=False). Also stop leaking raw exception strings to the UI: every AMAS-facing return now logs the full traceback and shows the generic, localized "ƏMAS unavailable" message. Added az/ru translations for it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b2a58e833c
commit
6647c4a351
|
|
@ -13,13 +13,20 @@ of Azərbaycan's Ministry of Labour and Social Protection.
|
|||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import ssl
|
||||
import string
|
||||
import tempfile
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from urllib.parse import unquote
|
||||
|
||||
import certifi
|
||||
import frappe
|
||||
import requests
|
||||
from frappe import _
|
||||
|
||||
# Increase header limit - AMAS returns many Set-Cookie headers
|
||||
http.client._MAXHEADERS = 1000
|
||||
|
|
@ -90,6 +97,169 @@ AMAS_GENERIC_UNAVAILABLE_MESSAGE = (
|
|||
)
|
||||
|
||||
|
||||
# --- TLS chain fix for eroom.e-social.gov.az -------------------------------
|
||||
# The ƏMAS server serves an INCOMPLETE certificate chain: it omits the
|
||||
# intermediate CA that signs its leaf certificate, so Python's certifi bundle
|
||||
# cannot build a trust path and requests raises CERTIFICATE_VERIFY_FAILED
|
||||
# ("unable to get local issuer certificate"). Browsers paper over this by
|
||||
# fetching the missing intermediate over AIA (Authority Information Access);
|
||||
# requests does not.
|
||||
#
|
||||
# Rather than vendor a certificate file into the repo (which goes stale the day
|
||||
# the CA rotates), we do what the browser does: read the AIA "CA Issuers"
|
||||
# pointer out of whatever leaf the server is currently serving, download the
|
||||
# missing intermediate(s) straight from the issuer (e.g. Sectigo), and cache
|
||||
# them OUTSIDE the repo (temp dir). This self-heals across CA rotations and has
|
||||
# no hardcoded certificate — it follows the pointer the live cert gives us.
|
||||
#
|
||||
# SSL verification STAYS ON — this is not `verify=False`; we only complete the
|
||||
# chain the server forgot to send, still anchored to certifi's trusted roots.
|
||||
_AMAS_HOST = AMAS_BASE_URL.split("://", 1)[-1].split("/", 1)[0]
|
||||
_AMAS_INTERMEDIATE_CACHE = os.path.join(tempfile.gettempdir(), "amas_missing_chain.pem")
|
||||
_AMAS_BUNDLE_CACHE = os.path.join(tempfile.gettempdir(), "amas_ca_bundle.pem")
|
||||
_amas_ca_bundle_path = None
|
||||
_amas_ca_bundle_lock = threading.Lock()
|
||||
_amas_fetch_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load_cert_any(raw):
|
||||
"""Load a certificate from DER or PEM bytes; None if neither parses."""
|
||||
from cryptography import x509
|
||||
|
||||
try:
|
||||
return x509.load_der_x509_certificate(raw)
|
||||
except Exception:
|
||||
try:
|
||||
return x509.load_pem_x509_certificate(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _collect_missing_chain():
|
||||
"""Walk the AIA chain starting from the live ƏMAS leaf certificate and
|
||||
return the missing intermediate(s) as a list of PEM strings.
|
||||
|
||||
Reads the leaf over a raw (unverified) TLS handshake purely to inspect its
|
||||
AIA pointer — no data is trusted from it — then downloads each issuer the
|
||||
pointer names, climbing until a self-signed root (already in certifi).
|
||||
"""
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives.serialization import Encoding
|
||||
from cryptography.x509.oid import AuthorityInformationAccessOID
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
with socket.create_connection((_AMAS_HOST, 443), timeout=15) as sock:
|
||||
with ctx.wrap_socket(sock, server_hostname=_AMAS_HOST) as tls:
|
||||
der = tls.getpeercert(binary_form=True)
|
||||
|
||||
cert = x509.load_der_x509_certificate(der)
|
||||
pems = []
|
||||
for _depth in range(5): # guard against a pathological / looping chain
|
||||
try:
|
||||
aia = cert.extensions.get_extension_for_class(
|
||||
x509.AuthorityInformationAccess
|
||||
).value
|
||||
except x509.ExtensionNotFound:
|
||||
break
|
||||
url = next(
|
||||
(
|
||||
d.access_location.value
|
||||
for d in aia
|
||||
if d.access_method == AuthorityInformationAccessOID.CA_ISSUERS
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not url:
|
||||
break
|
||||
raw = requests.get(url, timeout=15).content
|
||||
issuer = _load_cert_any(raw)
|
||||
if issuer is None:
|
||||
break
|
||||
pems.append(issuer.public_bytes(Encoding.PEM).decode("ascii"))
|
||||
if issuer.subject == issuer.issuer:
|
||||
break # reached a self-signed root; certifi already trusts it
|
||||
cert = issuer
|
||||
return pems
|
||||
|
||||
|
||||
def _fetch_amas_intermediate(force=False):
|
||||
"""Populate the cached missing-intermediate PEM from the live AIA pointer.
|
||||
|
||||
Returns True if the cache is present/fresh afterwards. Network or parse
|
||||
failures degrade to False (the caller then falls back to certifi-only, i.e.
|
||||
today's behaviour) and are logged to the file logger, never surfaced.
|
||||
"""
|
||||
with _amas_fetch_lock:
|
||||
if not force and os.path.exists(_AMAS_INTERMEDIATE_CACHE):
|
||||
return True
|
||||
try:
|
||||
pems = _collect_missing_chain()
|
||||
if not pems:
|
||||
return False
|
||||
tmp = _AMAS_INTERMEDIATE_CACHE + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(pems))
|
||||
os.replace(tmp, _AMAS_INTERMEDIATE_CACHE)
|
||||
return True
|
||||
except Exception:
|
||||
frappe.logger("amas").warning(
|
||||
"ƏMAS intermediate fetch failed", exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _amas_ca_bundle(rebuild=False):
|
||||
"""Return a path to a CA bundle that can verify eroom.e-social.gov.az.
|
||||
|
||||
The bundle = certifi's trusted roots + the intermediate(s) the server omits
|
||||
(fetched live, cached in temp). Falls back to requests' default verification
|
||||
(``True``) if the bundle can't be built, so a glitch degrades to today's
|
||||
behaviour rather than breaking every call.
|
||||
"""
|
||||
global _amas_ca_bundle_path
|
||||
if not rebuild and _amas_ca_bundle_path and os.path.exists(_amas_ca_bundle_path):
|
||||
return _amas_ca_bundle_path
|
||||
with _amas_ca_bundle_lock:
|
||||
if not rebuild and _amas_ca_bundle_path and os.path.exists(_amas_ca_bundle_path):
|
||||
return _amas_ca_bundle_path
|
||||
try:
|
||||
if not os.path.exists(_AMAS_INTERMEDIATE_CACHE):
|
||||
_fetch_amas_intermediate()
|
||||
with open(certifi.where(), encoding="utf-8") as f:
|
||||
data = f.read()
|
||||
if os.path.exists(_AMAS_INTERMEDIATE_CACHE):
|
||||
with open(_AMAS_INTERMEDIATE_CACHE, encoding="utf-8") as f:
|
||||
data += "\n" + f.read()
|
||||
with open(_AMAS_BUNDLE_CACHE, "w", encoding="utf-8") as f:
|
||||
f.write(data)
|
||||
_amas_ca_bundle_path = _AMAS_BUNDLE_CACHE
|
||||
return _AMAS_BUNDLE_CACHE
|
||||
except Exception:
|
||||
frappe.logger("amas").warning("ƏMAS CA bundle build failed", exc_info=True)
|
||||
return True
|
||||
|
||||
|
||||
def _amas_http(method, url, session=None, **kwargs):
|
||||
"""Perform an ƏMAS/MyGovID HTTP call with the chain-completing CA bundle.
|
||||
|
||||
If verification still fails (the server rotated to a new intermediate since
|
||||
we last cached), re-fetch the intermediate from the live AIA pointer, rebuild
|
||||
the bundle, and retry once. This is the self-heal that keeps things working
|
||||
across CA rotations without a code change.
|
||||
"""
|
||||
kwargs.setdefault("verify", _amas_ca_bundle())
|
||||
caller = session or requests
|
||||
try:
|
||||
return caller.request(method, url, **kwargs)
|
||||
except requests.exceptions.SSLError:
|
||||
if _fetch_amas_intermediate(force=True):
|
||||
kwargs["verify"] = _amas_ca_bundle(rebuild=True)
|
||||
return caller.request(method, url, **kwargs)
|
||||
raise
|
||||
|
||||
|
||||
def _post_amas(session_data, csrf_token, cookies, endpoint, data):
|
||||
"""Stateless POST to ƏMAS. No DB access.
|
||||
|
||||
|
|
@ -116,7 +286,8 @@ def _post_amas(session_data, csrf_token, cookies, endpoint, data):
|
|||
data["currentTabToken"] = token
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
response = _amas_http(
|
||||
"POST",
|
||||
f"{AMAS_BASE_URL}{endpoint}",
|
||||
data=data,
|
||||
headers=headers,
|
||||
|
|
@ -284,7 +455,7 @@ def connect_amas(asan_login_name):
|
|||
f"EMAS connect error: {str(e)}\n{frappe.get_traceback()}",
|
||||
"ƏMAS API Error"
|
||||
)
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
|
||||
|
||||
def get_amas_auth_code(mygovid_token):
|
||||
|
|
@ -307,11 +478,12 @@ def get_amas_auth_code(mygovid_token):
|
|||
headers = MYGOVID_HEADERS.copy()
|
||||
headers["Authorization"] = mygovid_token
|
||||
|
||||
response = requests.post(
|
||||
response = _amas_http(
|
||||
"POST",
|
||||
MYGOVID_AUTH_CODES_URL,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
|
|
@ -332,10 +504,12 @@ def get_amas_auth_code(mygovid_token):
|
|||
"message": f"Failed to get auth code: status {response.status_code}"
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {"success": False, "message": f"Network error: {str(e)}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
except requests.exceptions.RequestException:
|
||||
frappe.log_error(frappe.get_traceback(), "ƏMAS network request failed")
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
except Exception:
|
||||
frappe.log_error(frappe.get_traceback(), "ƏMAS request error")
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
|
||||
|
||||
def parse_config_from_html(html_text):
|
||||
|
|
@ -397,10 +571,12 @@ def exchange_code_for_session(code, state):
|
|||
# Create session to capture cookies
|
||||
session = requests.Session()
|
||||
|
||||
response = session.get(
|
||||
response = _amas_http(
|
||||
"GET",
|
||||
callback_url,
|
||||
session=session,
|
||||
allow_redirects=True,
|
||||
timeout=30
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# Extract cookies
|
||||
|
|
@ -467,10 +643,12 @@ def exchange_code_for_session(code, state):
|
|||
"session_data": session_data
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {"success": False, "message": f"Network error: {str(e)}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
except requests.exceptions.RequestException:
|
||||
frappe.log_error(frappe.get_traceback(), "ƏMAS network request failed")
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
except Exception:
|
||||
frappe.log_error(frappe.get_traceback(), "ƏMAS request error")
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
|
||||
|
||||
def get_amas_session_data(asan_login_name):
|
||||
|
|
@ -503,10 +681,11 @@ def refresh_csrf_token_internal(asan_login_name):
|
|||
|
||||
try:
|
||||
# Make GET request to dashboard to get fresh CSRF token
|
||||
response = requests.get(
|
||||
response = _amas_http(
|
||||
"GET",
|
||||
f"{AMAS_BASE_URL}/core.dashboard",
|
||||
cookies=cookies,
|
||||
timeout=30
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
|
|
@ -696,12 +875,13 @@ def get_amas_accounts(asan_login_name):
|
|||
data["currentTabToken"] = token
|
||||
|
||||
# Make request to /request/accounts
|
||||
response = requests.post(
|
||||
response = _amas_http(
|
||||
"POST",
|
||||
f"{AMAS_BASE_URL}{AMAS_GET_ACCOUNTS}",
|
||||
data=data,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
timeout=30
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# Update cookies from response
|
||||
|
|
@ -738,7 +918,7 @@ def get_amas_accounts(asan_login_name):
|
|||
f"EMAS get accounts error: {str(e)}\n{frappe.get_traceback()}",
|
||||
"ƏMAS API Error"
|
||||
)
|
||||
return {"success": False, "message": f"Error: {str(e)}", "accounts": []}
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE), "accounts": []}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -808,7 +988,7 @@ def change_amas_account(asan_login_name, account_oid, account_name, account_numb
|
|||
f"EMAS change account error: {str(e)}\n{frappe.get_traceback()}",
|
||||
"ƏMAS API Error"
|
||||
)
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -868,7 +1048,7 @@ def get_employees_report(asan_login_name, offset=0, limit=100):
|
|||
f"EMAS employees report error: {str(e)}\n{frappe.get_traceback()}",
|
||||
"ƏMAS API Error"
|
||||
)
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -920,7 +1100,7 @@ def get_contract_stats(asan_login_name):
|
|||
f"EMAS contract stats error: {str(e)}\n{frappe.get_traceback()}",
|
||||
"ƏMAS API Error"
|
||||
)
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -946,7 +1126,7 @@ def check_amas_connection(asan_login_name):
|
|||
f"EMAS check connection error: {str(e)}\n{frappe.get_traceback()}",
|
||||
"ƏMAS API Error"
|
||||
)
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -968,10 +1148,11 @@ def refresh_amas_session(asan_login_name):
|
|||
cookies = session_data.get("all_cookies", {})
|
||||
|
||||
# Make GET request to EMAS dashboard to get fresh CSRF token
|
||||
response = requests.get(
|
||||
response = _amas_http(
|
||||
"GET",
|
||||
f"{AMAS_BASE_URL}/core.dashboard",
|
||||
cookies=cookies,
|
||||
timeout=30
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
|
|
@ -1022,7 +1203,7 @@ def refresh_amas_session(asan_login_name):
|
|||
f"EMAS refresh session error: {str(e)}\n{frappe.get_traceback()}",
|
||||
"ƏMAS API Error"
|
||||
)
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -1054,7 +1235,7 @@ def disconnect_amas(asan_login_name):
|
|||
f"EMAS disconnect error: {str(e)}\n{frappe.get_traceback()}",
|
||||
"ƏMAS API Error"
|
||||
)
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -1109,7 +1290,7 @@ def get_connected_asan_logins():
|
|||
f"Get connected Asan Logins error: {str(e)}\n{frappe.get_traceback()}",
|
||||
"ƏMAS API Error"
|
||||
)
|
||||
return {"success": False, "message": f"Error: {str(e)}", "asan_logins": []}
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE), "asan_logins": []}
|
||||
|
||||
|
||||
# Exact list of API fields that map 1:1 to Əmas Employees DocType fields
|
||||
|
|
@ -1297,7 +1478,7 @@ def import_employees(asan_login_name, employees):
|
|||
f"Import employees error: {str(e)}\n{frappe.get_traceback()}",
|
||||
"EMAS API Error"
|
||||
)
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
|
||||
|
||||
def update_employee_from_data(employee_doc, emp_data):
|
||||
|
|
@ -2042,7 +2223,7 @@ def create_single_employee_from_amas(asan_login_name, emp_data, company, create_
|
|||
"success": False,
|
||||
"action": "error",
|
||||
"full_name": name_val,
|
||||
"message": error_msg
|
||||
"message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2351,7 +2532,7 @@ def create_employees_from_amas(asan_login_name, employees, company, create_desig
|
|||
f"Create employees from EMAS error: {str(e)}\n{frappe.get_traceback()}",
|
||||
"EMAS API Error"
|
||||
)
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
|
||||
|
||||
# ==================== EMPLOYEE DETAIL API FUNCTIONS ====================
|
||||
|
|
@ -2533,7 +2714,7 @@ def get_employee_detail(asan_login_name, doc_oid, doc_no, doc_type="docType_47",
|
|||
f"Get employee detail error: {str(e)}\n{frappe.get_traceback()}",
|
||||
"ƏMAS API Error"
|
||||
)
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
|
||||
|
||||
|
||||
def update_employee_from_detail_data(employee_doc, detail_data):
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -7344,3 +7344,7 @@ msgstr "Черновик в E-Taxes"
|
|||
#: invoice_az/client/sales_invoice.js:2245
|
||||
msgid "Loaded from E-Taxes"
|
||||
msgstr "Загружено из E-Taxes"
|
||||
|
||||
#: invoice_az/amas_api.py
|
||||
msgid "ƏMAS service is currently unavailable. Please try again in a few minutes."
|
||||
msgstr "Сервис ƏMAS временно недоступен. Пожалуйста, повторите попытку через несколько минут."
|
||||
|
|
|
|||
Loading…
Reference in New Issue