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