192 lines
5.9 KiB
Python
192 lines
5.9 KiB
Python
from __future__ import annotations
|
|
|
|
import sys
|
|
from http.cookies import SimpleCookie
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
ROOT_DIR = Path(__file__).resolve().parents[1]
|
|
if str(ROOT_DIR) not in sys.path:
|
|
sys.path.insert(0, str(ROOT_DIR))
|
|
|
|
from dashboard import authentication
|
|
from dashboard.auth_server import create_auth_routes
|
|
|
|
|
|
ADMIN_ID = "admin"
|
|
ADMIN_PASSWORD = "correct horse battery staple"
|
|
SIGNING_SECRET = "s" * 64
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _configured_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv(authentication.ADMIN_ID_ENV, ADMIN_ID)
|
|
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD)
|
|
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, SIGNING_SECRET)
|
|
monkeypatch.setattr(authentication.time, "sleep", lambda _seconds: None)
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> TestClient:
|
|
from starlette.applications import Starlette
|
|
|
|
return TestClient(
|
|
Starlette(routes=create_auth_routes()), base_url="https://testserver"
|
|
)
|
|
|
|
|
|
def _cookie_from(response) -> SimpleCookie[str]:
|
|
cookie: SimpleCookie[str] = SimpleCookie()
|
|
cookie.load(response.headers["set-cookie"])
|
|
return cookie
|
|
|
|
|
|
def test_successful_login_sets_hardened_24_hour_cookie(client: TestClient) -> None:
|
|
response = client.post(
|
|
"/api/auth/login",
|
|
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
|
|
headers={"origin": "https://testserver"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == "/"
|
|
header = response.headers["set-cookie"]
|
|
assert f"{authentication.AUTH_COOKIE_NAME}=" in header
|
|
assert "HttpOnly" in header
|
|
assert "Secure" in header
|
|
assert "SameSite=lax" in header
|
|
assert "Path=/" in header
|
|
assert f"Max-Age={authentication.DEFAULT_TOKEN_TTL_SECONDS}" in header
|
|
assert ADMIN_PASSWORD not in header
|
|
assert SIGNING_SECRET not in header
|
|
|
|
|
|
def test_remember_me_sets_30_day_cookie(client: TestClient) -> None:
|
|
response = client.post(
|
|
"/api/auth/login",
|
|
data={
|
|
"admin_id": ADMIN_ID,
|
|
"admin_password": ADMIN_PASSWORD,
|
|
"remember_me": "on",
|
|
},
|
|
headers={"origin": "https://testserver"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert (
|
|
f"Max-Age={authentication.REMEMBER_ME_TOKEN_TTL_SECONDS}"
|
|
in response.headers["set-cookie"]
|
|
)
|
|
|
|
|
|
def test_invalid_credentials_do_not_set_cookie_and_use_general_error(
|
|
client: TestClient,
|
|
) -> None:
|
|
response = client.post(
|
|
"/api/auth/login",
|
|
data={"admin_id": ADMIN_ID, "admin_password": "wrong"},
|
|
headers={"origin": "https://testserver"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == "/?auth_error=invalid"
|
|
assert "set-cookie" not in response.headers
|
|
assert "password" not in response.headers["location"]
|
|
assert ADMIN_ID not in response.headers["location"]
|
|
|
|
|
|
def test_missing_configuration_fails_closed(client: TestClient, monkeypatch) -> None:
|
|
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV)
|
|
|
|
response = client.post(
|
|
"/api/auth/login",
|
|
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
|
|
headers={"origin": "https://testserver"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == "/?auth_error=unavailable"
|
|
assert "set-cookie" not in response.headers
|
|
|
|
|
|
def test_cross_site_login_is_rejected(client: TestClient) -> None:
|
|
response = client.post(
|
|
"/api/auth/login",
|
|
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
|
|
headers={"origin": "https://attacker.example"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert "set-cookie" not in response.headers
|
|
|
|
|
|
def test_different_port_is_not_same_origin(client: TestClient) -> None:
|
|
response = client.post(
|
|
"/api/auth/login",
|
|
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
|
|
headers={"origin": "https://testserver:444"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert "set-cookie" not in response.headers
|
|
|
|
|
|
def test_plain_http_is_rejected_even_with_spoofed_https_origin() -> None:
|
|
from starlette.applications import Starlette
|
|
|
|
with TestClient(
|
|
Starlette(routes=create_auth_routes()), base_url="http://testserver"
|
|
) as http_client:
|
|
response = http_client.post(
|
|
"/api/auth/login",
|
|
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
|
|
headers={
|
|
"origin": "https://testserver",
|
|
"x-forwarded-proto": "https",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert "set-cookie" not in response.headers
|
|
|
|
|
|
def test_logout_clears_cookie_with_matching_scope(client: TestClient) -> None:
|
|
response = client.post(
|
|
"/api/auth/logout",
|
|
headers={"origin": "https://testserver"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
header = response.headers["set-cookie"]
|
|
assert f"{authentication.AUTH_COOKIE_NAME}=" in header
|
|
assert "Max-Age=0" in header
|
|
assert "HttpOnly" in header
|
|
assert "Secure" in header
|
|
assert "SameSite=lax" in header
|
|
assert "Path=/" in header
|
|
|
|
|
|
def test_cookie_token_round_trip(client: TestClient) -> None:
|
|
response = client.post(
|
|
"/api/auth/login",
|
|
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
|
|
headers={"origin": "https://testserver"},
|
|
follow_redirects=False,
|
|
)
|
|
cookie = _cookie_from(response)
|
|
token = cookie[authentication.AUTH_COOKIE_NAME].value
|
|
|
|
assert authentication.verify_persistent_token(
|
|
authentication.load_auth_settings(), token
|
|
) is not None
|