Spaces:
Running
Running
File size: 6,539 Bytes
f1fa34c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | """
Authentication tests: password policy, hashing migration, token lifecycle.
"""
from __future__ import annotations
import hashlib
import secrets
import pytest
from tests.conftest import VALID_PASSWORD, auth_header, register_and_login
# βββ Password policy (H2) ββββββββββββββββββββββββββββββββββββββββββββββββββββ
@pytest.mark.parametrize(
"password,reason",
[
("short1!A", "too short"),
("alllowercase123!", "no uppercase"),
("ALLUPPERCASE123!", "no lowercase"),
("NoDigitsHere!!!!", "no digit"),
("NoSymbolsHere123", "no special character"),
],
)
def test_register_rejects_weak_password(client, password, reason):
res = client.post(
"/api/auth/register",
json={"username": "weakuser", "display_name": "W", "password": password},
)
assert res.status_code == 422, f"accepted a password with {reason}"
def test_register_accepts_strong_password(client):
res = client.post(
"/api/auth/register",
json={"username": "stronguser", "display_name": "S", "password": VALID_PASSWORD},
)
assert res.status_code == 200
assert res.json()["success"] is True
def test_passwords_are_never_stored_in_plaintext(client, temp_auth_db):
register_and_login(client, "alice")
from app.auth import _conn
with _conn() as db:
stored = db.execute(
"SELECT password_hash FROM users WHERE username = 'alice'"
).fetchone()[0]
assert VALID_PASSWORD not in stored
assert stored.startswith("pbkdf2_sha256$"), "not using the PBKDF2 scheme"
assert int(stored.split("$")[1]) >= 600_000, "iteration count below OWASP guidance"
# βββ Legacy migration (H1) βββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_legacy_sha256_user_logs_in_and_is_rehashed(client, temp_auth_db):
"""
AUDIT H1: accounts created under salted single-round SHA-256 must keep
working and be silently upgraded β a migration that locks users out is not
a migration.
"""
from app.auth import _conn, login_user
from app.security import needs_rehash
username, password = "legacy", "oldpassword"
salt = secrets.token_hex(16)
legacy_hash = hashlib.sha256((salt + password).encode()).hexdigest()
with _conn() as db:
db.execute(
"INSERT INTO users (username, display_name, password_hash, salt) VALUES (?,?,?,?)",
(username, "Legacy", legacy_hash, salt),
)
db.commit()
def stored() -> str:
with _conn() as db:
return db.execute(
"SELECT password_hash FROM users WHERE username = ?", (username,)
).fetchone()[0]
assert needs_rehash(stored())
ok, _, display = login_user(username, password)
assert ok and display == "Legacy"
upgraded = stored()
assert upgraded.startswith("pbkdf2_sha256$")
assert not needs_rehash(upgraded)
# Still usable after the upgrade, and still rejects the wrong password.
assert login_user(username, password)[0] is True
assert login_user(username, "wrong")[0] is False
# βββ Token lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_login_returns_token_pair(client):
tokens = register_and_login(client, "alice")
assert tokens["token_type"] == "bearer"
assert tokens["access_token"].count(".") == 2, "access token is not a JWT"
assert tokens["refresh_token"]
assert tokens["expires_in"] > 0
def test_login_with_wrong_password_is_401(client):
register_and_login(client, "alice")
res = client.post("/api/auth/login", json={"username": "alice", "password": "Wr0ng!Passw0rd"})
assert res.status_code == 401
def test_access_token_grants_access(client):
tokens = register_and_login(client, "alice")
me = client.get("/api/auth/me", headers=auth_header(tokens))
assert me.status_code == 200
assert me.json()["user_id"] == "alice"
def test_expired_access_token_is_rejected(client, monkeypatch):
from api.settings import get_api_settings
tokens = register_and_login(client, "alice")
# Re-issue with a negative TTL to simulate expiry deterministically.
cfg = get_api_settings()
monkeypatch.setattr(cfg, "access_token_ttl_minutes", -1)
from api.security import issue_access_token
expired, _ = issue_access_token("alice")
res = client.get("/api/auth/me", headers={"Authorization": f"Bearer {expired}"})
assert res.status_code == 401
assert "expired" in res.json()["message"].lower()
# The original (valid) token still works β we only invalidated the new one.
assert client.get("/api/auth/me", headers=auth_header(tokens)).status_code == 200
def test_refresh_rotates_and_detects_reuse(client):
"""
Rotation + theft detection: a spent refresh token must not work twice, and
presenting one is treated as compromise, killing the whole family.
"""
tokens = register_and_login(client, "alice")
original = tokens["refresh_token"]
first = client.post("/api/auth/refresh", json={"refresh_token": original})
assert first.status_code == 200
rotated = first.json()["refresh_token"]
assert rotated != original
# Replay of the spent token β rejected.
assert client.post("/api/auth/refresh", json={"refresh_token": original}).status_code == 401
# β¦and the family is revoked, so the legitimate successor dies too.
assert client.post("/api/auth/refresh", json={"refresh_token": rotated}).status_code == 401
def test_logout_revokes_refresh_token(client):
tokens = register_and_login(client, "alice")
assert client.post(
"/api/auth/logout", json={"refresh_token": tokens["refresh_token"]}
).status_code == 204
assert client.post(
"/api/auth/refresh", json={"refresh_token": tokens["refresh_token"]}
).status_code == 401
def test_refresh_token_cannot_be_used_as_access_token(client):
"""Token type confusion: a refresh token must not authenticate a data route."""
tokens = register_and_login(client, "alice")
res = client.get(
"/api/auth/me", headers={"Authorization": f"Bearer {tokens['refresh_token']}"}
)
assert res.status_code == 401
|