feat(dashboard): persist administrator login
This commit is contained in:
@@ -6,7 +6,7 @@ from streamlit.testing.v1 import AppTest
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from dashboard.app import get_transfer_review_items
|
||||
from dashboard.authentication import ADMIN_ID_ENV, ADMIN_PASSWORD_ENV
|
||||
from dashboard import authentication
|
||||
from scripts.ci_fixture_db import create_ci_fixture_db
|
||||
|
||||
|
||||
@@ -44,8 +44,13 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
|
||||
fixture = tmp_path / "fixture.sqlite3"
|
||||
create_ci_fixture_db(fixture)
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
|
||||
monkeypatch.setenv(ADMIN_ID_ENV, "test-admin")
|
||||
monkeypatch.setenv(ADMIN_PASSWORD_ENV, "test-password")
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password")
|
||||
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64)
|
||||
settings = authentication.load_auth_settings()
|
||||
assert settings is not None
|
||||
token = authentication.issue_persistent_token(settings, remember_me=False).value
|
||||
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
|
||||
|
||||
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
|
||||
page_expectations = {
|
||||
@@ -61,10 +66,6 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
|
||||
if page == "월정산":
|
||||
at.query_params["month"] = "2026-06"
|
||||
at.run()
|
||||
at.text_input[0].input("test-admin")
|
||||
at.text_input[1].input("test-password")
|
||||
next(button for button in at.button if button.label == "로그인").click()
|
||||
at.run()
|
||||
assert not at.exception
|
||||
body_text = "\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption))
|
||||
assert expected_text in body_text
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
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
|
||||
@@ -2,9 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
@@ -14,31 +12,40 @@ from dashboard import authentication
|
||||
|
||||
ADMIN_ID = "test-admin"
|
||||
ADMIN_PASSWORD = "test-password"
|
||||
SIGNING_SECRET = "s" * 64
|
||||
APP_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
|
||||
|
||||
|
||||
def _body_text(at: AppTest) -> str:
|
||||
elements = list(at.title) + list(at.header) + list(at.subheader) + list(at.text) + list(at.markdown) + list(at.caption) + list(at.error)
|
||||
elements = (
|
||||
list(at.title)
|
||||
+ list(at.header)
|
||||
+ list(at.subheader)
|
||||
+ list(at.text)
|
||||
+ list(at.markdown)
|
||||
+ list(at.caption)
|
||||
+ list(at.error)
|
||||
)
|
||||
return "\n".join(str(item.value) for item in elements)
|
||||
|
||||
|
||||
def _configured_app(monkeypatch, *, page: str = "요약") -> AppTest:
|
||||
def _configure(monkeypatch) -> authentication.AuthSettings:
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, ADMIN_ID)
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD)
|
||||
at = AppTest.from_file(str(APP_PATH), default_timeout=10)
|
||||
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, SIGNING_SECRET)
|
||||
settings = authentication.load_auth_settings()
|
||||
assert settings is not None
|
||||
return settings
|
||||
|
||||
|
||||
def _app_with_cookie(monkeypatch, token: str | None, *, page: str = "요약") -> AppTest:
|
||||
_configure(monkeypatch)
|
||||
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
|
||||
at = AppTest.from_file(str(APP_PATH), default_timeout=30)
|
||||
at.query_params["page"] = page
|
||||
return at.run()
|
||||
|
||||
|
||||
def _submit_login(at: AppTest, admin_id: str, password: str) -> AppTest:
|
||||
assert [field.label for field in at.text_input] == ["관리자 ID", "비밀번호"]
|
||||
at.text_input[0].input(admin_id)
|
||||
at.text_input[1].input(password)
|
||||
login_button = next(button for button in at.button if button.label == "로그인")
|
||||
login_button.click()
|
||||
return at.run()
|
||||
|
||||
|
||||
def test_missing_authentication_configuration_fails_closed(monkeypatch, tmp_path):
|
||||
from scripts.ci_fixture_db import create_ci_fixture_db
|
||||
|
||||
@@ -46,18 +53,20 @@ def test_missing_authentication_configuration_fails_closed(monkeypatch, tmp_path
|
||||
create_ci_fixture_db(fixture)
|
||||
monkeypatch.delenv(authentication.ADMIN_ID_ENV, raising=False)
|
||||
monkeypatch.delenv(authentication.ADMIN_PASSWORD_ENV, raising=False)
|
||||
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV, raising=False)
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
|
||||
|
||||
at = AppTest.from_file(str(APP_PATH), default_timeout=10).run()
|
||||
at = AppTest.from_file(str(APP_PATH), default_timeout=30).run()
|
||||
|
||||
assert not at.exception
|
||||
assert "관리자 로그인" in _body_text(at)
|
||||
assert authentication.CONFIGURATION_ERROR_MESSAGE in _body_text(at)
|
||||
assert not at.text_input
|
||||
assert "이번 달 수입/지출 현황" not in _body_text(at)
|
||||
|
||||
|
||||
def test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects(monkeypatch):
|
||||
def test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects(
|
||||
monkeypatch,
|
||||
):
|
||||
from dashboard import app
|
||||
|
||||
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: False)
|
||||
@@ -71,7 +80,7 @@ def test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects(
|
||||
|
||||
|
||||
def test_query_parameter_cannot_bypass_login_or_reach_import_page(monkeypatch):
|
||||
at = _configured_app(monkeypatch, page="Import")
|
||||
at = _app_with_cookie(monkeypatch, None, page="Import")
|
||||
|
||||
assert not at.exception
|
||||
assert "관리자 로그인" in _body_text(at)
|
||||
@@ -79,96 +88,68 @@ def test_query_parameter_cannot_bypass_login_or_reach_import_page(monkeypatch):
|
||||
assert "Import 이력" not in _body_text(at)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("admin_id", "password"),
|
||||
[
|
||||
("wrong-id", ADMIN_PASSWORD),
|
||||
(ADMIN_ID, "wrong-password"),
|
||||
],
|
||||
)
|
||||
def test_invalid_credentials_have_same_error_and_minimum_one_second_delay(monkeypatch, admin_id, password):
|
||||
at = _configured_app(monkeypatch)
|
||||
def test_login_form_posts_credentials_to_server_side_adapter(monkeypatch):
|
||||
at = _app_with_cookie(monkeypatch, None)
|
||||
body = _body_text(at)
|
||||
|
||||
started_at = time.monotonic()
|
||||
at = _submit_login(at, admin_id, password)
|
||||
elapsed = time.monotonic() - started_at
|
||||
|
||||
assert elapsed >= authentication.MINIMUM_FAILURE_DELAY_SECONDS
|
||||
assert authentication.INVALID_CREDENTIALS_MESSAGE in _body_text(at)
|
||||
assert ADMIN_ID not in _body_text(at)
|
||||
assert ADMIN_PASSWORD not in _body_text(at)
|
||||
assert admin_id not in _body_text(at)
|
||||
assert password not in _body_text(at)
|
||||
assert 'action="/api/auth/login"' in body
|
||||
assert 'method="post"' in body
|
||||
assert 'name="admin_id"' in body
|
||||
assert 'name="admin_password"' in body
|
||||
assert 'name="remember_me"' in body
|
||||
assert ADMIN_ID not in repr(at.session_state)
|
||||
assert ADMIN_PASSWORD not in repr(at.session_state)
|
||||
assert admin_id not in repr(at.session_state)
|
||||
assert password not in repr(at.session_state)
|
||||
assert SIGNING_SECRET not in repr(at.session_state)
|
||||
|
||||
|
||||
def test_invalid_cookie_blocks_database_and_page_dispatch(monkeypatch):
|
||||
at = _app_with_cookie(monkeypatch, "malformed-token")
|
||||
|
||||
assert not at.exception
|
||||
assert "관리자 로그인" in _body_text(at)
|
||||
assert "이번 달 수입/지출 현황" not in _body_text(at)
|
||||
|
||||
|
||||
def test_successful_login_persists_for_current_session_and_logout_clears_it(monkeypatch, tmp_path):
|
||||
def test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
from scripts.ci_fixture_db import create_ci_fixture_db
|
||||
|
||||
fixture = tmp_path / "fixture.sqlite3"
|
||||
create_ci_fixture_db(fixture)
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
|
||||
at = _configured_app(monkeypatch)
|
||||
settings = _configure(monkeypatch)
|
||||
token = authentication.issue_persistent_token(
|
||||
settings, remember_me=False, now=1_700_000_000
|
||||
).value
|
||||
monkeypatch.setattr(authentication.time, "time", lambda: 1_700_000_001)
|
||||
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
|
||||
|
||||
at = _submit_login(at, ADMIN_ID, ADMIN_PASSWORD)
|
||||
at = AppTest.from_file(str(APP_PATH), default_timeout=30).run()
|
||||
|
||||
assert not at.exception
|
||||
assert "이번 달 수입/지출 현황" in _body_text(at)
|
||||
assert authentication.INVALID_CREDENTIALS_MESSAGE not in _body_text(at)
|
||||
assert 'action="/api/auth/logout"' in _body_text(at)
|
||||
assert token not in repr(at.session_state)
|
||||
assert ADMIN_PASSWORD not in repr(at.session_state)
|
||||
assert SIGNING_SECRET not in repr(at.session_state)
|
||||
|
||||
at = at.run()
|
||||
|
||||
def test_password_rotation_invalidates_existing_cookie(monkeypatch, tmp_path):
|
||||
from scripts.ci_fixture_db import create_ci_fixture_db
|
||||
|
||||
fixture = tmp_path / "fixture.sqlite3"
|
||||
create_ci_fixture_db(fixture)
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
|
||||
settings = _configure(monkeypatch)
|
||||
token = authentication.issue_persistent_token(settings, remember_me=True).value
|
||||
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
|
||||
|
||||
at = AppTest.from_file(str(APP_PATH), default_timeout=30).run()
|
||||
assert "이번 달 수입/지출 현황" in _body_text(at)
|
||||
|
||||
logout_button = next(button for button in at.button if button.label == "로그아웃")
|
||||
logout_button.click()
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "rotated-password")
|
||||
at = at.run()
|
||||
|
||||
assert "관리자 로그인" in _body_text(at)
|
||||
assert "이번 달 수입/지출 현황" not in _body_text(at)
|
||||
|
||||
new_session = AppTest.from_file(str(APP_PATH), default_timeout=10).run()
|
||||
assert "관리자 로그인" in _body_text(new_session)
|
||||
assert "이번 달 수입/지출 현황" not in _body_text(new_session)
|
||||
|
||||
|
||||
def test_successful_login_does_not_invoke_failure_delay(monkeypatch, tmp_path):
|
||||
from scripts.ci_fixture_db import create_ci_fixture_db
|
||||
|
||||
fixture = tmp_path / "fixture.sqlite3"
|
||||
create_ci_fixture_db(fixture)
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
|
||||
|
||||
def forbidden_failure_delay(**kwargs):
|
||||
raise AssertionError("successful login must not invoke the failure delay")
|
||||
|
||||
monkeypatch.setattr(authentication, "enforce_failed_login_delay", forbidden_failure_delay)
|
||||
at = _configured_app(monkeypatch)
|
||||
|
||||
at = _submit_login(at, ADMIN_ID, ADMIN_PASSWORD)
|
||||
|
||||
assert not at.exception
|
||||
assert "이번 달 수입/지출 현황" in _body_text(at)
|
||||
|
||||
|
||||
def test_configuration_failure_after_login_clears_existing_authentication(monkeypatch, tmp_path):
|
||||
from scripts.ci_fixture_db import create_ci_fixture_db
|
||||
|
||||
fixture = tmp_path / "fixture.sqlite3"
|
||||
create_ci_fixture_db(fixture)
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
|
||||
at = _configured_app(monkeypatch)
|
||||
at = _submit_login(at, ADMIN_ID, ADMIN_PASSWORD)
|
||||
assert "이번 달 수입/지출 현황" in _body_text(at)
|
||||
|
||||
monkeypatch.delenv(authentication.ADMIN_PASSWORD_ENV)
|
||||
at = at.run()
|
||||
|
||||
assert authentication.CONFIGURATION_ERROR_MESSAGE in _body_text(at)
|
||||
assert "이번 달 수입/지출 현황" not in _body_text(at)
|
||||
assert authentication.AUTHENTICATED_SESSION_KEY not in repr(at.session_state)
|
||||
assert ADMIN_ID not in repr(at.session_state)
|
||||
assert ADMIN_PASSWORD not in repr(at.session_state)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
|
||||
|
||||
ADMIN_ID = "admin"
|
||||
ADMIN_PASSWORD = "correct horse battery staple"
|
||||
SIGNING_SECRET = "s" * 64
|
||||
|
||||
|
||||
def _settings() -> authentication.AuthSettings:
|
||||
return authentication.AuthSettings(
|
||||
admin_id=ADMIN_ID,
|
||||
admin_password=ADMIN_PASSWORD,
|
||||
signing_secret=SIGNING_SECRET,
|
||||
)
|
||||
|
||||
|
||||
def test_load_auth_settings_requires_signing_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, ADMIN_ID)
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD)
|
||||
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV, raising=False)
|
||||
|
||||
assert authentication.load_auth_settings() is None
|
||||
|
||||
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, SIGNING_SECRET)
|
||||
assert authentication.load_auth_settings() == _settings()
|
||||
|
||||
|
||||
def test_issue_and_verify_default_token_without_refresh() -> None:
|
||||
settings = _settings()
|
||||
issued = authentication.issue_persistent_token(
|
||||
settings,
|
||||
remember_me=False,
|
||||
now=1_700_000_000,
|
||||
token_id="fixed-token-id",
|
||||
)
|
||||
|
||||
verified = authentication.verify_persistent_token(
|
||||
settings,
|
||||
issued.value,
|
||||
now=1_700_000_001,
|
||||
)
|
||||
|
||||
assert verified is not None
|
||||
assert verified.issued_at == 1_700_000_000
|
||||
assert verified.expires_at == 1_700_086_400
|
||||
assert issued.max_age == authentication.DEFAULT_TOKEN_TTL_SECONDS
|
||||
assert verified.token_id == "fixed-token-id"
|
||||
assert ADMIN_PASSWORD not in issued.value
|
||||
assert SIGNING_SECRET not in issued.value
|
||||
|
||||
|
||||
def test_remember_me_token_uses_30_day_absolute_expiry() -> None:
|
||||
issued = authentication.issue_persistent_token(
|
||||
_settings(),
|
||||
remember_me=True,
|
||||
now=1_700_000_000,
|
||||
token_id="fixed-token-id",
|
||||
)
|
||||
|
||||
assert issued.expires_at == 1_702_592_000
|
||||
assert issued.max_age == authentication.REMEMBER_ME_TOKEN_TTL_SECONDS
|
||||
|
||||
|
||||
def test_token_expiry_is_absolute_and_not_sliding() -> None:
|
||||
settings = _settings()
|
||||
issued = authentication.issue_persistent_token(
|
||||
settings,
|
||||
remember_me=False,
|
||||
now=1_700_000_000,
|
||||
token_id="fixed-token-id",
|
||||
)
|
||||
|
||||
assert authentication.verify_persistent_token(
|
||||
settings, issued.value, now=issued.expires_at - 1
|
||||
) is not None
|
||||
assert authentication.verify_persistent_token(
|
||||
settings, issued.value, now=issued.expires_at
|
||||
) is None
|
||||
|
||||
|
||||
def test_tampering_and_rotations_invalidate_token() -> None:
|
||||
settings = _settings()
|
||||
issued = authentication.issue_persistent_token(
|
||||
settings,
|
||||
remember_me=True,
|
||||
now=1_700_000_000,
|
||||
token_id="fixed-token-id",
|
||||
)
|
||||
replacement = "A" if issued.value[-1] != "A" else "B"
|
||||
tampered = issued.value[:-1] + replacement
|
||||
|
||||
assert authentication.verify_persistent_token(
|
||||
settings, tampered, now=1_700_000_001
|
||||
) is None
|
||||
assert authentication.verify_persistent_token(
|
||||
replace(settings, signing_secret="n" * 64),
|
||||
issued.value,
|
||||
now=1_700_000_001,
|
||||
) is None
|
||||
assert authentication.verify_persistent_token(
|
||||
replace(settings, admin_password="rotated password"),
|
||||
issued.value,
|
||||
now=1_700_000_001,
|
||||
) is None
|
||||
assert authentication.verify_persistent_token(
|
||||
replace(settings, admin_id="rotated-admin"),
|
||||
issued.value,
|
||||
now=1_700_000_001,
|
||||
) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{},
|
||||
{"v": 2, "sub": ADMIN_ID, "iat": 1, "exp": 2, "jti": "x"},
|
||||
{"v": 1, "sub": "", "iat": 1, "exp": 2, "jti": "x"},
|
||||
{"v": 1, "sub": ADMIN_ID, "iat": "1", "exp": 2, "jti": "x"},
|
||||
{"v": 1, "sub": ADMIN_ID, "iat": 2, "exp": 2, "jti": "x"},
|
||||
{"v": 1, "sub": ADMIN_ID, "iat": 1, "exp": 2, "jti": ""},
|
||||
{"v": 1, "sub": ADMIN_ID, "iat": 1, "exp": 2, "jti": "x", "extra": 1},
|
||||
],
|
||||
)
|
||||
def test_claim_schema_is_strict(payload: dict[str, object]) -> None:
|
||||
token = authentication._serializer_for(_settings()).dumps(payload)
|
||||
|
||||
assert authentication.verify_persistent_token(
|
||||
_settings(), token, now=1
|
||||
) is None
|
||||
|
||||
|
||||
def test_oversized_and_malformed_tokens_are_rejected() -> None:
|
||||
settings = _settings()
|
||||
|
||||
assert authentication.verify_persistent_token(settings, "x" * 10_000, now=1) is None
|
||||
assert authentication.verify_persistent_token(settings, "not-a-token", now=1) is None
|
||||
|
||||
|
||||
def test_payload_does_not_encode_secrets() -> None:
|
||||
issued = authentication.issue_persistent_token(
|
||||
_settings(),
|
||||
remember_me=False,
|
||||
now=1_700_000_000,
|
||||
token_id="fixed-token-id",
|
||||
)
|
||||
payload = authentication._serializer_for(_settings()).loads(issued.value)
|
||||
|
||||
assert set(payload) == {"v", "sub", "iat", "exp", "jti"}
|
||||
assert "password" not in json.dumps(payload).lower()
|
||||
assert "secret" not in json.dumps(payload).lower()
|
||||
Reference in New Issue
Block a user