Coverage for src/lexigram/admin/auth/services/session_service.py: 70%

97 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""Admin session lifecycle management service.""" 

2 

3from __future__ import annotations 

4 

5from datetime import UTC, datetime, timedelta 

6import hashlib 

7import hmac 

8import secrets 

9from typing import Any 

10 

11from lexigram.admin.auth.protocols import AdminSessionServiceProtocol 

12from lexigram.contracts.auth.repositories import SessionRepositoryProtocol 

13from lexigram.di.decorators import inject 

14from lexigram.logging import get_logger 

15from lexigram.serialization import dumps 

16from lexigram.serialization.backends.json import loads as _json_loads 

17 

18logger = get_logger(__name__) 

19 

20 

21@inject 

22class AdminSessionService: 

23 """Admin session lifecycle management service. 

24 

25 Wraps ``SessionRepositoryProtocol`` to handle creation, idle-timeout 

26 enforcement, absolute-expiry enforcement, activity tracking, and 

27 single/bulk revocation. 

28 

29 The ``fingerprint`` column in the underlying ``admin_sessions`` table is 

30 used to carry ``email`` and ``roles`` as a JSON-serialisable dict. 

31 PostgreSQL stores this as JSONB (auto-parsed on read); SQLite stores it 

32 as TEXT, which this service parses back to a dict transparently. 

33 

34 The ``fingerprint_sig`` column stores an HMAC-SHA256 signature of the 

35 fingerprint JSON to detect tampering (AUTH-05). 

36 """ 

37 

38 def __init__( 

39 self, 

40 session_repo: SessionRepositoryProtocol, 

41 session_lifetime: int = 86400, 

42 idle_timeout: int = 3600, 

43 fingerprint_secret: str = "", 

44 ) -> None: 

45 """Initialize with repository and lifetime configuration. 

46 

47 Args: 

48 session_repo: Repository that satisfies 

49 ``SessionRepositoryProtocol`` from ``lexigram-contracts``. 

50 session_lifetime: Absolute session lifetime in seconds 

51 (default 86 400 = 24 h). 

52 idle_timeout: Idle inactivity timeout in seconds 

53 (default 3 600 = 1 h). A session that has not been 

54 touched within this window is treated as expired. 

55 fingerprint_secret: HMAC key for fingerprint signing. 

56 When empty, signing is skipped (backward compatibility). 

57 """ 

58 self._repo = session_repo 

59 self._session_lifetime = session_lifetime 

60 self._idle_timeout = idle_timeout 

61 self._fingerprint_key: bytes = ( 

62 hashlib.sha256(fingerprint_secret.encode()).digest() 

63 if fingerprint_secret 

64 else b"" 

65 ) 

66 self._signing_enabled = bool(fingerprint_secret) 

67 

68 # ------------------------------------------------------------------ 

69 # AdminSessionServiceProtocol 

70 # ------------------------------------------------------------------ 

71 

72 async def create_session( 

73 self, 

74 user_id: str, 

75 email: str, 

76 roles: list[str], 

77 ip_address: str, 

78 user_agent: str, 

79 ) -> str: 

80 """Create a new session and return the session ID. 

81 

82 Generates a cryptographically secure session identifier, persists 

83 the session with absolute expiry and initial last-active timestamp, 

84 and stores ``email`` and ``roles`` in the ``fingerprint`` column so 

85 that they can be retrieved without a secondary user-table lookup. 

86 

87 When ``fingerprint_secret`` is configured, the fingerprint JSON is 

88 HMAC-SHA256 signed and the signature stored in ``fingerprint_sig``. 

89 

90 Args: 

91 user_id: Admin user UUID. 

92 email: Admin user email. 

93 roles: User's role names. 

94 ip_address: Client IP address. 

95 user_agent: Client user agent string. 

96 

97 Returns: 

98 New session identifier (``secrets.token_urlsafe(32)``). 

99 """ 

100 session_id = secrets.token_urlsafe(32) 

101 now = datetime.now(UTC) 

102 expires_at = now + timedelta(seconds=self._session_lifetime) 

103 

104 fingerprint: dict[str, Any] = {"email": email, "roles": roles} 

105 

106 payload: dict[str, Any] = { 

107 "session_id": session_id, 

108 "admin_id": user_id, 

109 "ip_address": ip_address, 

110 "user_agent": user_agent, 

111 "fingerprint": fingerprint, 

112 "expires_at": expires_at, 

113 "created_at": now, 

114 "last_active_at": now, 

115 } 

116 

117 if self._signing_enabled: 

118 payload["fingerprint_sig"] = self._sign_fingerprint(fingerprint) 

119 

120 await self._repo.insert(payload) 

121 logger.info( 

122 "session.created", 

123 session_id=session_id, 

124 user_id=user_id, 

125 expires_at=expires_at.isoformat(), 

126 ) 

127 return session_id 

128 

129 async def get_session(self, session_id: str) -> dict[str, Any] | None: 

130 """Retrieve session data if valid (not expired, not revoked). 

131 

132 Checks absolute expiry first, then idle timeout. Either condition 

133 triggers an immediate revocation of the session record so that 

134 subsequent requests cannot use it even if the ``is_active`` flag 

135 would otherwise still be TRUE. 

136 

137 When ``fingerprint_secret`` is configured, the fingerprint signature 

138 is verified on read. A mismatch causes immediate revocation. 

139 

140 Args: 

141 session_id: Session to retrieve. 

142 

143 Returns: 

144 Session data dict (including parsed ``fingerprint``) or ``None`` 

145 if the session does not exist, is revoked, or has expired. 

146 """ 

147 row = await self._repo.find_active(session_id) 

148 if row is None: 

149 return None 

150 

151 now = datetime.now(UTC) 

152 

153 # --- Absolute expiry check --- 

154 expires_at = _parse_dt(row.get("expires_at")) 

155 if expires_at is not None and expires_at <= now: 

156 await self._repo.revoke(session_id) 

157 logger.debug("session.expired_absolute", session_id=session_id) 

158 return None 

159 

160 # --- Idle timeout check --- 

161 last_active_at = _parse_dt(row.get("last_active_at")) 

162 if last_active_at is not None: 

163 idle_deadline = last_active_at + timedelta(seconds=self._idle_timeout) 

164 if idle_deadline <= now: 

165 await self._repo.revoke(session_id) 

166 logger.debug("session.expired_idle", session_id=session_id) 

167 return None 

168 

169 # Return a copy with fingerprint normalised to a dict regardless of 

170 # whether the underlying store returned it as JSONB (already dict) or 

171 # TEXT (requires JSON parsing — SQLite path). 

172 result = dict(row) 

173 fingerprint = result.get("fingerprint") 

174 if isinstance(fingerprint, str): 

175 try: 

176 result["fingerprint"] = _json_loads(fingerprint) 

177 except (ValueError, TypeError): 

178 result["fingerprint"] = {} 

179 

180 # --- Fingerprint HMAC verification (AUTH-05) --- 

181 if self._signing_enabled: 

182 stored_sig: str | None = result.get("fingerprint_sig") 

183 if not stored_sig or not self._verify_fingerprint( 

184 result["fingerprint"], stored_sig 

185 ): 

186 logger.warning( 

187 "session.fingerprint_tampered", 

188 session_id=session_id, 

189 ) 

190 await self._repo.revoke(session_id) 

191 return None 

192 

193 return result 

194 

195 # ------------------------------------------------------------------ 

196 # Fingerprint HMAC helpers (AUTH-05) 

197 # ------------------------------------------------------------------ 

198 

199 def _sign_fingerprint(self, fingerprint: dict[str, Any]) -> str: 

200 """HMAC-SHA256 sign the fingerprint dict. 

201 

202 Args: 

203 fingerprint: Fingerprint dict (email, roles). 

204 

205 Returns: 

206 Hex-encoded HMAC-SHA256 signature. 

207 """ 

208 raw = dumps(fingerprint, sort_keys=True) 

209 return hmac.new(self._fingerprint_key, raw, hashlib.sha256).hexdigest() 

210 

211 def _verify_fingerprint(self, fingerprint: dict[str, Any], signature: str) -> bool: 

212 """Verify the HMAC-SHA256 signature of a fingerprint. 

213 

214 Args: 

215 fingerprint: Fingerprint dict to verify. 

216 signature: Previously stored hex-encoded signature. 

217 

218 Returns: 

219 True if the signature matches, False otherwise. 

220 """ 

221 expected = self._sign_fingerprint(fingerprint) 

222 return hmac.compare_digest(expected, signature) 

223 

224 # ------------------------------------------------------------------ 

225 # Touch / revoke 

226 # ------------------------------------------------------------------ 

227 

228 async def touch_session(self, session_id: str) -> None: 

229 """Update session last-active timestamp to now (UTC). 

230 

231 Args: 

232 session_id: Session to touch. 

233 """ 

234 now = datetime.now(UTC) 

235 await self._repo.update_activity(session_id, now) 

236 logger.debug("session.touched", session_id=session_id) 

237 

238 async def revoke_session(self, session_id: str) -> None: 

239 """Revoke a single session (logout). 

240 

241 Args: 

242 session_id: Session to revoke. 

243 """ 

244 await self._repo.revoke(session_id) 

245 logger.info("session.revoked", session_id=session_id) 

246 

247 async def revoke_all_user_sessions(self, user_id: str) -> None: 

248 """Revoke all active sessions for a user. 

249 

250 Delegates to the repository's ``revoke_all`` which issues a single 

251 bulk UPDATE rather than fetching and revoking sessions individually. 

252 

253 Args: 

254 user_id: Admin user UUID whose sessions are to be revoked. 

255 """ 

256 await self._repo.revoke_all(user_id) 

257 logger.info("session.revoked_all", user_id=user_id) 

258 

259 

260# --------------------------------------------------------------------------- 

261# Module-level helpers 

262# --------------------------------------------------------------------------- 

263 

264 

265def _parse_dt(value: Any) -> datetime | None: 

266 """Parse a datetime from a DB row value. 

267 

268 Handles both timezone-aware ``datetime`` objects returned by async 

269 PostgreSQL drivers (e.g. asyncpg) and ISO-8601 strings returned by 

270 SQLite drivers. Naïve datetimes are assumed to be UTC. 

271 

272 Args: 

273 value: Raw column value from the database row. 

274 

275 Returns: 

276 Timezone-aware ``datetime`` in UTC, or ``None`` if ``value`` is 

277 ``None`` or cannot be parsed. 

278 """ 

279 if value is None: 

280 return None 

281 if isinstance(value, datetime): 

282 if value.tzinfo is None: 

283 return value.replace(tzinfo=UTC) 

284 return value 

285 if isinstance(value, str): 

286 try: 

287 dt = datetime.fromisoformat(value) 

288 if dt.tzinfo is None: 

289 dt = dt.replace(tzinfo=UTC) 

290 return dt 

291 except ValueError: 

292 return None 

293 return None 

294 

295 

296# Verify structural subtyping at import time: AdminSessionService must 

297# satisfy AdminSessionServiceProtocol without explicit inheritance. 

298_: AdminSessionServiceProtocol = AdminSessionService.__new__(AdminSessionService) 

299 

300__all__ = ["AdminSessionService"]