e39521d51d
- TASK-03-01 server/auth/passwords.py: argon2id via argon2-cffi
PasswordHasher (t=3, m=64MiB, p=4 — exceeds OWASP). hash/verify/
needs_rehash; verify returns False on mismatch (uniform 401 path).
- TASK-03-02 server/auth/cookies.py: get_session_middleware_kwargs()
→ Starlette SessionMiddleware (itsdangerous HMAC-SHA256, D-056).
Cookie praxis_op, httpOnly, SameSite=strict, max_age=28800 (8h).
PRAXIS_COOKIE_SECURE default true; false logs WARNING (R-AUTH-01).
G-031 reframe documented: k-anon defense-in-depth is the PRIMARY
mitigation (sniffed cookie → no PII); secure flag is SECONDARY.
- TASK-03-03 server/auth/rate_limit.py: slowapi Limiter (in-memory,
D-041), 5/minute per IP on login. reset_login_rate_limit() helper.
- TASK-03-04 server/auth/dependencies.py + models.py: current_operator
Depends — reads signed-cookie session, fetches operator from PgStore,
401 on missing/invalid/inactive (clears session), 503 if no Postgres.
Never trusts the client (D-057).
- TASK-03-05 server/auth/routes.py: APIRouter(prefix=/api/operator)
with POST /login (rate-limited, rehash-on-login), POST /logout
(auth-gated, clears session), GET /me (auth-gated, React guard).
- TASK-03-06 tests/test_auth.py: 18 unit tests (mocked PgStore) —
passwords, cookie config, rate limit, 401/503 cases, login/logout/me,
rehash-on-login.
- pyproject.toml: added itsdangerous>=2.1 (SessionMiddleware dep).
---ci---
project: praxis
phase: 1
milestone: v0.4
status: execute
persona: security-engineer
task: 03-01,03-02,03-03,03-04,03-05,03-06
requirements:
covered: [REQ-AUTH-01, REQ-NFR-AUTH-01]
grill:
- G-031 (R-AUTH-01 reframe: k-anon primary, secure flag secondary)
---/ci---
309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""Auth unit tests (TASK-03-06) — mocked PgStore, no real Postgres.
|
|
|
|
Covers: password hash/verify/rehash, cookie config (secure flag, missing
|
|
secret), rate limiter threshold, current_operator dependency (401/503
|
|
cases, active/inactive), login/logout/me route handlers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import types
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from server.auth.cookies import get_session_middleware_kwargs
|
|
from server.auth.dependencies import current_operator
|
|
from server.auth.models import Operator
|
|
from server.auth.passwords import hash_password, needs_rehash, verify_password
|
|
from server.auth.rate_limit import limiter, rate_limit_login, reset_login_rate_limit
|
|
from server.auth.routes import router
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_limiter():
|
|
reset_login_rate_limit()
|
|
yield
|
|
reset_login_rate_limit()
|
|
|
|
|
|
# ── Passwords ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_password_hash_verify_roundtrip():
|
|
h = hash_password("correct horse battery staple")
|
|
assert h.startswith("$argon2id$")
|
|
assert verify_password(h, "correct horse battery staple") is True
|
|
|
|
|
|
def test_password_verify_wrong_returns_false():
|
|
h = hash_password("secret-1")
|
|
assert verify_password(h, "secret-2") is False
|
|
# no exception raised — uniform 401 path
|
|
assert verify_password(h, "") is False
|
|
|
|
|
|
def test_needs_rehash_false_for_current_defaults():
|
|
h = hash_password("x")
|
|
assert needs_rehash(h) is False
|
|
|
|
|
|
def test_needs_rehash_true_for_weak_hash():
|
|
# A hash produced with weaker params triggers rehash.
|
|
from argon2 import PasswordHasher
|
|
weak = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1).hash("x")
|
|
assert needs_rehash(weak) is True
|
|
|
|
|
|
# ── Cookie config ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_cookie_kwargs_defaults(monkeypatch):
|
|
monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 48)
|
|
monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "true")
|
|
kw = get_session_middleware_kwargs()
|
|
assert kw["session_cookie"] == "praxis_op"
|
|
assert kw["max_age"] == 28800
|
|
assert kw["httponly"] is True
|
|
assert kw["samesite"] == "strict"
|
|
assert kw["secure"] is True
|
|
assert kw["path"] == "/"
|
|
|
|
|
|
def test_cookie_secure_false(monkeypatch):
|
|
monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 48)
|
|
monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "false")
|
|
kw = get_session_middleware_kwargs()
|
|
assert kw["secure"] is False
|
|
|
|
|
|
def test_cookie_secret_unset_generates_random(monkeypatch):
|
|
monkeypatch.delenv("PRAXIS_COOKIE_SECRET", raising=False)
|
|
kw = get_session_middleware_kwargs()
|
|
assert kw["secret_key"]
|
|
assert len(kw["secret_key"]) >= 32
|
|
|
|
|
|
# ── current_operator dependency ─────────────────────────────────────────────
|
|
|
|
|
|
def _make_app_with_store(store) -> FastAPI:
|
|
app = FastAPI()
|
|
app.state.pg_store = store
|
|
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
|
app.include_router(router)
|
|
return app
|
|
|
|
|
|
def _mock_store(operator_row=None):
|
|
store = MagicMock()
|
|
store.get_operator_by_id = AsyncMock(return_value=operator_row)
|
|
return store
|
|
|
|
|
|
def test_current_operator_no_cookie_401():
|
|
app = _make_app_with_store(_mock_store(operator_row=None))
|
|
with TestClient(app) as client:
|
|
r = client.get("/api/operator/me")
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_current_operator_no_postgres_503():
|
|
app = FastAPI()
|
|
app.state.pg_store = None
|
|
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
|
app.include_router(router)
|
|
with TestClient(app) as client:
|
|
r = client.get("/api/operator/me")
|
|
assert r.status_code == 503
|
|
|
|
|
|
def test_current_operator_inactive_401():
|
|
op = {
|
|
"id": "11111111-1111-1111-1111-111111111111",
|
|
"username": "ghost",
|
|
"display_name": "Ghost",
|
|
"role": "operator",
|
|
"is_active": False,
|
|
}
|
|
store = _mock_store(operator_row=op)
|
|
app = _make_app_with_store(store)
|
|
with TestClient(app) as client:
|
|
# seed a session by hitting login would need a real store; instead
|
|
# set the session directly via a cookie. Use TestClient's cookie jar.
|
|
# Easiest: POST /login with a mocked store that returns the op.
|
|
store.get_operator_by_username = AsyncMock(return_value=op)
|
|
store.update_last_login = AsyncMock()
|
|
store.pool = MagicMock()
|
|
conn = MagicMock()
|
|
conn.execute = AsyncMock()
|
|
cm = MagicMock()
|
|
cm.__aenter__ = AsyncMock(return_value=conn)
|
|
cm.__aexit__ = AsyncMock(return_value=None)
|
|
store.pool.acquire = MagicMock(return_value=cm)
|
|
# hash the password so verify works
|
|
op = dict(op)
|
|
op["password_hash"] = hash_password("pw")
|
|
store.get_operator_by_username = AsyncMock(return_value=op)
|
|
r = client.post("/api/operator/login", json={"username": "ghost", "password": "pw"})
|
|
# inactive operator → 401 even with correct password
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_current_operator_valid_cookie_returns_operator():
|
|
op = {
|
|
"id": "22222222-2222-2222-2222-222222222222",
|
|
"username": "alice",
|
|
"display_name": "Alice",
|
|
"role": "operator",
|
|
"is_active": True,
|
|
"password_hash": hash_password("pw"),
|
|
}
|
|
store = _mock_store(operator_row=op)
|
|
store.get_operator_by_username = AsyncMock(return_value=op)
|
|
store.update_last_login = AsyncMock()
|
|
store.pool = MagicMock()
|
|
conn = MagicMock()
|
|
conn.execute = AsyncMock()
|
|
cm = MagicMock()
|
|
cm.__aenter__ = AsyncMock(return_value=conn)
|
|
cm.__aexit__ = AsyncMock(return_value=None)
|
|
store.pool.acquire = MagicMock(return_value=cm)
|
|
app = _make_app_with_store(store)
|
|
with TestClient(app) as client:
|
|
r = client.post("/api/operator/login", json={"username": "alice", "password": "pw"})
|
|
assert r.status_code == 200
|
|
assert r.json()["operator"]["username"] == "alice"
|
|
# cookie is now set; /me should work
|
|
r2 = client.get("/api/operator/me")
|
|
assert r2.status_code == 200
|
|
assert r2.json()["operator"]["username"] == "alice"
|
|
|
|
|
|
# ── Login route ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_login_wrong_password_401_no_cookie():
|
|
op = {
|
|
"id": "33333333-3333-3333-3333-333333333333",
|
|
"username": "bob",
|
|
"display_name": None,
|
|
"role": "operator",
|
|
"is_active": True,
|
|
"password_hash": hash_password("correct"),
|
|
}
|
|
store = _mock_store(operator_row=op)
|
|
store.get_operator_by_username = AsyncMock(return_value=op)
|
|
store.update_last_login = AsyncMock()
|
|
app = _make_app_with_store(store)
|
|
with TestClient(app) as client:
|
|
r = client.post("/api/operator/login", json={"username": "bob", "password": "wrong"})
|
|
assert r.status_code == 401
|
|
# no auth cookie set on failure
|
|
cookies = client.cookies.get("praxis_op")
|
|
assert not cookies
|
|
|
|
|
|
def test_login_unknown_user_401():
|
|
store = _mock_store(operator_row=None)
|
|
store.get_operator_by_username = AsyncMock(return_value=None)
|
|
app = _make_app_with_store(store)
|
|
with TestClient(app) as client:
|
|
r = client.post("/api/operator/login", json={"username": "nobody", "password": "x"})
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_login_no_postgres_503():
|
|
app = FastAPI()
|
|
app.state.pg_store = None
|
|
app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
|
|
app.include_router(router)
|
|
with TestClient(app) as client:
|
|
r = client.post("/api/operator/login", json={"username": "a", "password": "b"})
|
|
assert r.status_code == 503
|
|
|
|
|
|
# ── Logout ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_logout_clears_session():
|
|
op = {
|
|
"id": "44444444-4444-4444-4444-444444444444",
|
|
"username": "carol",
|
|
"display_name": "Carol",
|
|
"role": "operator",
|
|
"is_active": True,
|
|
"password_hash": hash_password("pw"),
|
|
}
|
|
store = _mock_store(operator_row=op)
|
|
store.get_operator_by_username = AsyncMock(return_value=op)
|
|
store.update_last_login = AsyncMock()
|
|
store.pool = MagicMock()
|
|
conn = MagicMock()
|
|
conn.execute = AsyncMock()
|
|
cm = MagicMock()
|
|
cm.__aenter__ = AsyncMock(return_value=conn)
|
|
cm.__aexit__ = AsyncMock(return_value=None)
|
|
store.pool.acquire = MagicMock(return_value=cm)
|
|
app = _make_app_with_store(store)
|
|
with TestClient(app) as client:
|
|
client.post("/api/operator/login", json={"username": "carol", "password": "pw"})
|
|
r = client.post("/api/operator/logout")
|
|
assert r.status_code == 200
|
|
assert r.json()["ok"] is True
|
|
# /me now 401
|
|
r2 = client.get("/api/operator/me")
|
|
assert r2.status_code == 401
|
|
|
|
|
|
# ── Rehash on login ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_login_rehash_when_needed():
|
|
from argon2 import PasswordHasher
|
|
weak_hasher = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1)
|
|
op = {
|
|
"id": "55555555-5555-5555-5555-555555555555",
|
|
"username": "dave",
|
|
"display_name": "Dave",
|
|
"role": "operator",
|
|
"is_active": True,
|
|
"password_hash": weak_hasher.hash("pw"),
|
|
}
|
|
store = _mock_store(operator_row=op)
|
|
store.get_operator_by_username = AsyncMock(return_value=op)
|
|
store.update_last_login = AsyncMock()
|
|
store.pool = MagicMock()
|
|
executed = []
|
|
conn = MagicMock()
|
|
async def _exec(*a, **kw):
|
|
executed.append(a)
|
|
conn.execute = _exec
|
|
cm = MagicMock()
|
|
cm.__aenter__ = AsyncMock(return_value=conn)
|
|
cm.__aexit__ = AsyncMock(return_value=None)
|
|
store.pool.acquire = MagicMock(return_value=cm)
|
|
app = _make_app_with_store(store)
|
|
with TestClient(app) as client:
|
|
r = client.post("/api/operator/login", json={"username": "dave", "password": "pw"})
|
|
assert r.status_code == 200
|
|
assert executed, "rehash UPDATE should have run"
|
|
# the second arg to execute is the new hash; verify it's argon2id
|
|
assert executed[0][1].startswith("$argon2id$")
|
|
|
|
|
|
# ── Rate limiter ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_rate_limit_login_decorator():
|
|
# The decorator factory returns a decorator; applying it should not raise.
|
|
deco = rate_limit_login()
|
|
assert callable(deco)
|
|
|
|
|
|
def test_limiter_is_in_memory():
|
|
assert getattr(limiter, "_storage_uri", "memory://") == "memory://" or limiter._storage is not None |