kekkai-cli 1.1.0__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kekkai/cli.py +238 -36
- kekkai/dojo_import.py +9 -1
- kekkai/output.py +2 -3
- kekkai/report/unified.py +226 -0
- kekkai/triage/__init__.py +54 -1
- kekkai/triage/fix_screen.py +232 -0
- kekkai/triage/loader.py +196 -0
- kekkai/triage/screens.py +1 -0
- kekkai_cli-2.0.0.dist-info/METADATA +317 -0
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-2.0.0.dist-info}/RECORD +13 -28
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-2.0.0.dist-info}/entry_points.txt +0 -1
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-2.0.0.dist-info}/top_level.txt +0 -1
- kekkai_cli-1.1.0.dist-info/METADATA +0 -359
- portal/__init__.py +0 -19
- portal/api.py +0 -155
- portal/auth.py +0 -103
- portal/enterprise/__init__.py +0 -45
- portal/enterprise/audit.py +0 -435
- portal/enterprise/licensing.py +0 -408
- portal/enterprise/rbac.py +0 -276
- portal/enterprise/saml.py +0 -595
- portal/ops/__init__.py +0 -53
- portal/ops/backup.py +0 -553
- portal/ops/log_shipper.py +0 -469
- portal/ops/monitoring.py +0 -517
- portal/ops/restore.py +0 -469
- portal/ops/secrets.py +0 -408
- portal/ops/upgrade.py +0 -591
- portal/tenants.py +0 -340
- portal/uploads.py +0 -259
- portal/web.py +0 -393
- {kekkai_cli-1.1.0.dist-info → kekkai_cli-2.0.0.dist-info}/WHEEL +0 -0
portal/enterprise/saml.py
DELETED
|
@@ -1,595 +0,0 @@
|
|
|
1
|
-
"""SAML 2.0 SSO integration for enterprise portal.
|
|
2
|
-
|
|
3
|
-
Security controls:
|
|
4
|
-
- ASVS V6.8.2: Validate assertion signatures
|
|
5
|
-
- ASVS V6.8.3: SAML one-time use / replay defense
|
|
6
|
-
- ASVS V7.6.1: Federated session lifetime
|
|
7
|
-
- ASVS V9.1.1: Signed tokens
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import base64
|
|
13
|
-
import hashlib
|
|
14
|
-
import hmac
|
|
15
|
-
import logging
|
|
16
|
-
import re
|
|
17
|
-
import secrets
|
|
18
|
-
import time
|
|
19
|
-
import xml.etree.ElementTree as ET # nosec B405 - validated SAML XML
|
|
20
|
-
from dataclasses import dataclass, field
|
|
21
|
-
from datetime import UTC, datetime, timedelta
|
|
22
|
-
from enum import Enum
|
|
23
|
-
from typing import TYPE_CHECKING, Any
|
|
24
|
-
from urllib.parse import quote
|
|
25
|
-
|
|
26
|
-
from kekkai_core import redact
|
|
27
|
-
|
|
28
|
-
if TYPE_CHECKING:
|
|
29
|
-
pass
|
|
30
|
-
|
|
31
|
-
logger = logging.getLogger(__name__)
|
|
32
|
-
|
|
33
|
-
SAML_NS = {
|
|
34
|
-
"saml": "urn:oasis:names:tc:SAML:2.0:assertion",
|
|
35
|
-
"samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
|
|
36
|
-
"ds": "http://www.w3.org/2000/09/xmldsig#",
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
DEFAULT_SESSION_LIFETIME = 3600 * 8
|
|
40
|
-
MAX_CLOCK_SKEW = 300
|
|
41
|
-
ASSERTION_ID_TTL = 3600
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class SAMLError(Exception):
|
|
45
|
-
"""Base exception for SAML errors."""
|
|
46
|
-
|
|
47
|
-
pass
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
class SAMLValidationError(SAMLError):
|
|
51
|
-
"""SAML assertion validation failed."""
|
|
52
|
-
|
|
53
|
-
pass
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class SAMLReplayError(SAMLError):
|
|
57
|
-
"""SAML assertion replay detected."""
|
|
58
|
-
|
|
59
|
-
pass
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
class SAMLSignatureError(SAMLError):
|
|
63
|
-
"""SAML signature validation failed."""
|
|
64
|
-
|
|
65
|
-
pass
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
class SAMLStatus(Enum):
|
|
69
|
-
"""SAML response status codes."""
|
|
70
|
-
|
|
71
|
-
SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
|
|
72
|
-
REQUESTER = "urn:oasis:names:tc:SAML:2.0:status:Requester"
|
|
73
|
-
RESPONDER = "urn:oasis:names:tc:SAML:2.0:status:Responder"
|
|
74
|
-
AUTHN_FAILED = "urn:oasis:names:tc:SAML:2.0:status:AuthnFailed"
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
@dataclass(frozen=True)
|
|
78
|
-
class SAMLConfig:
|
|
79
|
-
"""SAML IdP configuration."""
|
|
80
|
-
|
|
81
|
-
entity_id: str
|
|
82
|
-
sso_url: str
|
|
83
|
-
slo_url: str | None = None
|
|
84
|
-
certificate: str | None = None
|
|
85
|
-
certificate_fingerprint: str | None = None
|
|
86
|
-
name_id_format: str = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
|
87
|
-
signing_key: str | None = None
|
|
88
|
-
session_lifetime: int = DEFAULT_SESSION_LIFETIME
|
|
89
|
-
want_assertions_signed: bool = True
|
|
90
|
-
allow_clock_skew: int = MAX_CLOCK_SKEW
|
|
91
|
-
|
|
92
|
-
def to_dict(self) -> dict[str, Any]:
|
|
93
|
-
return {
|
|
94
|
-
"entity_id": self.entity_id,
|
|
95
|
-
"sso_url": self.sso_url,
|
|
96
|
-
"slo_url": self.slo_url,
|
|
97
|
-
"name_id_format": self.name_id_format,
|
|
98
|
-
"session_lifetime": self.session_lifetime,
|
|
99
|
-
"want_assertions_signed": self.want_assertions_signed,
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
@classmethod
|
|
103
|
-
def from_dict(cls, data: dict[str, Any]) -> SAMLConfig:
|
|
104
|
-
return cls(
|
|
105
|
-
entity_id=str(data["entity_id"]),
|
|
106
|
-
sso_url=str(data["sso_url"]),
|
|
107
|
-
slo_url=data.get("slo_url"),
|
|
108
|
-
certificate=data.get("certificate"),
|
|
109
|
-
certificate_fingerprint=data.get("certificate_fingerprint"),
|
|
110
|
-
name_id_format=data.get(
|
|
111
|
-
"name_id_format", "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
|
112
|
-
),
|
|
113
|
-
signing_key=data.get("signing_key"),
|
|
114
|
-
session_lifetime=int(data.get("session_lifetime", DEFAULT_SESSION_LIFETIME)),
|
|
115
|
-
want_assertions_signed=data.get("want_assertions_signed", True),
|
|
116
|
-
allow_clock_skew=int(data.get("allow_clock_skew", MAX_CLOCK_SKEW)),
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
@dataclass
|
|
121
|
-
class SAMLAssertion:
|
|
122
|
-
"""Parsed SAML assertion."""
|
|
123
|
-
|
|
124
|
-
assertion_id: str
|
|
125
|
-
issuer: str
|
|
126
|
-
subject_name_id: str
|
|
127
|
-
subject_name_id_format: str
|
|
128
|
-
session_index: str | None
|
|
129
|
-
authn_instant: datetime
|
|
130
|
-
not_before: datetime | None
|
|
131
|
-
not_on_or_after: datetime | None
|
|
132
|
-
attributes: dict[str, list[str]] = field(default_factory=dict)
|
|
133
|
-
audience: str | None = None
|
|
134
|
-
is_signed: bool = False
|
|
135
|
-
|
|
136
|
-
@property
|
|
137
|
-
def email(self) -> str | None:
|
|
138
|
-
"""Extract email from NameID or attributes."""
|
|
139
|
-
if "emailAddress" in self.subject_name_id_format:
|
|
140
|
-
return self.subject_name_id
|
|
141
|
-
emails = self.attributes.get("email", []) + self.attributes.get("mail", [])
|
|
142
|
-
return emails[0] if emails else None
|
|
143
|
-
|
|
144
|
-
@property
|
|
145
|
-
def display_name(self) -> str | None:
|
|
146
|
-
"""Extract display name from attributes."""
|
|
147
|
-
names = (
|
|
148
|
-
self.attributes.get("displayName", [])
|
|
149
|
-
+ self.attributes.get("name", [])
|
|
150
|
-
+ self.attributes.get("cn", [])
|
|
151
|
-
)
|
|
152
|
-
return names[0] if names else None
|
|
153
|
-
|
|
154
|
-
@property
|
|
155
|
-
def roles(self) -> list[str]:
|
|
156
|
-
"""Extract roles from attributes."""
|
|
157
|
-
return (
|
|
158
|
-
self.attributes.get("role", [])
|
|
159
|
-
+ self.attributes.get("roles", [])
|
|
160
|
-
+ self.attributes.get("group", [])
|
|
161
|
-
+ self.attributes.get("groups", [])
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
class AssertionIDStore:
|
|
166
|
-
"""Tracks used assertion IDs for replay protection (ASVS V6.8.3)."""
|
|
167
|
-
|
|
168
|
-
def __init__(self, ttl: int = ASSERTION_ID_TTL) -> None:
|
|
169
|
-
self._used_ids: dict[str, float] = {}
|
|
170
|
-
self._ttl = ttl
|
|
171
|
-
self._last_cleanup = time.time()
|
|
172
|
-
|
|
173
|
-
def _cleanup(self) -> None:
|
|
174
|
-
"""Remove expired assertion IDs."""
|
|
175
|
-
now = time.time()
|
|
176
|
-
if now - self._last_cleanup < 60:
|
|
177
|
-
return
|
|
178
|
-
|
|
179
|
-
cutoff = now - self._ttl
|
|
180
|
-
self._used_ids = {k: v for k, v in self._used_ids.items() if v > cutoff}
|
|
181
|
-
self._last_cleanup = now
|
|
182
|
-
|
|
183
|
-
def check_and_mark(self, assertion_id: str) -> bool:
|
|
184
|
-
"""Check if assertion ID was used, mark it as used.
|
|
185
|
-
|
|
186
|
-
Returns:
|
|
187
|
-
True if this is a new assertion, False if replay detected
|
|
188
|
-
"""
|
|
189
|
-
self._cleanup()
|
|
190
|
-
|
|
191
|
-
if assertion_id in self._used_ids:
|
|
192
|
-
logger.warning("saml.replay_detected assertion_id=%s", assertion_id[:16])
|
|
193
|
-
return False
|
|
194
|
-
|
|
195
|
-
self._used_ids[assertion_id] = time.time()
|
|
196
|
-
return True
|
|
197
|
-
|
|
198
|
-
def is_used(self, assertion_id: str) -> bool:
|
|
199
|
-
"""Check if assertion ID was already used."""
|
|
200
|
-
self._cleanup()
|
|
201
|
-
return assertion_id in self._used_ids
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
class SAMLProcessor:
|
|
205
|
-
"""Process SAML authentication requests and responses."""
|
|
206
|
-
|
|
207
|
-
def __init__(
|
|
208
|
-
self,
|
|
209
|
-
sp_entity_id: str,
|
|
210
|
-
sp_acs_url: str,
|
|
211
|
-
idp_config: SAMLConfig | None = None,
|
|
212
|
-
) -> None:
|
|
213
|
-
self._sp_entity_id = sp_entity_id
|
|
214
|
-
self._sp_acs_url = sp_acs_url
|
|
215
|
-
self._idp_config = idp_config
|
|
216
|
-
self._assertion_store = AssertionIDStore()
|
|
217
|
-
|
|
218
|
-
def set_idp_config(self, config: SAMLConfig) -> None:
|
|
219
|
-
"""Update IdP configuration."""
|
|
220
|
-
self._idp_config = config
|
|
221
|
-
|
|
222
|
-
def create_authn_request(
|
|
223
|
-
self,
|
|
224
|
-
relay_state: str | None = None,
|
|
225
|
-
) -> tuple[str, str]:
|
|
226
|
-
"""Create a SAML AuthnRequest.
|
|
227
|
-
|
|
228
|
-
Returns:
|
|
229
|
-
Tuple of (redirect_url, request_id)
|
|
230
|
-
"""
|
|
231
|
-
if not self._idp_config:
|
|
232
|
-
raise SAMLError("IdP not configured")
|
|
233
|
-
|
|
234
|
-
request_id = f"_kek_{secrets.token_hex(16)}"
|
|
235
|
-
issue_instant = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
236
|
-
|
|
237
|
-
authn_request = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
238
|
-
<samlp:AuthnRequest
|
|
239
|
-
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
|
240
|
-
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
|
|
241
|
-
ID="{request_id}"
|
|
242
|
-
Version="2.0"
|
|
243
|
-
IssueInstant="{issue_instant}"
|
|
244
|
-
Destination="{self._idp_config.sso_url}"
|
|
245
|
-
AssertionConsumerServiceURL="{self._sp_acs_url}"
|
|
246
|
-
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
|
|
247
|
-
<saml:Issuer>{self._sp_entity_id}</saml:Issuer>
|
|
248
|
-
<samlp:NameIDPolicy Format="{self._idp_config.name_id_format}" AllowCreate="true"/>
|
|
249
|
-
</samlp:AuthnRequest>"""
|
|
250
|
-
|
|
251
|
-
encoded = base64.b64encode(authn_request.encode()).decode()
|
|
252
|
-
url = f"{self._idp_config.sso_url}?SAMLRequest={quote(encoded)}"
|
|
253
|
-
if relay_state:
|
|
254
|
-
url += f"&RelayState={quote(relay_state)}"
|
|
255
|
-
|
|
256
|
-
return url, request_id
|
|
257
|
-
|
|
258
|
-
def process_response(
|
|
259
|
-
self,
|
|
260
|
-
saml_response_b64: str,
|
|
261
|
-
validate_signature: bool = True,
|
|
262
|
-
) -> SAMLAssertion:
|
|
263
|
-
"""Process a SAML Response and extract the assertion.
|
|
264
|
-
|
|
265
|
-
Args:
|
|
266
|
-
saml_response_b64: Base64-encoded SAML response
|
|
267
|
-
validate_signature: Whether to validate the signature
|
|
268
|
-
|
|
269
|
-
Returns:
|
|
270
|
-
Parsed SAMLAssertion
|
|
271
|
-
|
|
272
|
-
Raises:
|
|
273
|
-
SAMLValidationError: If the response is invalid
|
|
274
|
-
SAMLReplayError: If the assertion was already used
|
|
275
|
-
SAMLSignatureError: If signature validation fails
|
|
276
|
-
"""
|
|
277
|
-
try:
|
|
278
|
-
xml_bytes = base64.b64decode(saml_response_b64)
|
|
279
|
-
xml_str = xml_bytes.decode("utf-8")
|
|
280
|
-
except (ValueError, UnicodeDecodeError) as e:
|
|
281
|
-
raise SAMLValidationError(f"Invalid Base64 encoding: {e}") from e
|
|
282
|
-
|
|
283
|
-
try:
|
|
284
|
-
root = ET.fromstring(xml_str) # noqa: S314 # nosec B314
|
|
285
|
-
except ET.ParseError as e:
|
|
286
|
-
raise SAMLValidationError(f"Invalid XML: {e}") from e
|
|
287
|
-
|
|
288
|
-
status = root.find(".//samlp:Status/samlp:StatusCode", SAML_NS)
|
|
289
|
-
if status is not None:
|
|
290
|
-
status_value = status.get("Value", "")
|
|
291
|
-
if status_value != SAMLStatus.SUCCESS.value:
|
|
292
|
-
raise SAMLValidationError(f"SAML authentication failed: {status_value}")
|
|
293
|
-
|
|
294
|
-
assertion = root.find(".//saml:Assertion", SAML_NS)
|
|
295
|
-
if assertion is None:
|
|
296
|
-
raise SAMLValidationError("No assertion found in response")
|
|
297
|
-
|
|
298
|
-
is_signed = self._has_signature(assertion) or self._has_signature(root)
|
|
299
|
-
if validate_signature and self._idp_config and self._idp_config.want_assertions_signed:
|
|
300
|
-
if not is_signed:
|
|
301
|
-
raise SAMLSignatureError("Assertion is not signed")
|
|
302
|
-
if self._idp_config.certificate and not self._validate_signature(
|
|
303
|
-
root, self._idp_config.certificate
|
|
304
|
-
):
|
|
305
|
-
raise SAMLSignatureError("Invalid signature")
|
|
306
|
-
|
|
307
|
-
parsed = self._parse_assertion(assertion, is_signed)
|
|
308
|
-
|
|
309
|
-
self._validate_timing(parsed)
|
|
310
|
-
|
|
311
|
-
if not self._assertion_store.check_and_mark(parsed.assertion_id):
|
|
312
|
-
raise SAMLReplayError(f"Assertion {parsed.assertion_id[:16]}... already used")
|
|
313
|
-
|
|
314
|
-
logger.info(
|
|
315
|
-
"saml.assertion_processed assertion_id=%s subject=%s",
|
|
316
|
-
parsed.assertion_id[:16],
|
|
317
|
-
redact(parsed.subject_name_id),
|
|
318
|
-
)
|
|
319
|
-
|
|
320
|
-
return parsed
|
|
321
|
-
|
|
322
|
-
def _has_signature(self, element: ET.Element) -> bool:
|
|
323
|
-
"""Check if element contains a signature."""
|
|
324
|
-
sig = element.find(".//ds:Signature", SAML_NS)
|
|
325
|
-
return sig is not None
|
|
326
|
-
|
|
327
|
-
def _validate_signature(self, root: ET.Element, certificate: str) -> bool:
|
|
328
|
-
"""Validate XML signature against certificate.
|
|
329
|
-
|
|
330
|
-
Note: This is a simplified implementation. Production use should
|
|
331
|
-
employ a proper XML signature library like signxml or xmlsec.
|
|
332
|
-
"""
|
|
333
|
-
sig = root.find(".//ds:Signature", SAML_NS)
|
|
334
|
-
if sig is None:
|
|
335
|
-
return False
|
|
336
|
-
|
|
337
|
-
digest_value = sig.find(".//ds:DigestValue", SAML_NS)
|
|
338
|
-
signature_value = sig.find(".//ds:SignatureValue", SAML_NS)
|
|
339
|
-
|
|
340
|
-
if digest_value is None or signature_value is None:
|
|
341
|
-
return False
|
|
342
|
-
|
|
343
|
-
cert_clean = certificate.replace("-----BEGIN CERTIFICATE-----", "")
|
|
344
|
-
cert_clean = cert_clean.replace("-----END CERTIFICATE-----", "")
|
|
345
|
-
cert_clean = cert_clean.replace("\n", "").replace(" ", "")
|
|
346
|
-
|
|
347
|
-
if self._idp_config and self._idp_config.certificate_fingerprint:
|
|
348
|
-
cert_bytes = base64.b64decode(cert_clean)
|
|
349
|
-
fingerprint = hashlib.sha256(cert_bytes).hexdigest()
|
|
350
|
-
expected = self._idp_config.certificate_fingerprint.lower().replace(":", "")
|
|
351
|
-
if not hmac.compare_digest(fingerprint, expected):
|
|
352
|
-
logger.warning("saml.certificate_fingerprint_mismatch")
|
|
353
|
-
return False
|
|
354
|
-
|
|
355
|
-
return True
|
|
356
|
-
|
|
357
|
-
def _parse_assertion(self, assertion: ET.Element, is_signed: bool) -> SAMLAssertion:
|
|
358
|
-
"""Parse a SAML Assertion element."""
|
|
359
|
-
assertion_id = assertion.get("ID", "")
|
|
360
|
-
if not assertion_id:
|
|
361
|
-
raise SAMLValidationError("Assertion missing ID")
|
|
362
|
-
|
|
363
|
-
issuer_elem = assertion.find("saml:Issuer", SAML_NS)
|
|
364
|
-
issuer = issuer_elem.text if issuer_elem is not None and issuer_elem.text else ""
|
|
365
|
-
|
|
366
|
-
subject = assertion.find("saml:Subject", SAML_NS)
|
|
367
|
-
if subject is None:
|
|
368
|
-
raise SAMLValidationError("Assertion missing Subject")
|
|
369
|
-
|
|
370
|
-
name_id = subject.find("saml:NameID", SAML_NS)
|
|
371
|
-
if name_id is None or not name_id.text:
|
|
372
|
-
raise SAMLValidationError("Assertion missing NameID")
|
|
373
|
-
|
|
374
|
-
subject_name_id = name_id.text
|
|
375
|
-
subject_name_id_format = name_id.get(
|
|
376
|
-
"Format", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
|
|
377
|
-
)
|
|
378
|
-
|
|
379
|
-
conditions = assertion.find("saml:Conditions", SAML_NS)
|
|
380
|
-
not_before = None
|
|
381
|
-
not_on_or_after = None
|
|
382
|
-
audience = None
|
|
383
|
-
|
|
384
|
-
if conditions is not None:
|
|
385
|
-
nb = conditions.get("NotBefore")
|
|
386
|
-
if nb:
|
|
387
|
-
not_before = self._parse_datetime(nb)
|
|
388
|
-
noa = conditions.get("NotOnOrAfter")
|
|
389
|
-
if noa:
|
|
390
|
-
not_on_or_after = self._parse_datetime(noa)
|
|
391
|
-
|
|
392
|
-
aud_elem = conditions.find(".//saml:Audience", SAML_NS)
|
|
393
|
-
if aud_elem is not None and aud_elem.text:
|
|
394
|
-
audience = aud_elem.text
|
|
395
|
-
|
|
396
|
-
authn_statement = assertion.find("saml:AuthnStatement", SAML_NS)
|
|
397
|
-
session_index = None
|
|
398
|
-
authn_instant = datetime.now(UTC)
|
|
399
|
-
|
|
400
|
-
if authn_statement is not None:
|
|
401
|
-
session_index = authn_statement.get("SessionIndex")
|
|
402
|
-
ai = authn_statement.get("AuthnInstant")
|
|
403
|
-
if ai:
|
|
404
|
-
authn_instant = self._parse_datetime(ai)
|
|
405
|
-
|
|
406
|
-
attributes: dict[str, list[str]] = {}
|
|
407
|
-
attr_statement = assertion.find("saml:AttributeStatement", SAML_NS)
|
|
408
|
-
if attr_statement is not None:
|
|
409
|
-
for attr in attr_statement.findall("saml:Attribute", SAML_NS):
|
|
410
|
-
attr_name = attr.get("Name", "")
|
|
411
|
-
if not attr_name:
|
|
412
|
-
continue
|
|
413
|
-
values = []
|
|
414
|
-
for value_elem in attr.findall("saml:AttributeValue", SAML_NS):
|
|
415
|
-
if value_elem.text:
|
|
416
|
-
values.append(value_elem.text)
|
|
417
|
-
if values:
|
|
418
|
-
attributes[attr_name] = values
|
|
419
|
-
|
|
420
|
-
return SAMLAssertion(
|
|
421
|
-
assertion_id=assertion_id,
|
|
422
|
-
issuer=issuer,
|
|
423
|
-
subject_name_id=subject_name_id,
|
|
424
|
-
subject_name_id_format=subject_name_id_format,
|
|
425
|
-
session_index=session_index,
|
|
426
|
-
authn_instant=authn_instant,
|
|
427
|
-
not_before=not_before,
|
|
428
|
-
not_on_or_after=not_on_or_after,
|
|
429
|
-
attributes=attributes,
|
|
430
|
-
audience=audience,
|
|
431
|
-
is_signed=is_signed,
|
|
432
|
-
)
|
|
433
|
-
|
|
434
|
-
def _parse_datetime(self, dt_str: str) -> datetime:
|
|
435
|
-
"""Parse SAML datetime string."""
|
|
436
|
-
dt_str = re.sub(r"\.\d+", "", dt_str)
|
|
437
|
-
if dt_str.endswith("Z"):
|
|
438
|
-
dt_str = dt_str[:-1] + "+00:00"
|
|
439
|
-
return datetime.fromisoformat(dt_str)
|
|
440
|
-
|
|
441
|
-
def _validate_timing(self, assertion: SAMLAssertion) -> None:
|
|
442
|
-
"""Validate assertion timing constraints."""
|
|
443
|
-
now = datetime.now(UTC)
|
|
444
|
-
skew = timedelta(
|
|
445
|
-
seconds=self._idp_config.allow_clock_skew if self._idp_config else MAX_CLOCK_SKEW
|
|
446
|
-
)
|
|
447
|
-
|
|
448
|
-
if assertion.not_before and now < assertion.not_before - skew:
|
|
449
|
-
raise SAMLValidationError("Assertion not yet valid")
|
|
450
|
-
|
|
451
|
-
if assertion.not_on_or_after and now > assertion.not_on_or_after + skew:
|
|
452
|
-
raise SAMLValidationError("Assertion has expired")
|
|
453
|
-
|
|
454
|
-
def create_session_token(
|
|
455
|
-
self,
|
|
456
|
-
assertion: SAMLAssertion,
|
|
457
|
-
tenant_id: str,
|
|
458
|
-
secret_key: str,
|
|
459
|
-
) -> str:
|
|
460
|
-
"""Create a signed session token from SAML assertion.
|
|
461
|
-
|
|
462
|
-
Returns:
|
|
463
|
-
Signed session token
|
|
464
|
-
"""
|
|
465
|
-
session_lifetime = (
|
|
466
|
-
self._idp_config.session_lifetime if self._idp_config else DEFAULT_SESSION_LIFETIME
|
|
467
|
-
)
|
|
468
|
-
expires_at = int(time.time()) + session_lifetime
|
|
469
|
-
|
|
470
|
-
payload = {
|
|
471
|
-
"sub": assertion.subject_name_id,
|
|
472
|
-
"tid": tenant_id,
|
|
473
|
-
"sid": assertion.session_index or secrets.token_hex(8),
|
|
474
|
-
"exp": expires_at,
|
|
475
|
-
"iat": int(time.time()),
|
|
476
|
-
"iss": self._sp_entity_id,
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
import json
|
|
480
|
-
|
|
481
|
-
payload_json = json.dumps(payload, separators=(",", ":"))
|
|
482
|
-
payload_b64 = base64.urlsafe_b64encode(payload_json.encode()).decode().rstrip("=")
|
|
483
|
-
|
|
484
|
-
signature = hmac.new(
|
|
485
|
-
secret_key.encode(),
|
|
486
|
-
payload_b64.encode(),
|
|
487
|
-
hashlib.sha256,
|
|
488
|
-
).hexdigest()
|
|
489
|
-
|
|
490
|
-
return f"{payload_b64}.{signature}"
|
|
491
|
-
|
|
492
|
-
def verify_session_token(
|
|
493
|
-
self,
|
|
494
|
-
token: str,
|
|
495
|
-
secret_key: str,
|
|
496
|
-
) -> dict[str, Any] | None:
|
|
497
|
-
"""Verify and decode a session token.
|
|
498
|
-
|
|
499
|
-
Returns:
|
|
500
|
-
Decoded payload or None if invalid
|
|
501
|
-
"""
|
|
502
|
-
parts = token.split(".")
|
|
503
|
-
if len(parts) != 2:
|
|
504
|
-
return None
|
|
505
|
-
|
|
506
|
-
payload_b64, signature = parts
|
|
507
|
-
|
|
508
|
-
expected_sig = hmac.new(
|
|
509
|
-
secret_key.encode(),
|
|
510
|
-
payload_b64.encode(),
|
|
511
|
-
hashlib.sha256,
|
|
512
|
-
).hexdigest()
|
|
513
|
-
|
|
514
|
-
if not hmac.compare_digest(signature, expected_sig):
|
|
515
|
-
logger.warning("saml.session_token_invalid_signature")
|
|
516
|
-
return None
|
|
517
|
-
|
|
518
|
-
try:
|
|
519
|
-
padding = 4 - len(payload_b64) % 4
|
|
520
|
-
if padding != 4:
|
|
521
|
-
payload_b64 += "=" * padding
|
|
522
|
-
payload_json = base64.urlsafe_b64decode(payload_b64).decode()
|
|
523
|
-
import json
|
|
524
|
-
|
|
525
|
-
payload = json.loads(payload_json)
|
|
526
|
-
except (ValueError, json.JSONDecodeError) as e:
|
|
527
|
-
logger.warning("saml.session_token_decode_error: %s", e)
|
|
528
|
-
return None
|
|
529
|
-
|
|
530
|
-
if payload.get("exp", 0) < time.time():
|
|
531
|
-
logger.debug("saml.session_token_expired")
|
|
532
|
-
return None
|
|
533
|
-
|
|
534
|
-
return dict(payload)
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
def create_mock_saml_response(
|
|
538
|
-
assertion_id: str,
|
|
539
|
-
issuer: str,
|
|
540
|
-
subject: str,
|
|
541
|
-
audience: str,
|
|
542
|
-
attributes: dict[str, list[str]] | None = None,
|
|
543
|
-
not_before: datetime | None = None,
|
|
544
|
-
not_on_or_after: datetime | None = None,
|
|
545
|
-
) -> str:
|
|
546
|
-
"""Create a mock SAML response for testing."""
|
|
547
|
-
now = datetime.now(UTC)
|
|
548
|
-
not_before = not_before or now - timedelta(minutes=5)
|
|
549
|
-
not_on_or_after = not_on_or_after or now + timedelta(hours=1)
|
|
550
|
-
response_id = f"_resp_{secrets.token_hex(16)}"
|
|
551
|
-
|
|
552
|
-
attrs_xml = ""
|
|
553
|
-
if attributes:
|
|
554
|
-
attrs_xml = "<saml:AttributeStatement>"
|
|
555
|
-
for name, values in attributes.items():
|
|
556
|
-
attrs_xml += f'<saml:Attribute Name="{name}">'
|
|
557
|
-
for v in values:
|
|
558
|
-
attrs_xml += f"<saml:AttributeValue>{v}</saml:AttributeValue>"
|
|
559
|
-
attrs_xml += "</saml:Attribute>"
|
|
560
|
-
attrs_xml += "</saml:AttributeStatement>"
|
|
561
|
-
|
|
562
|
-
nameid_fmt = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
|
563
|
-
response = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
564
|
-
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
|
565
|
-
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
|
|
566
|
-
ID="{response_id}"
|
|
567
|
-
Version="2.0"
|
|
568
|
-
IssueInstant="{now.strftime("%Y-%m-%dT%H:%M:%SZ")}">
|
|
569
|
-
<saml:Issuer>{issuer}</saml:Issuer>
|
|
570
|
-
<samlp:Status>
|
|
571
|
-
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
|
572
|
-
</samlp:Status>
|
|
573
|
-
<saml:Assertion ID="{assertion_id}" Version="2.0"
|
|
574
|
-
IssueInstant="{now.strftime("%Y-%m-%dT%H:%M:%SZ")}">
|
|
575
|
-
<saml:Issuer>{issuer}</saml:Issuer>
|
|
576
|
-
<saml:Subject>
|
|
577
|
-
<saml:NameID Format="{nameid_fmt}">{subject}</saml:NameID>
|
|
578
|
-
</saml:Subject>
|
|
579
|
-
<saml:Conditions NotBefore="{not_before.strftime("%Y-%m-%dT%H:%M:%SZ")}"
|
|
580
|
-
NotOnOrAfter="{not_on_or_after.strftime("%Y-%m-%dT%H:%M:%SZ")}">
|
|
581
|
-
<saml:AudienceRestriction>
|
|
582
|
-
<saml:Audience>{audience}</saml:Audience>
|
|
583
|
-
</saml:AudienceRestriction>
|
|
584
|
-
</saml:Conditions>
|
|
585
|
-
<saml:AuthnStatement AuthnInstant="{now.strftime("%Y-%m-%dT%H:%M:%SZ")}"
|
|
586
|
-
SessionIndex="_session_{secrets.token_hex(8)}">
|
|
587
|
-
<saml:AuthnContext>
|
|
588
|
-
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
|
|
589
|
-
</saml:AuthnContext>
|
|
590
|
-
</saml:AuthnStatement>
|
|
591
|
-
{attrs_xml}
|
|
592
|
-
</saml:Assertion>
|
|
593
|
-
</samlp:Response>"""
|
|
594
|
-
|
|
595
|
-
return base64.b64encode(response.encode()).decode()
|
portal/ops/__init__.py
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
"""Production operations module for Kekkai Portal.
|
|
2
|
-
|
|
3
|
-
Provides:
|
|
4
|
-
- Backup/restore functionality for DB and media
|
|
5
|
-
- Upgrade management with version pinning and rollback
|
|
6
|
-
- Centralized logging and monitoring
|
|
7
|
-
- Secret rotation utilities
|
|
8
|
-
|
|
9
|
-
ASVS 5.0 Requirements:
|
|
10
|
-
- V16.4.3: Send logs to separate system
|
|
11
|
-
- V16.4.2: Log protection
|
|
12
|
-
- V13.1.4: Secret rotation schedule
|
|
13
|
-
- V12.1.2: Strong cipher suites
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
from .backup import BackupConfig, BackupJob, BackupResult, create_backup_job
|
|
19
|
-
from .log_shipper import LogShipper, LogShipperConfig, ShipperType
|
|
20
|
-
from .monitoring import (
|
|
21
|
-
AlertRule,
|
|
22
|
-
AlertSeverity,
|
|
23
|
-
MonitoringConfig,
|
|
24
|
-
MonitoringService,
|
|
25
|
-
create_monitoring_service,
|
|
26
|
-
)
|
|
27
|
-
from .restore import RestoreConfig, RestoreJob, RestoreResult, create_restore_job
|
|
28
|
-
from .secrets import RotationSchedule, SecretRotation
|
|
29
|
-
from .upgrade import UpgradeManager, UpgradeResult, VersionManifest
|
|
30
|
-
|
|
31
|
-
__all__ = [
|
|
32
|
-
"BackupConfig",
|
|
33
|
-
"BackupJob",
|
|
34
|
-
"BackupResult",
|
|
35
|
-
"create_backup_job",
|
|
36
|
-
"RestoreConfig",
|
|
37
|
-
"RestoreJob",
|
|
38
|
-
"RestoreResult",
|
|
39
|
-
"create_restore_job",
|
|
40
|
-
"AlertRule",
|
|
41
|
-
"AlertSeverity",
|
|
42
|
-
"MonitoringConfig",
|
|
43
|
-
"MonitoringService",
|
|
44
|
-
"create_monitoring_service",
|
|
45
|
-
"LogShipper",
|
|
46
|
-
"LogShipperConfig",
|
|
47
|
-
"ShipperType",
|
|
48
|
-
"SecretRotation",
|
|
49
|
-
"RotationSchedule",
|
|
50
|
-
"UpgradeManager",
|
|
51
|
-
"VersionManifest",
|
|
52
|
-
"UpgradeResult",
|
|
53
|
-
]
|