Private
Public Access
Replaces the stateless signed-cookie session (bare user_id) with a real server-side sessions table -- the cookie now just carries an opaque session id, resolved against the DB on every request. Each session records IP address (respects X-Forwarded-For), a parsed device label, and last-seen time (throttled updates, not written on every request). New GET/DELETE /api/auth/sessions endpoints and an "Active sessions" section in Profile settings let a user see every device they're logged in from and revoke one they don't recognize -- including their own current session, which just signs them out. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
113 lines
4.7 KiB
Python
113 lines
4.7 KiB
Python
import uuid
|
|
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.schemas.user import UserCreate
|
|
from app.services.auth_service import register_user
|
|
from tests.conftest import login_as, register_and_login
|
|
|
|
|
|
def _unique(prefix: str) -> str:
|
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
async def test_login_creates_a_session(client, db_session):
|
|
username = _unique("alice")
|
|
await register_user(
|
|
db_session, UserCreate(username=username, email=f"{username}@example.com", password="password123")
|
|
)
|
|
resp = await client.post(
|
|
"/api/auth/login",
|
|
json={"username_or_email": username, "password": "password123"},
|
|
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0 Safari/537.36"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
sessions = (await client.get("/api/auth/sessions")).json()
|
|
assert len(sessions) == 1
|
|
assert sessions[0]["is_current"] is True
|
|
assert sessions[0]["device_label"] == "Chrome on Windows"
|
|
assert sessions[0]["ip_address"]
|
|
|
|
|
|
async def test_logout_revokes_the_session(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
assert len((await client.get("/api/auth/sessions")).json()) == 1
|
|
|
|
await client.post("/api/auth/logout")
|
|
# The important part is server-side: the session row itself is gone
|
|
# from the active list (this request also 401s since this client's own
|
|
# cookie was cleared, but that alone wouldn't prove the *row* is dead).
|
|
resp = await client.get("/api/auth/sessions")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
async def test_x_forwarded_for_takes_priority_over_direct_peer(client, db_session):
|
|
username = _unique("alice")
|
|
await register_user(
|
|
db_session, UserCreate(username=username, email=f"{username}@example.com", password="password123")
|
|
)
|
|
await client.post(
|
|
"/api/auth/login",
|
|
json={"username_or_email": username, "password": "password123"},
|
|
headers={"X-Forwarded-For": "203.0.113.7, 10.0.0.1"},
|
|
)
|
|
|
|
sessions = (await client.get("/api/auth/sessions")).json()
|
|
assert sessions[0]["ip_address"] == "203.0.113.7"
|
|
|
|
|
|
async def test_revoking_another_session_logs_it_out(client, app, db_session):
|
|
username = _unique("alice")
|
|
await register_and_login(client, db_session, username=username)
|
|
|
|
# A second "device" -- a separate client hitting the same app instance
|
|
# (so it shares the DB-override wiring), its own independent cookie.
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as other_device:
|
|
await login_as(other_device, username)
|
|
assert len((await other_device.get("/api/auth/sessions")).json()) == 2
|
|
|
|
mine = (await client.get("/api/auth/sessions")).json()
|
|
assert len(mine) == 2
|
|
not_current = next(s for s in mine if not s["is_current"])
|
|
|
|
revoke = await client.delete(f"/api/auth/sessions/{not_current['id']}")
|
|
assert revoke.status_code == 204
|
|
|
|
# The other device's own cookie is now dead.
|
|
resp = await other_device.get("/api/auth/sessions")
|
|
assert resp.status_code == 401
|
|
|
|
# And the revoked session no longer shows up for the account at all.
|
|
remaining = (await client.get("/api/auth/sessions")).json()
|
|
assert len(remaining) == 1
|
|
assert remaining[0]["is_current"] is True
|
|
|
|
|
|
async def test_cannot_revoke_another_users_session(client, app, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
my_session_id = (await client.get("/api/auth/sessions")).json()[0]["id"]
|
|
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as bob_client:
|
|
await register_and_login(bob_client, db_session, username=_unique("bob"))
|
|
resp = await bob_client.delete(f"/api/auth/sessions/{my_session_id}")
|
|
assert resp.status_code == 404
|
|
|
|
# Untouched -- still exactly one active session for alice.
|
|
assert len((await client.get("/api/auth/sessions")).json()) == 1
|
|
|
|
|
|
async def test_revoking_own_current_session_works_then_logs_this_client_out(client, db_session):
|
|
await register_and_login(client, db_session, username=_unique("alice"))
|
|
session_id = (await client.get("/api/auth/sessions")).json()[0]["id"]
|
|
|
|
# Revoking your own *current* session is allowed (a remote sign-out of
|
|
# this same device is a legitimate, if odd, thing to do).
|
|
first = await client.delete(f"/api/auth/sessions/{session_id}")
|
|
assert first.status_code == 204
|
|
|
|
# A second call with the same (now-dead) cookie can't even reach the
|
|
# revoke check -- get_current_user itself already 401s.
|
|
second = await client.delete(f"/api/auth/sessions/{session_id}")
|
|
assert second.status_code == 401
|