identity_sessions.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. """Server-side session ledger with opaque rotating refresh credentials."""
  2. from __future__ import annotations
  3. import hashlib
  4. import secrets
  5. from collections.abc import Callable
  6. from dataclasses import dataclass
  7. from datetime import UTC, datetime, timedelta
  8. from typing import Any
  9. from app.core.common.identifiers import new_governance_uid
  10. from app.core.system.enterprise_identity import IdentityPolicyError
  11. from app.core.system.tokens import issue_access_token
  12. @dataclass(frozen=True)
  13. class SessionCredentials:
  14. session_uid: str
  15. access_token: str
  16. refresh_token: str
  17. class SessionManager:
  18. def __init__(self, repository: Any, *, secret: str, clock: Callable[[], datetime] | None = None,
  19. max_sessions: int = 5, refresh_lifetime: timedelta = timedelta(hours=8)) -> None:
  20. self.repository = repository
  21. self.secret = secret
  22. self.clock = clock or (lambda: datetime.now(UTC))
  23. self.max_sessions = max_sessions
  24. self.refresh_lifetime = refresh_lifetime
  25. @staticmethod
  26. def _digest(token: str) -> str:
  27. return hashlib.sha256(token.encode()).hexdigest()
  28. def _access(self, record: dict[str, Any]) -> str:
  29. return issue_access_token(user_id=record["user_uid"], roles=list(record["roles"]), secret=self.secret,
  30. now=self.clock(), session_uid=record["uid"], token_version=record["token_version"],
  31. identity_source=record["identity_source"])
  32. def create(self, *, subject: str, user_uid: str, roles: list[str], identity_source: str,
  33. token_version: int = 1, provider_uid: str | None = None,
  34. emergency_request_uid: str | None = None,
  35. emergency_expires_at: datetime | None = None, commit: bool = True) -> SessionCredentials:
  36. if identity_source not in {"oidc", "local", "emergency"}:
  37. raise IdentityPolicyError("unsupported identity source")
  38. if identity_source == "oidc" and not provider_uid:
  39. raise IdentityPolicyError("OIDC session requires provider binding")
  40. if identity_source == "emergency" and (not emergency_request_uid or not emergency_expires_at):
  41. raise IdentityPolicyError("emergency session requires request and expiry binding")
  42. now = self.clock()
  43. if identity_source == "emergency" and not self.repository.emergency_session_active(
  44. str(emergency_request_uid), user_uid, now):
  45. raise IdentityPolicyError("emergency account is no longer an active local administrator")
  46. refresh = secrets.token_urlsafe(48)
  47. record = {"uid": new_governance_uid(), "family_uid": new_governance_uid(), "subject": subject,
  48. "user_uid": user_uid, "roles": sorted(set(roles)), "identity_source": identity_source,
  49. "provider_uid": provider_uid, "emergency_request_uid": emergency_request_uid,
  50. "token_version": token_version, "refresh_hash": self._digest(refresh), "status": "active",
  51. "created_at": now, "last_seen_at": now,
  52. "expires_at": min(now + self.refresh_lifetime, emergency_expires_at) if emergency_expires_at else now + self.refresh_lifetime,
  53. "rotated_from_uid": None, "risk_reason": None}
  54. self.repository.create_session(record, max_sessions=self.max_sessions, commit=commit)
  55. return SessionCredentials(record["uid"], self._access(record), refresh)
  56. def refresh(self, refresh_token: str) -> SessionCredentials:
  57. record = self.repository.get_session_by_refresh(self._digest(refresh_token))
  58. if not record:
  59. raise IdentityPolicyError("unknown refresh credential")
  60. if record["identity_source"] == "emergency" and not self.repository.emergency_session_active(
  61. record["emergency_request_uid"], record["user_uid"], self.clock()):
  62. self.repository.revoke_family(record["family_uid"], reason="emergency_window_closed")
  63. raise IdentityPolicyError("emergency access window is not active")
  64. if record["status"] != "active":
  65. self.repository.revoke_family(record["family_uid"], reason="refresh_reuse")
  66. raise IdentityPolicyError("refresh reuse detected")
  67. if self.clock() >= record["expires_at"]:
  68. self.repository.update_session(record["uid"], status="revoked", revoke_reason="expired")
  69. raise IdentityPolicyError("refresh credential expired")
  70. refresh = secrets.token_urlsafe(48)
  71. next_expiry = self.clock() + self.refresh_lifetime
  72. if record["identity_source"] == "emergency":
  73. next_expiry = min(record["expires_at"], next_expiry)
  74. next_record = {**record, "uid": new_governance_uid(), "refresh_hash": self._digest(refresh),
  75. "status": "active", "created_at": self.clock(), "last_seen_at": self.clock(),
  76. "expires_at": next_expiry, "rotated_from_uid": record["uid"]}
  77. outcome = self.repository.rotate_refresh(self._digest(refresh_token), next_record)
  78. if outcome == "unknown":
  79. raise IdentityPolicyError("unknown refresh credential")
  80. if outcome == "reuse":
  81. raise IdentityPolicyError("refresh reuse detected")
  82. return SessionCredentials(next_record["uid"], self._access(next_record), refresh)
  83. def revoke(self, session_uid: str, *, reason: str = "logout") -> None:
  84. if self.repository.get_session(session_uid):
  85. self.repository.update_session(session_uid, status="revoked", revoke_reason=reason)
  86. def risk_revoke(self, session_uid: str, *, reason: str) -> None:
  87. self.repository.update_session(session_uid, status="revoked", revoke_reason="risk", risk_reason=reason)
  88. def revoke_subject(self, provider_uid: str | None, subject: str, *, reason: str) -> None:
  89. self.repository.revoke_subject_sessions(provider_uid, subject, reason=reason)