fix(auth): api_key condivisa fra postazioni, timing costante al login (#25)

login_user riusa l'api_key esistente invece di rigenerarla ad ogni
login: due postazioni collegate con lo stesso utente restavano
altrimenti disconnesse a vicenda. logout_user diventa no-op sulla key
a meno di all_sessions=True (nuovo parametro, passato da
POST /api/auth/logout?all_sessions=true nel router).

authenticate_user esegue comunque bcrypt.checkpw contro un hash fittizio
quando lo username non esiste, per non rivelare col tempo di risposta
se uno username è registrato.

PUT /api/users/{id}/password azzera l'api_key dell'utente dopo il
cambio, come già fa setup.py:634.

Non introdotto in questa issue: hashing o scadenza della api_key
(annotato come MANCA nel rapporto del lotto).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:40:05 +02:00
parent 302903a8a7
commit ac76c0a395
5 changed files with 207 additions and 8 deletions
+30 -5
View File
@@ -27,6 +27,13 @@ def generate_api_key() -> str:
return secrets.token_urlsafe(48) # 64 chars base64
# A precomputed, unused bcrypt hash. When the username does not match any
# user, authenticate_user still runs bcrypt against this instead of
# returning immediately, so "unknown username" costs the same as "wrong
# password" and a caller cannot use response time to enumerate usernames.
_DUMMY_PASSWORD_HASH = "$2b$12$BLtwrR2COyd5W/UWfxp7BOc7OHua9jazo6zZdWOwRD9PV.pyaqKZC"
async def authenticate_user(
db: AsyncSession, username: str, password: str
) -> User | None:
@@ -35,7 +42,10 @@ async def authenticate_user(
select(User).where(User.username == username, User.active == True)
)
user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.password_hash):
if user is None:
verify_password(password, _DUMMY_PASSWORD_HASH)
return None
if not verify_password(password, user.password_hash):
return None
return user
@@ -66,8 +76,14 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
async def login_user(db: AsyncSession, user: User) -> str:
"""Generate API key and update last_login for user."""
api_key = generate_api_key()
"""Generate (or reuse) the API key and update last_login for user.
Reuses an existing key rather than rotating it on every login: the same
account logged in from two stations at once (a common shift-account
pattern here) must keep both sessions working. Rotating the key would
silently log the other station out.
"""
api_key = user.api_key or generate_api_key()
await db.execute(
update(User)
.where(User.id == user.id)
@@ -77,8 +93,17 @@ async def login_user(db: AsyncSession, user: User) -> str:
return api_key
async def logout_user(db: AsyncSession, user: User) -> None:
"""Invalidate user's API key."""
async def logout_user(
db: AsyncSession, user: User, all_sessions: bool = False
) -> None:
"""Log out. By default a no-op on the shared API key.
Clearing the key on every logout would kick out any other station still
using the same shift account. `all_sessions=True` is the explicit
override for a supervisor who wants every session logged out.
"""
if not all_sessions:
return
await db.execute(
update(User).where(User.id == user.id).values(api_key=None)
)