Coverage for src/local_deep_research/web/auth/session_manager.py: 100%

110 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2Session management for encrypted database connections. 

3Handles session creation, validation, and cleanup. 

4""" 

5 

6import datetime 

7import secrets 

8import threading 

9from datetime import UTC 

10from typing import Dict, Optional, Set 

11 

12from loguru import logger 

13 

14from ...security import get_security_default 

15 

16 

17class SessionManager: 

18 """Manages user sessions and database connection lifecycle. 

19 

20 Note: session state is in-memory and per-process (no Redis or other 

21 shared store). In multi-worker deployments (e.g. gunicorn with more 

22 than one worker) each worker holds its own ``sessions`` dict, and a 

23 worker restart drops all sessions it held. ``validate_session`` only 

24 ever sees sessions created by *this* process — including for callers 

25 like the socket connect-time session gate, which now relies on this 

26 lookup. See ``security/account_lockout.py`` and 

27 ``web/routers/rag.py`` for the same caveat elsewhere in the codebase. 

28 """ 

29 

30 def __init__(self): 

31 self.sessions: Dict[str, dict] = {} 

32 # WARNING: never call logger.* while holding _lock. The synchronous 

33 # frontend_progress_sink can re-enter emit_to_subscribers -> 

34 # touch_session -> _lock on the same thread and self-deadlock (this is 

35 # a plain Lock, not an RLock). 

36 self._lock = threading.Lock() 

37 # Load session timeouts from security settings 

38 session_hours = get_security_default( 

39 "security.session_timeout_hours", 2 

40 ) 

41 remember_days = get_security_default( 

42 "security.session_remember_me_days", 30 

43 ) 

44 self.session_timeout = datetime.timedelta(hours=session_hours) 

45 self.remember_me_timeout = datetime.timedelta(days=remember_days) 

46 

47 def create_session(self, username: str, remember_me: bool = False) -> str: 

48 """Create a new session for a user. 

49 

50 Args: 

51 username: The username to create a session for. 

52 remember_me: If True, use extended session timeout. 

53 

54 Returns: 

55 The session ID as a URL-safe string. 

56 """ 

57 session_id = secrets.token_urlsafe(32) 

58 

59 with self._lock: 

60 self.sessions[session_id] = { 

61 "username": username, 

62 "created_at": datetime.datetime.now(UTC), 

63 "last_access": datetime.datetime.now(UTC), 

64 "remember_me": remember_me, 

65 } 

66 

67 logger.debug(f"Created session {session_id[:8]}... for user {username}") 

68 return session_id 

69 

70 def validate_session(self, session_id: str) -> Optional[str]: 

71 """ 

72 Validate a session and return username if valid. 

73 Updates last access time. 

74 """ 

75 expired_username = None 

76 with self._lock: 

77 if session_id not in self.sessions: 

78 return None 

79 

80 session_data = self.sessions[session_id] 

81 now = datetime.datetime.now(UTC) 

82 

83 # Check timeout 

84 timeout = ( 

85 self.remember_me_timeout 

86 if session_data["remember_me"] 

87 else self.session_timeout 

88 ) 

89 if now - session_data["last_access"] > timeout: 

90 # Session expired — remove inline (already under lock). 

91 # Defer the log call until after the lock is released: see 

92 # the warning in __init__ about not logging while holding 

93 # _lock. 

94 expired_username = session_data["username"] 

95 del self.sessions[session_id] 

96 else: 

97 # Update last access 

98 session_data["last_access"] = now 

99 return str(session_data["username"]) 

100 

101 logger.debug( 

102 f"Session {session_id[:8]}... expired for {expired_username}" 

103 ) 

104 return None 

105 

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

107 """Refresh a session's ``last_access`` without enforcing the timeout. 

108 

109 Called on socket activity: a user actively watching a long research 

110 run makes no HTTP requests, so the request-path idle-timeout refresh 

111 (in ``validate_session``) never fires and the session would silently 

112 expire mid-run. Unlike ``validate_session`` this never deletes an 

113 expired session — it only bumps ``last_access`` for one still present. 

114 """ 

115 if not session_id: 

116 return 

117 with self._lock: 

118 session_data = self.sessions.get(session_id) 

119 if session_data is not None: 

120 session_data["last_access"] = datetime.datetime.now(UTC) 

121 

122 def destroy_session(self, session_id: str): 

123 """Destroy a session and clean up.""" 

124 username = None 

125 with self._lock: 

126 if session_id in self.sessions: 

127 username = self.sessions[session_id]["username"] 

128 del self.sessions[session_id] 

129 

130 # Logged after releasing the lock: see the warning in __init__ 

131 # about not logging while holding _lock. 

132 if username is not None: 

133 logger.debug( 

134 f"Destroyed session {session_id[:8]}... for user {username}" 

135 ) 

136 

137 def destroy_all_user_sessions(self, username: str) -> int: 

138 """Destroy all sessions for a given user. Returns count destroyed.""" 

139 with self._lock: 

140 to_delete = [ 

141 sid 

142 for sid, data in self.sessions.items() 

143 if data["username"] == username 

144 ] 

145 for sid in to_delete: 

146 del self.sessions[sid] 

147 if to_delete: 

148 logger.debug( 

149 f"Destroyed {len(to_delete)} session(s) for user {username}" 

150 ) 

151 return len(to_delete) 

152 

153 def cleanup_expired_sessions(self): 

154 """Remove all expired sessions and disconnect their live sockets.""" 

155 now = datetime.datetime.now(UTC) 

156 expired = [] 

157 

158 with self._lock: 

159 for session_id, data in self.sessions.items(): 

160 timeout = ( 

161 self.remember_me_timeout 

162 if data["remember_me"] 

163 else self.session_timeout 

164 ) 

165 if now - data["last_access"] > timeout: 

166 expired.append(session_id) 

167 

168 for session_id in expired: 

169 del self.sessions[session_id] 

170 

171 if expired: 

172 logger.info(f"Cleaned up {len(expired)} expired sessions") 

173 # Tear down any sockets still attached to these now-dead sessions. 

174 # A socket is authorised once at handshake and then frozen, so 

175 # without this an expired session's tab keeps receiving the user's 

176 # events. Done AFTER releasing the lock (disconnect reaches into 

177 # the socket server) and best-effort — socket teardown must never 

178 # break session cleanup. 

179 self._disconnect_expired_sockets(expired) 

180 

181 @staticmethod 

182 def _disconnect_expired_sockets(session_ids: list[str]) -> None: 

183 """Best-effort disconnect of the sockets for expired session ids. 

184 

185 Lazy-imports the socket layer to avoid an import cycle and to 

186 tolerate its absence in non-web contexts (CLI, tests). Each session is 

187 torn down independently so one failure can't strand the rest. 

188 

189 Retargeted from the deleted Flask ``SocketIOService`` onto the ASGI 

190 layer. The old import raised ``ModuleNotFoundError`` into the bare 

191 ``except Exception`` below, so this path silently disconnected 

192 nothing: an idle-expired session's sockets stayed live and kept 

193 receiving that user's events -- including ``settings_changed``, which 

194 carries plaintext secrets. It was masked whenever the idle-DB-close 

195 sweep happened to tear the user down anyway, but not when they had 

196 another live session or an in-flight research run. 

197 """ 

198 try: 

199 from ..services.socketio_asgi import disconnect_session 

200 except Exception: 

201 logger.opt(exception=True).warning( 

202 "Failed to resolve socket layer for expired-session teardown" 

203 ) 

204 return 

205 for session_id in session_ids: 

206 try: 

207 disconnect_session(session_id) 

208 except Exception: 

209 logger.opt(exception=True).warning( 

210 "Failed to disconnect sockets for an expired session" 

211 ) 

212 

213 def get_active_sessions_count(self) -> int: 

214 """Get count of active sessions.""" 

215 self.cleanup_expired_sessions() 

216 with self._lock: 

217 return len(self.sessions) 

218 

219 def get_user_sessions(self, username: str) -> list: 

220 """Get all active sessions for a user.""" 

221 user_sessions = [] 

222 with self._lock: 

223 for session_id, data in self.sessions.items(): 

224 if data["username"] == username: 

225 user_sessions.append( 

226 { 

227 "session_id": session_id[:8] + "...", 

228 "created_at": data["created_at"], 

229 "last_access": data["last_access"], 

230 "remember_me": data["remember_me"], 

231 } 

232 ) 

233 return user_sessions 

234 

235 def get_active_usernames(self) -> Set[str]: 

236 """Return set of usernames with at least one non-expired session.""" 

237 now = datetime.datetime.now(UTC) 

238 with self._lock: 

239 return { 

240 data["username"] 

241 for data in self.sessions.values() 

242 if now - data["last_access"] 

243 <= ( 

244 self.remember_me_timeout 

245 if data["remember_me"] 

246 else self.session_timeout 

247 ) 

248 } 

249 

250 def has_active_sessions_for(self, username: str) -> bool: 

251 """Check if a user has any non-expired sessions.""" 

252 now = datetime.datetime.now(UTC) 

253 with self._lock: 

254 for data in self.sessions.values(): 

255 if data["username"] != username: 

256 continue 

257 timeout = ( 

258 self.remember_me_timeout 

259 if data["remember_me"] 

260 else self.session_timeout 

261 ) 

262 if now - data["last_access"] <= timeout: 

263 return True 

264 return False 

265 

266 

267# Module-level singleton 

268session_manager = SessionManager()