@@ -140,6 +140,15 @@ def __init__(self, auth_path: str = DEFAULT_AUTH_PATH):
140140 # detect sessions written by other uvicorn workers (see
141141 # _reload_sessions_if_changed).
142142 self ._sessions_mtime_ns = - 1
143+ # Tokens present in sessions.json at the last disk sync. Used to
144+ # distinguish "revoked by another worker" (was on disk, now gone —
145+ # drop it) from "issued locally moments ago, racing its own save"
146+ # (never seen on disk — keep it).
147+ self ._disk_tokens : set = set ()
148+ # Tokens this worker revoked whose removal may not yet be visible
149+ # on disk. A disk sync must never re-add these; pruned once the
150+ # on-disk file no longer contains them.
151+ self ._revoked_tokens : set = set ()
143152 self ._load ()
144153 self ._load_sessions ()
145154 self ._migrate_single_user ()
@@ -149,6 +158,12 @@ def __init__(self, auth_path: str = DEFAULT_AUTH_PATH):
149158 def _load (self ):
150159 try :
151160 if os .path .exists (self .auth_path ):
161+ # Contains password hashes — restrict pre-existing files
162+ # written before the 0600 policy.
163+ try :
164+ os .chmod (self .auth_path , 0o600 )
165+ except OSError :
166+ pass
152167 with open (self .auth_path , "r" , encoding = "utf-8" ) as f :
153168 self ._config = json .load (f )
154169 # Normalize all stored usernames to lowercase so they match
@@ -172,11 +187,19 @@ def _load_sessions(self):
172187 """Load persisted session tokens from disk, pruning expired ones."""
173188 try :
174189 if os .path .exists (self ._sessions_path ):
190+ # Session tokens are bearer credentials — never leave the
191+ # file readable by other local users (same policy as
192+ # data/app.db, #4420).
193+ try :
194+ os .chmod (self ._sessions_path , 0o600 )
195+ except OSError :
196+ pass
175197 self ._sessions_mtime_ns = os .stat (self ._sessions_path ).st_mtime_ns
176198 with open (self ._sessions_path , "r" , encoding = "utf-8" ) as f :
177199 data = json .load (f )
178200 now = time .time ()
179201 self ._sessions = {k : v for k , v in data .items () if v .get ("expiry" , 0 ) > now }
202+ self ._disk_tokens = set (data )
180203 pruned = len (data ) - len (self ._sessions )
181204 if pruned > 0 :
182205 self ._save_sessions ()
@@ -186,19 +209,23 @@ def _load_sessions(self):
186209 self ._sessions = {}
187210
188211 def _reload_sessions_if_changed (self ):
189- """Merge sessions written by other uvicorn workers.
190-
191- The OIDC callback (or a password login) may run on one worker while
192- the browser's next request lands on another; each worker loads
193- sessions.json only at startup, so the new token would be rejected.
194- Called on a token miss: when the file's mtime has changed since the
195- last load, re-read it and add unknown unexpired tokens to the
196- in-memory map. The mtime gate keeps unknown-token spam at one
197- os.stat per request, not a JSON parse.
198-
199- Additive only — tokens missing from disk are NOT dropped from
200- memory, so a token issued moments ago on this worker can't be lost
201- to a reload racing its own _save_sessions.
212+ """Sync session state written by other uvicorn workers.
213+
214+ The OIDC callback (or a password login/logout) may run on one
215+ worker while the browser's next request lands on another; each
216+ worker loads sessions.json only at startup, so cross-worker
217+ issuance and revocation would otherwise be invisible. Called on
218+ every token validation: when the file's mtime has changed since
219+ the last sync, re-read it and
220+
221+ - add unknown unexpired tokens (issued by another worker), and
222+ - drop in-memory tokens that were on disk at the last sync but
223+ are gone now (revoked by another worker).
224+
225+ A token never yet seen on disk is kept — it was issued locally
226+ moments ago and may be racing its own _save_sessions. The mtime
227+ gate keeps the steady-state cost at one os.stat per validation,
228+ not a JSON parse.
202229 """
203230 try :
204231 stat = os .stat (self ._sessions_path )
@@ -216,21 +243,76 @@ def _reload_sessions_if_changed(self):
216243 self ._sessions_mtime_ns = stat .st_mtime_ns
217244 if not isinstance (data , dict ):
218245 return
219- now = time .time ()
220- for tok , sess in data .items ():
221- if (
222- tok not in self ._sessions
223- and isinstance (sess , dict )
224- and sess .get ("expiry" , 0 ) > now
225- ):
226- self ._sessions [tok ] = sess
246+ self ._apply_disk_sessions (data )
247+
248+ def _apply_disk_sessions (self , data : Dict [str , Any ]) -> None :
249+ """Merge parsed sessions.json content into memory.
250+
251+ Caller must hold ``_sessions_lock``. Adds unknown unexpired
252+ tokens (unless this worker revoked them and the removal hasn't
253+ reached disk yet), drops tokens revoked by other workers, and
254+ refreshes the disk-snapshot bookkeeping.
255+ """
256+ now = time .time ()
257+ for tok , sess in data .items ():
258+ if (
259+ tok not in self ._sessions
260+ and tok not in self ._revoked_tokens
261+ and isinstance (sess , dict )
262+ and sess .get ("expiry" , 0 ) > now
263+ ):
264+ self ._sessions [tok ] = sess
265+ revoked_elsewhere = [
266+ tok for tok in self ._sessions
267+ if tok not in data and tok in self ._disk_tokens
268+ ]
269+ for tok in revoked_elsewhere :
270+ self ._sessions .pop (tok , None )
271+ self ._disk_tokens = set (data )
272+ # A tombstone is only needed while the token is still on disk.
273+ self ._revoked_tokens &= self ._disk_tokens
274+
275+ @contextmanager
276+ def _interprocess_sessions_lock (self ):
277+ """Serialise sessions.json read-merge-write cycles across uvicorn
278+ workers. Separate lock file from the auth.json IPC lock so a
279+ session save can never deadlock a caller already holding the auth
280+ lock (flock is not re-entrant across file descriptors)."""
281+ if not HAS_FCNTL :
282+ yield
283+ return
284+ fd = os .open (self ._sessions_path + ".lock" , os .O_CREAT | os .O_RDWR , 0o600 )
285+ try :
286+ fcntl .flock (fd , fcntl .LOCK_EX )
287+ yield
288+ finally :
289+ fcntl .flock (fd , fcntl .LOCK_UN )
290+ os .close (fd )
227291
228292 def _save_sessions (self ):
229- """Persist session tokens to disk (atomic, lock-guarded)."""
293+ """Persist session tokens to disk (atomic, merge-on-write).
294+
295+ Merges the current on-disk state before writing, under an
296+ inter-process flock — a plain overwrite would clobber sessions
297+ issued by other workers since this worker's last sync (lost
298+ update). Tombstones in ``_revoked_tokens`` keep just-revoked
299+ tokens from being re-merged and resurrected.
300+ """
230301 try :
231- with self ._sessions_lock :
302+ with self ._interprocess_sessions_lock (), self ._sessions_lock :
303+ try :
304+ with open (self ._sessions_path , "r" , encoding = "utf-8" ) as f :
305+ data = json .load (f )
306+ if isinstance (data , dict ):
307+ self ._apply_disk_sessions (data )
308+ except OSError :
309+ pass # first save — no file yet
310+ except Exception as e :
311+ logger .error (f"Failed to merge sessions before save: { e } " )
232312 snapshot = dict (self ._sessions )
233- _atomic_write_json (self ._sessions_path , snapshot )
313+ _atomic_write_json (self ._sessions_path , snapshot , mode = 0o600 )
314+ self ._disk_tokens = set (snapshot )
315+ self ._revoked_tokens &= self ._disk_tokens
234316 except Exception as e :
235317 logger .error (f"Failed to save sessions: { e } " )
236318
@@ -295,7 +377,8 @@ def _migrate_legacy_admin_role(self):
295377 self ._save ()
296378
297379 def _save (self ):
298- _atomic_write_json (self .auth_path , self ._config , indent = 2 )
380+ # Password hashes — owner-only, same policy as sessions.json.
381+ _atomic_write_json (self .auth_path , self ._config , indent = 2 , mode = 0o600 )
299382
300383 @property
301384 def users (self ) -> Dict [str , Any ]:
@@ -622,6 +705,7 @@ def delete_user(self, username: str, requesting_user: str) -> bool:
622705 if (sess or {}).get ("username" ) == username ]
623706 for tok in to_drop :
624707 self ._sessions .pop (tok , None )
708+ self ._revoked_tokens .add (tok )
625709 revoked += 1
626710 if revoked :
627711 self ._save_sessions ()
@@ -923,11 +1007,8 @@ def create_session_trusted(self, username: str) -> Optional[str]:
9231007 def validate_token (self , token : Optional [str ]) -> bool :
9241008 if not token :
9251009 return False
926- with self ._sessions_lock :
927- known = token in self ._sessions
928- if not known :
929- # May have been issued by another worker — read through to disk.
930- self ._reload_sessions_if_changed ()
1010+ # Sync issuance/revocation from other workers (mtime-gated).
1011+ self ._reload_sessions_if_changed ()
9311012 expired = False
9321013 deleted_user = False
9331014 with self ._sessions_lock :
@@ -944,6 +1025,7 @@ def validate_token(self, token: Optional[str]) -> bool:
9441025 # silently authenticating against a non-existent account.
9451026 if session .get ("username" ) not in self .users :
9461027 self ._sessions .pop (token , None )
1028+ self ._revoked_tokens .add (token )
9471029 deleted_user = True
9481030 if expired or deleted_user :
9491031 self ._save_sessions ()
@@ -954,11 +1036,8 @@ def get_username_for_token(self, token: Optional[str]) -> Optional[str]:
9541036 """Return the username associated with a valid token."""
9551037 if not token :
9561038 return None
957- with self ._sessions_lock :
958- known = token in self ._sessions
959- if not known :
960- # May have been issued by another worker — read through to disk.
961- self ._reload_sessions_if_changed ()
1039+ # Sync issuance/revocation from other workers (mtime-gated).
1040+ self ._reload_sessions_if_changed ()
9621041 expired = False
9631042 deleted_user = False
9641043 with self ._sessions_lock :
@@ -973,6 +1052,7 @@ def get_username_for_token(self, token: Optional[str]) -> Optional[str]:
9731052 # SECURITY: orphan check — same rationale as validate_token.
9741053 if _u not in self .users :
9751054 self ._sessions .pop (token , None )
1055+ self ._revoked_tokens .add (token )
9761056 deleted_user = True
9771057 else :
9781058 return _u
@@ -983,6 +1063,7 @@ def get_username_for_token(self, token: Optional[str]) -> Optional[str]:
9831063 def revoke_token (self , token : str ):
9841064 with self ._sessions_lock :
9851065 self ._sessions .pop (token , None )
1066+ self ._revoked_tokens .add (token )
9861067 self ._save_sessions ()
9871068
9881069 def revoke_user_sessions (self , username : str , except_token : Optional [str ] = None ) -> int :
@@ -996,9 +1077,13 @@ def revoke_user_sessions(self, username: str, except_token: Optional[str] = None
9961077 ]
9971078 for token in to_drop :
9981079 self ._sessions .pop (token , None )
1080+ self ._revoked_tokens .add (token )
9991081 revoked += 1
1000- if revoked :
1001- self ._save_sessions ()
1082+ # Save outside _sessions_lock: _save_sessions acquires the
1083+ # inter-process flock before _sessions_lock, and taking them in
1084+ # the opposite order here could deadlock two threads.
1085+ if revoked :
1086+ self ._save_sessions ()
10021087 return revoked
10031088
10041089 def status (self , token : Optional [str ]) -> Dict [str , Any ]:
0 commit comments