33from __future__ import annotations
44
55import hashlib
6+ import hmac
67import json
78import secrets
9+ import time
810import uuid
911
1012import bcrypt
1517
1618_COOKIE_NAME = "edictum_session"
1719_SESSION_PREFIX = "session:"
20+ _MAX_ABSOLUTE_LIFETIME = 7 * 24 * 3600 # 7 days — no session lives beyond this
1821
1922
2023class LocalAuthProvider (AuthProvider ):
21- """Bcrypt password auth with Redis-backed sessions."""
24+ """Bcrypt password auth with HMAC-signed Redis-backed sessions."""
2225
2326 def __init__ (
2427 self ,
2528 redis : aioredis .Redis ,
2629 session_ttl_hours : int = 24 ,
2730 * ,
2831 secure_cookies : bool = False ,
32+ secret_key : str ,
2933 ) -> None :
34+ if not secret_key :
35+ raise ValueError ("secret_key must not be empty" )
3036 self ._redis = redis
3137 self ._session_ttl = session_ttl_hours * 3600
3238 self ._secure_cookies = secure_cookies
39+ self ._secret_key = secret_key
40+
41+ def _sign (self , data : str ) -> str :
42+ """HMAC-SHA256 sign session data, return 'hmac_hex:json' string."""
43+ mac = hmac .new (
44+ self ._secret_key .encode (), data .encode (), hashlib .sha256
45+ ).hexdigest ()
46+ return f"{ mac } :{ data } "
47+
48+ def _verify_and_parse (self , raw : str ) -> dict [str , object ] | None :
49+ """Verify HMAC signature and return parsed session data, or None."""
50+ if ":" not in raw :
51+ return None
52+ stored_mac , _ , payload = raw .partition (":" )
53+ expected_mac = hmac .new (
54+ self ._secret_key .encode (), payload .encode (), hashlib .sha256
55+ ).hexdigest ()
56+ if not hmac .compare_digest (stored_mac , expected_mac ):
57+ return None
58+ try :
59+ return json .loads (payload ) # type: ignore[no-any-return]
60+ except (json .JSONDecodeError , ValueError ):
61+ return None
3362
3463 async def authenticate (self , request : Request ) -> DashboardAuthContext :
3564 token = request .cookies .get (_COOKIE_NAME )
@@ -44,14 +73,38 @@ async def authenticate(self, request: Request) -> DashboardAuthContext:
4473 status_code = status .HTTP_401_UNAUTHORIZED ,
4574 detail = "Session expired or invalid." ,
4675 )
47- data = json .loads (raw )
76+
77+ # Verify HMAC signature — reject tampered sessions
78+ raw_str = raw if isinstance (raw , str ) else raw .decode ()
79+ data = self ._verify_and_parse (raw_str )
80+ if data is None :
81+ # Tampered or legacy unsigned session — destroy it
82+ await self ._redis .delete (f"{ _SESSION_PREFIX } { token } " )
83+ raise HTTPException (
84+ status_code = status .HTTP_401_UNAUTHORIZED ,
85+ detail = "Session expired or invalid." ,
86+ )
87+
88+ # Enforce absolute session lifetime (7 days max)
89+ created_at = data .get ("created_at" )
90+ expired = (
91+ isinstance (created_at , (int , float ))
92+ and time .time () - created_at > _MAX_ABSOLUTE_LIFETIME
93+ )
94+ if expired :
95+ await self ._redis .delete (f"{ _SESSION_PREFIX } { token } " )
96+ raise HTTPException (
97+ status_code = status .HTTP_401_UNAUTHORIZED ,
98+ detail = "Session expired. Please log in again." ,
99+ )
100+
48101 # Slide the expiration on each successful auth
49102 await self ._redis .expire (f"{ _SESSION_PREFIX } { token } " , self ._session_ttl )
50103 return DashboardAuthContext (
51- user_id = uuid .UUID (data ["user_id" ]),
52- tenant_id = uuid .UUID (data ["tenant_id" ]),
53- email = data ["email" ],
54- is_admin = data ["is_admin" ],
104+ user_id = uuid .UUID (str ( data ["user_id" ]) ),
105+ tenant_id = uuid .UUID (str ( data ["tenant_id" ]) ),
106+ email = str ( data ["email" ]) ,
107+ is_admin = bool ( data ["is_admin" ]) ,
55108 )
56109
57110 async def create_session (
@@ -68,11 +121,14 @@ async def create_session(
68121 "tenant_id" : str (tenant_id ),
69122 "email" : email ,
70123 "is_admin" : is_admin ,
124+ "created_at" : time .time (),
71125 }
72126 )
127+ # HMAC-sign session data to prevent forgery via Redis access
128+ signed = self ._sign (session_data )
73129 await self ._redis .set (
74130 f"{ _SESSION_PREFIX } { token } " ,
75- session_data ,
131+ signed ,
76132 ex = self ._session_ttl ,
77133 )
78134 cookie_params : dict [str , str | bool | int ] = {
0 commit comments