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
@@ -0,0 +1,74 @@
"""Tests for PUT /api/users/{id}/password (admin only) - issue #25(c).
Kept in its own file rather than test_users.py: that file covers the rest of
the users router, worked on in a different lot in the same session.
"""
from httpx import AsyncClient
from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers
class TestChangeUserPassword:
async def test_admin_changes_password_invalidates_api_key(
self, client: AsyncClient, admin_user: User, maker_user: User
):
"""Changing a user's password invalidates their existing api_key,
the same as setup.py's change-user-password (setup.py:634).
"""
old_key = maker_user.api_key
old_username = maker_user.username
assert old_key is not None
resp = await client.put(
f"/api/users/{maker_user.id}/password",
headers=auth_headers(admin_user),
json={"password": "NewSecurePass1"},
)
assert resp.status_code == 200
assert "changed" in resp.json()["message"].lower()
# The old key no longer authenticates. Read it before the request
# above: the client and app share one session (see conftest), so
# `maker_user.api_key` in this process reflects the mutation too.
resp2 = await client.get("/api/auth/me", headers={"X-API-Key": old_key})
assert resp2.status_code == 401
# The new password works and issues a fresh key.
resp3 = await client.post(
"/api/auth/login",
json={"username": old_username, "password": "NewSecurePass1"},
)
assert resp3.status_code == 200
assert resp3.json()["api_key"] != old_key
async def test_non_admin_forbidden(
self, client: AsyncClient, maker_user: User
):
resp = await client.put(
f"/api/users/{maker_user.id}/password",
headers=auth_headers(maker_user),
json={"password": "WhateverPass1"},
)
assert resp.status_code == 403
async def test_user_not_found(
self, client: AsyncClient, admin_user: User
):
resp = await client.put(
"/api/users/999999/password",
headers=auth_headers(admin_user),
json={"password": "WhateverPass1"},
)
assert resp.status_code == 404
async def test_password_too_short_rejected(
self, client: AsyncClient, admin_user: User, maker_user: User
):
resp = await client.put(
f"/api/users/{maker_user.id}/password",
headers=auth_headers(admin_user),
json={"password": "short"},
)
assert resp.status_code == 422