Compare commits

..

2 Commits

Author SHA1 Message Date
Adriano 0d004c43ff Login: cancella le key scadute dell'utente (#41)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:34:30 +02:00
Adriano 35f46926a9 API key come hash SHA-256, una per sessione, TTL rinnovabile (#41)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:34:30 +02:00
14 changed files with 369 additions and 111 deletions
+21 -4
View File
@@ -1,10 +1,17 @@
"""API Key authentication dependency for FastAPI.""" """API Key authentication dependency for FastAPI."""
from datetime import timedelta
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db from src.backend.database import get_db
from src.backend.config import settings
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.services.auth_service import hash_api_key, utcnow
_RENEW_INTERVAL = timedelta(minutes=5)
async def get_current_user( async def get_current_user(
@@ -24,15 +31,25 @@ async def get_current_user(
) )
result = await db.execute( result = await db.execute(
select(User).where(User.api_key == api_key, User.active == True) select(ApiKey, User)
.join(User, User.id == ApiKey.user_id)
.where(ApiKey.key_hash == hash_api_key(api_key), User.active == True)
) )
user = result.scalar_one_or_none() row = result.first()
now = utcnow()
if user is None: if row is None or row[0].expires_at <= now:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or inactive API key", detail="Invalid or expired API key",
) )
key_row, user = row
# Sliding TTL, written at most once per _RENEW_INTERVAL to spare the DB.
if key_row.last_used_at is None or now - key_row.last_used_at > _RENEW_INTERVAL:
key_row.last_used_at = now
key_row.expires_at = now + timedelta(hours=settings.api_key_ttl_hours)
await db.flush()
# Store user_id in request state for access logging middleware # Store user_id in request state for access logging middleware
request.state.user_id = user.id request.state.user_id = user.id
+7 -6
View File
@@ -1,5 +1,5 @@
"""Authentication router - login, logout, profile.""" """Authentication router - login, logout, profile."""
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db from src.backend.database import get_db
@@ -34,15 +34,16 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout( async def logout(
request: Request,
all_sessions: bool = False, all_sessions: bool = False,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Logout. The API key is shared across stations logged in as the same """Logout. Deletes this session's API key only; ?all_sessions=true
user, so by default this does not invalidate it - only this station's deletes every key of the user."""
session (its Flask session, on the client side) ends. Pass await logout_user(
?all_sessions=true to invalidate the key everywhere.""" db, user, key=request.headers.get("X-API-Key"), all_sessions=all_sessions
await logout_user(db, user, all_sessions=all_sessions) )
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
+5 -9
View File
@@ -5,7 +5,6 @@ NOT protected by API Key auth (standalone access).
""" """
import hmac import hmac
import random import random
import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -25,7 +24,7 @@ from src.backend.models.orm import (
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
) )
from src.backend.models.orm.station import Station, StationRecipeAssignment from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.services.auth_service import hash_password from src.backend.services.auth_service import hash_password, issue_api_key, revoke_all_keys
from src.backend.services.measurement_service import calculate_pass_fail from src.backend.services.measurement_service import calculate_pass_fail
router = APIRouter(prefix="/api/setup", tags=["setup"]) router = APIRouter(prefix="/api/setup", tags=["setup"])
@@ -321,8 +320,6 @@ async def create_admin(body: CreateAdminBody):
"""Create an admin user with all roles.""" """Create an admin user with all roles."""
_check_password(body.password) _check_password(body.password)
api_key = secrets.token_hex(32)
async with async_session_factory() as session: async with async_session_factory() as session:
async with session.begin(): async with session.begin():
user = User( user = User(
@@ -332,9 +329,10 @@ async def create_admin(body: CreateAdminBody):
display_name=body.admin_display_name, display_name=body.admin_display_name,
roles=["Maker", "MeasurementTec", "Metrologist"], roles=["Maker", "MeasurementTec", "Metrologist"],
is_admin=True, is_admin=True,
api_key=api_key,
) )
session.add(user) session.add(user)
await session.flush()
api_key = await issue_api_key(session, user)
return { return {
"status": "ok", "status": "ok",
@@ -404,7 +402,6 @@ async def seed_demo_data(body: PasswordBody):
email=u["email"], email=u["email"],
roles=u["roles"], roles=u["roles"],
is_admin=u["is_admin"], is_admin=u["is_admin"],
api_key=secrets.token_hex(32),
) )
session.add(user) session.add(user)
created_users.append(user) created_users.append(user)
@@ -652,7 +649,6 @@ async def manage_user(body: ManageUserBody):
email=body.email, email=body.email,
roles=body.roles, roles=body.roles,
is_admin=body.is_admin, is_admin=body.is_admin,
api_key=secrets.token_hex(32),
) )
session.add(user) session.add(user)
await session.flush() await session.flush()
@@ -707,7 +703,7 @@ async def change_user_password(body: ChangeUserPasswordBody):
detail=f"User ID {body.user_id} not found", detail=f"User ID {body.user_id} not found",
) )
user.password_hash = hash_password(body.new_password) user.password_hash = hash_password(body.new_password)
user.api_key = None # Invalidate sessions await revoke_all_keys(session, user.id) # Invalidate sessions
return {"status": "ok", "message": f"Password changed for '{user.username}'"} return {"status": "ok", "message": f"Password changed for '{user.username}'"}
@@ -730,7 +726,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
) )
user.active = not user.active user.active = not user.active
if not user.active: if not user.active:
user.api_key = None # Invalidate sessions on deactivation await revoke_all_keys(session, user.id) # on deactivation
new_status = "active" if user.active else "inactive" new_status = "active" if user.active else "inactive"
return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active} return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active}
+4 -4
View File
@@ -7,7 +7,7 @@ from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_admin_user from src.backend.api.middleware.api_key import require_admin_user
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key, revoke_all_keys
router = APIRouter(prefix="/api/users", tags=["users"]) router = APIRouter(prefix="/api/users", tags=["users"])
@@ -146,7 +146,7 @@ async def change_user_password(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.password_hash = hash_password(data.password) user.password_hash = hash_password(data.password)
user.api_key = None await revoke_all_keys(db, user.id)
await db.flush() await db.flush()
return {"message": f"Password changed for user {user.username}"} return {"message": f"Password changed for user {user.username}"}
@@ -168,7 +168,7 @@ async def deactivate_user(
detail="Cannot deactivate yourself", detail="Cannot deactivate yourself",
) )
user.active = False user.active = False
user.api_key = None await revoke_all_keys(db, user.id)
await db.flush() await db.flush()
@@ -183,5 +183,5 @@ async def regenerate_user_key(
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
new_key = await regenerate_api_key(db, user_id) new_key = await regenerate_api_key(db, user)
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"} return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}
+3
View File
@@ -38,6 +38,9 @@ class Settings(BaseSettings):
# configured limits to keep the cluster-wide effective limit close to what # configured limits to keep the cluster-wide effective limit close to what
# was configured instead of multiplying it by N. # was configured instead of multiplying it by N.
web_concurrency: int = 1 web_concurrency: int = 1
# API key session lifetime (hours); renewed on use (env API_KEY_TTL_HOURS).
api_key_ttl_hours: int = 24
# How many reverse proxies between the client and this API are trusted to # How many reverse proxies between the client and this API are trusted to
# have appended their own hop to X-Forwarded-For (Nginx, in the default # have appended their own hop to X-Forwarded-For (Nginx, in the default
# deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header # deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header
@@ -0,0 +1,54 @@
"""API keys as SHA-256 hashes, one per session (#41)
Replaces the plaintext `users.api_key` column with an `api_keys` table: one
row per login, holding only the SHA-256 hex digest, an expiry and the last
use. Existing keys are invalidated on purpose, not migrated - the plaintext
is exactly what must stop existing, so everybody logs in once again.
Revision ID: 015_api_key_hash
Revises: 014_vision_fk_ondelete
Create Date: 2026-10-06
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '015_api_key_hash'
down_revision: Union[str, None] = '014_vision_fk_ondelete'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'api_keys',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('key_hash', sa.String(64), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
),
)
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
with op.batch_alter_table('users') as batch:
batch.drop_index('ix_users_api_key')
batch.drop_constraint('uq_users_api_key', type_='unique')
batch.drop_column('api_key')
def downgrade() -> None:
with op.batch_alter_table('users') as batch:
batch.add_column(sa.Column('api_key', sa.String(64), nullable=True))
batch.create_unique_constraint('uq_users_api_key', ['api_key'])
batch.create_index('ix_users_api_key', ['api_key'])
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
op.drop_table('api_keys')
+2
View File
@@ -1,5 +1,6 @@
"""SQLAlchemy models for TieMeasureFlow.""" """SQLAlchemy models for TieMeasureFlow."""
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.recipe import Recipe, RecipeVersion from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeTask, RecipeSubtask from src.backend.models.orm.task import RecipeTask, RecipeSubtask
from src.backend.models.orm.measurement import Measurement from src.backend.models.orm.measurement import Measurement
@@ -11,6 +12,7 @@ from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
__all__ = [ __all__ = [
"User", "User",
"ApiKey",
"Recipe", "Recipe",
"RecipeVersion", "RecipeVersion",
"RecipeTask", "RecipeTask",
+25
View File
@@ -0,0 +1,25 @@
"""API key sessions: one row per login, stored as a SHA-256 hash."""
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from src.backend.database import Base
class ApiKey(Base):
"""One API key per login session.
Only the SHA-256 hex digest is stored, never the key. Dates are naive UTC.
"""
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
-1
View File
@@ -32,7 +32,6 @@ class User(Base):
# Status # Status
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
api_key: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, index=True)
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
+67 -35
View File
@@ -1,12 +1,15 @@
"""Authentication service - password hashing, API key management.""" """Authentication service - password hashing, API key management."""
import hashlib
import secrets import secrets
from datetime import datetime from datetime import datetime, timedelta, timezone
import bcrypt import bcrypt
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import select, update from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
@@ -58,8 +61,8 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
of them because it is the same question every time - who is this, and are they of them because it is the same question every time - who is this, and are they
allowed to say yes. allowed to say yes.
Uses authenticate_user rather than a full login: logging in would rotate the Uses authenticate_user rather than a full login: logging in would issue a
supervisor's API key and knock out whatever session they have open elsewhere. needless API key for the supervisor.
""" """
supervisor = await authenticate_user(db, username, password) supervisor = await authenticate_user(db, username, password)
if supervisor is None: if supervisor is None:
@@ -75,39 +78,72 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
return supervisor return supervisor
async def login_user(db: AsyncSession, user: User) -> str: def utcnow() -> datetime:
"""Generate (or reuse) the API key and update last_login for user. """Naive UTC now (the whole DB moves to naive UTC, #42)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
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 def hash_api_key(key: str) -> str:
pattern here) must keep both sessions working. Rotating the key would """SHA-256 hex digest of an API key; this is all the DB ever stores."""
silently log the other station out. return hashlib.sha256(key.encode("utf-8")).hexdigest()
"""
api_key = user.api_key or generate_api_key()
async def issue_api_key(db: AsyncSession, user: User) -> str:
"""Create a new session key for the user and return it in clear (once)."""
key = generate_api_key()
now = utcnow()
# Housekeeping: drop this user's expired keys so the table does not grow forever.
await db.execute( await db.execute(
update(User) delete(ApiKey).where(ApiKey.user_id == user.id, ApiKey.expires_at <= now)
.where(User.id == user.id) )
.values(api_key=api_key, last_login=datetime.utcnow()) db.add(
ApiKey(
user_id=user.id,
key_hash=hash_api_key(key),
created_at=now,
expires_at=now + timedelta(hours=settings.api_key_ttl_hours),
last_used_at=now,
)
)
await db.flush()
return key
async def revoke_all_keys(db: AsyncSession, user_id: int) -> None:
"""Invalidate every session key of a user."""
await db.execute(delete(ApiKey).where(ApiKey.user_id == user_id))
await db.flush()
async def login_user(db: AsyncSession, user: User) -> str:
"""Issue a new API key for this login and update last_login.
One key per login: the same account used from several stations at once
(#25) keeps every station working, because no login revokes another's key.
"""
api_key = await issue_api_key(db, user)
await db.execute(
update(User).where(User.id == user.id).values(last_login=utcnow())
) )
await db.flush() await db.flush()
return api_key return api_key
async def logout_user( async def logout_user(
db: AsyncSession, user: User, all_sessions: bool = False db: AsyncSession, user: User, key: str | None = None, all_sessions: bool = False
) -> None: ) -> None:
"""Log out. By default a no-op on the shared API key. """Log out. Deletes only the key of this session; all_sessions=True
deletes every key of the user."""
Clearing the key on every logout would kick out any other station still if all_sessions:
using the same shift account. `all_sessions=True` is the explicit await revoke_all_keys(db, user.id)
override for a supervisor who wants every session logged out.
"""
if not all_sessions:
return return
await db.execute( if key:
update(User).where(User.id == user.id).values(api_key=None) await db.execute(
) delete(ApiKey).where(
await db.flush() ApiKey.user_id == user.id, ApiKey.key_hash == hash_api_key(key)
)
)
await db.flush()
async def create_user( async def create_user(
@@ -138,11 +174,7 @@ async def create_user(
return user return user
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str: async def regenerate_api_key(db: AsyncSession, user: User) -> str:
"""Regenerate API key for a user.""" """Revoke every key of the user and issue a fresh one."""
new_key = generate_api_key() await revoke_all_keys(db, user.id)
await db.execute( return await issue_api_key(db, user)
update(User).where(User.id == user_id).values(api_key=new_key)
)
await db.flush()
return new_key
+4 -4
View File
@@ -40,7 +40,7 @@ from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.access_log import AccessLog from src.backend.models.orm.access_log import AccessLog
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
from src.backend.models.orm.station import Station, StationRecipeAssignment from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.services.auth_service import hash_password, generate_api_key from src.backend.services.auth_service import hash_password, issue_api_key
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# In-memory SQLite engine for tests # In-memory SQLite engine for tests
@@ -142,7 +142,6 @@ async def _create_user(
active: bool = True, active: bool = True,
) -> User: ) -> User:
"""Insert a user into the test DB and return it with an API key set.""" """Insert a user into the test DB and return it with an API key set."""
api_key = generate_api_key()
user = User( user = User(
username=username, username=username,
password_hash=hash_password(password), password_hash=hash_password(password),
@@ -150,13 +149,14 @@ async def _create_user(
roles=roles or [], roles=roles or [],
is_admin=is_admin, is_admin=is_admin,
active=active, active=active,
api_key=api_key,
language_pref="en", language_pref="en",
theme_pref="light", theme_pref="light",
) )
session.add(user) session.add(user)
await session.flush() await session.flush()
await session.refresh(user) await session.refresh(user)
# Clear key of this test session; the DB only keeps its hash.
user.plain_key = await issue_api_key(session, user)
return user return user
@@ -278,4 +278,4 @@ async def create_test_recipe(
def auth_headers(user: User) -> dict[str, str]: def auth_headers(user: User) -> dict[str, str]:
"""Return headers dict with the user's API key for authenticated requests.""" """Return headers dict with the user's API key for authenticated requests."""
return {"X-API-Key": user.api_key} return {"X-API-Key": user.plain_key}
+151 -46
View File
@@ -78,75 +78,114 @@ class TestLogin:
assert len(api_key) >= 32 assert len(api_key) >= 32
class TestLoginKeyReuse: class TestLoginKeyPerSession:
"""login_user reuses an existing api_key instead of rotating it (#25).""" """Each login issues its own key; none revokes another (#25, #41)."""
async def test_two_logins_same_user_share_api_key( async def test_two_logins_same_user_get_distinct_valid_keys(
self, client: AsyncClient, db_session self, client: AsyncClient, db_session
): ):
"""Two stations logging in as the same shift account both keep working."""
await _create_user(db_session, username="twostations", password="TwoStations1") await _create_user(db_session, username="twostations", password="TwoStations1")
resp1 = await client.post( creds = {"username": "twostations", "password": "TwoStations1"}
"/api/auth/login", resp1 = await client.post("/api/auth/login", json=creds)
json={"username": "twostations", "password": "TwoStations1"}, resp2 = await client.post("/api/auth/login", json=creds)
) k1, k2 = resp1.json()["api_key"], resp2.json()["api_key"]
resp2 = await client.post( assert k1 != k2
"/api/auth/login", for k in (k1, k2):
json={"username": "twostations", "password": "TwoStations1"}, resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
) assert resp.status_code == 200
assert resp1.status_code == 200
assert resp2.status_code == 200
assert resp1.json()["api_key"] == resp2.json()["api_key"]
async def test_login_generates_key_when_user_has_none( async def test_db_stores_hash_never_clear_key(
self, client: AsyncClient, db_session self, client: AsyncClient, db_session
): ):
"""A user created without an api_key (e.g. via create_user) still gets one.""" from sqlalchemy import select
from src.backend.services.auth_service import create_user from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import hash_api_key
await create_user( await _create_user(db_session, username="hashed", password="HashedPass1")
db_session, username="brandnew", password="BrandNew1",
display_name="Brand New",
)
resp = await client.post( resp = await client.post(
"/api/auth/login", "/api/auth/login", json={"username": "hashed", "password": "HashedPass1"}
json={"username": "brandnew", "password": "BrandNew1"},
) )
key = resp.json()["api_key"]
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
assert hash_api_key(key) in hashes
assert key not in hashes
assert all(len(h) == 64 for h in hashes)
async def test_expired_key_gives_401(self, client: AsyncClient, db_session):
from datetime import timedelta
from sqlalchemy import update
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import utcnow
user = await _create_user(db_session, username="expired", password="ExpiredPass1")
await db_session.execute(
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
)
resp = await client.get("/api/auth/me", headers=auth_headers(user))
assert resp.status_code == 401
assert "expired" in resp.json()["detail"].lower()
async def test_use_renews_expiry_only_after_interval(
self, client: AsyncClient, db_session
):
from datetime import timedelta
from sqlalchemy import select, update
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import utcnow
user = await _create_user(db_session, username="renew", password="RenewPass1")
headers = auth_headers(user)
old = utcnow() - timedelta(hours=10)
# Recent use: no write, expiry untouched.
recent = utcnow() - timedelta(minutes=1)
await db_session.execute(
update(ApiKey).values(expires_at=old + timedelta(hours=24), last_used_at=recent)
)
await client.get("/api/auth/me", headers=headers)
row = (await db_session.execute(select(ApiKey))).scalar_one()
await db_session.refresh(row)
assert row.last_used_at == recent
# Stale last use: expiry is pushed forward.
await db_session.execute(
update(ApiKey).values(last_used_at=utcnow() - timedelta(minutes=10))
)
before = utcnow()
resp = await client.get("/api/auth/me", headers=headers)
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["api_key"] await db_session.refresh(row)
assert row.expires_at >= before + timedelta(hours=23, minutes=59)
class TestLogout: class TestLogout:
"""POST /api/auth/logout is a no-op on the shared api_key by default (#25).""" """Logout deletes only this session's key (#41)."""
async def test_logout_default_keeps_api_key_valid( async def test_logout_removes_only_own_key(self, client: AsyncClient, db_session):
self, client: AsyncClient, db_session await _create_user(db_session, username="shared_logout", password="SharedPass1")
): creds = {"username": "shared_logout", "password": "SharedPass1"}
user = await _create_user( k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
db_session, username="shared_logout", password="SharedPass1" k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
) resp = await client.post("/api/auth/logout", headers={"X-API-Key": k1})
headers = auth_headers(user)
resp = await client.post("/api/auth/logout", headers=headers)
assert resp.status_code == 204 assert resp.status_code == 204
# The key is still valid: another station on the same account keeps working. assert (await client.get("/api/auth/me", headers={"X-API-Key": k1})).status_code == 401
resp = await client.get("/api/auth/me", headers=headers) assert (await client.get("/api/auth/me", headers={"X-API-Key": k2})).status_code == 200
assert resp.status_code == 200
async def test_logout_all_sessions_invalidates_api_key( async def test_logout_all_sessions_removes_every_key(
self, client: AsyncClient, db_session self, client: AsyncClient, db_session
): ):
user = await _create_user( await _create_user(db_session, username="shared_logout_all", password="SharedPass1")
db_session, username="shared_logout_all", password="SharedPass1" creds = {"username": "shared_logout_all", "password": "SharedPass1"}
) k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
headers = auth_headers(user) k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
resp = await client.post( resp = await client.post(
"/api/auth/logout", params={"all_sessions": "true"}, headers=headers "/api/auth/logout", params={"all_sessions": "true"},
headers={"X-API-Key": k1},
) )
assert resp.status_code == 204 assert resp.status_code == 204
for k in (k1, k2):
resp = await client.get("/api/auth/me", headers=headers) assert (await client.get("/api/auth/me", headers={"X-API-Key": k})).status_code == 401
assert resp.status_code == 401
class TestAuthenticateUserTiming: class TestAuthenticateUserTiming:
@@ -185,3 +224,69 @@ class TestHealthAndAuth:
resp = await client.get("/api/users") resp = await client.get("/api/users")
assert resp.status_code == 401 assert resp.status_code == 401
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"] assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
class TestRevokeAllKeys:
"""Password change, deactivation and regenerate invalidate every key (#41)."""
async def _two_keys(self, client, db_session, name):
user = await _create_user(db_session, username=name, password="KeysPass123")
creds = {"username": name, "password": "KeysPass123"}
keys = [
(await client.post("/api/auth/login", json=creds)).json()["api_key"]
for _ in range(2)
]
return user, keys
async def _all_401(self, client, keys):
for k in keys:
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
assert resp.status_code == 401
async def test_password_change_invalidates_all(self, client, db_session, admin_user):
user, keys = await self._two_keys(client, db_session, "pwkeys")
resp = await client.put(
f"/api/users/{user.id}/password",
headers=auth_headers(admin_user), json={"password": "NewSecurePass1"},
)
assert resp.status_code == 200
await self._all_401(client, keys)
async def test_deactivation_invalidates_all(self, client, db_session, admin_user):
user, keys = await self._two_keys(client, db_session, "offkeys")
resp = await client.delete(
f"/api/users/{user.id}", headers=auth_headers(admin_user)
)
assert resp.status_code == 204
await self._all_401(client, keys)
async def test_regenerate_invalidates_old_keys(self, client, db_session, admin_user):
user, keys = await self._two_keys(client, db_session, "regenkeys")
resp = await client.post(
f"/api/users/{user.id}/regenerate-key", headers=auth_headers(admin_user)
)
assert resp.status_code == 200
await self._all_401(client, keys)
new = resp.json()["api_key"]
assert (await client.get("/api/auth/me", headers={"X-API-Key": new})).status_code == 200
class TestExpiredKeyCleanup:
async def test_expired_key_removed_at_next_login(self, client, db_session):
from datetime import timedelta
from sqlalchemy import select, update
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import hash_api_key, utcnow
user = await _create_user(db_session, username="cleanup", password="CleanPass123")
old_hash = hash_api_key(user.plain_key)
await db_session.execute(
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
)
resp = await client.post(
"/api/auth/login", json={"username": "cleanup", "password": "CleanPass123"}
)
assert resp.status_code == 200
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
assert old_hash not in hashes
assert len(hashes) == 1
+24
View File
@@ -107,3 +107,27 @@ class TestBaseMigrationNoopsOnExistingSchema:
assert "image_path" in { assert "image_path" in {
c["name"] for c in insp.get_columns("recipes") c["name"] for c in insp.get_columns("recipes")
} }
class TestApiKeyHashMigration:
"""015_api_key_hash: users.api_key -> api_keys table, and back (#41)."""
def test_015_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
command.upgrade(cfg, "head")
insp = _inspect(db_path)
assert "api_keys" in insp.get_table_names()
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
assert {c["name"] for c in insp.get_columns("api_keys")} >= {
"user_id", "key_hash", "created_at", "expires_at", "last_used_at",
}
command.downgrade(cfg, "014_vision_fk_ondelete")
insp = _inspect(db_path)
assert "api_keys" not in insp.get_table_names()
assert "api_key" in {c["name"] for c in insp.get_columns("users")}
command.upgrade(cfg, "head") # and up again
assert "api_keys" in _inspect(db_path).get_table_names()
@@ -17,7 +17,7 @@ class TestChangeUserPassword:
the same as setup.py's change-user-password (setup.py:634). the same as setup.py's change-user-password (setup.py:634).
""" """
old_key = maker_user.api_key old_key = maker_user.plain_key
old_username = maker_user.username old_username = maker_user.username
assert old_key is not None assert old_key is not None
@@ -31,7 +31,7 @@ class TestChangeUserPassword:
# The old key no longer authenticates. Read it before the request # The old key no longer authenticates. Read it before the request
# above: the client and app share one session (see conftest), so # above: the client and app share one session (see conftest), so
# `maker_user.api_key` in this process reflects the mutation too. # `maker_user.plain_key` in this process reflects the mutation too.
resp2 = await client.get("/api/auth/me", headers={"X-API-Key": old_key}) resp2 = await client.get("/api/auth/me", headers={"X-API-Key": old_key})
assert resp2.status_code == 401 assert resp2.status_code == 401