feat(dashboard): add administrator authentication
Verify and publish ARM64 image / verify-arm64 (pull_request) Canceled after 0s
Verify and publish ARM64 image / publish (pull_request) Canceled after 0s

This commit is contained in:
2026-09-25 14:40:19 +00:00
parent 069d4c16fd
commit 7c2a6bdcd3
12 changed files with 1309 additions and 1 deletions
+7
View File
@@ -6,6 +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 scripts.ci_fixture_db import create_ci_fixture_db
@@ -43,6 +44,8 @@ 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")
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
page_expectations = {
@@ -58,6 +61,10 @@ 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
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
from pathlib import Path
import sys
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dashboard import authentication
ADMIN_ID = "test-admin"
ADMIN_PASSWORD = "test-password"
def test_load_admin_credentials_uses_only_required_environment_variables():
credentials = authentication.load_admin_credentials(
{
"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID,
"JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD,
"auth.admin_id": "ignored-streamlit-id",
"auth.admin_password": "ignored-streamlit-password",
}
)
assert credentials.admin_id == ADMIN_ID
assert credentials.password == ADMIN_PASSWORD
@pytest.mark.parametrize(
"environ",
[
{},
{"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID},
{"JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD},
{"JARCA_DASHBOARD_ADMIN_ID": "", "JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD},
{"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID, "JARCA_DASHBOARD_ADMIN_PASSWORD": ""},
{"JARCA_DASHBOARD_ADMIN_ID": 123, "JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD},
{"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID, "JARCA_DASHBOARD_ADMIN_PASSWORD": object()},
],
)
def test_load_admin_credentials_fails_closed_for_missing_empty_or_nonstring_values(environ):
with pytest.raises(authentication.AuthenticationConfigurationError) as exc_info:
authentication.load_admin_credentials(environ)
assert str(exc_info.value) == authentication.CONFIGURATION_ERROR_MESSAGE
assert ADMIN_ID not in str(exc_info.value)
assert ADMIN_PASSWORD not in str(exc_info.value)
def test_load_admin_credentials_fails_closed_when_environment_cannot_be_read():
class UnreadableEnvironment:
def get(self, key):
raise OSError("secret backend unavailable")
with pytest.raises(authentication.AuthenticationConfigurationError) as exc_info:
authentication.load_admin_credentials(UnreadableEnvironment())
assert str(exc_info.value) == authentication.CONFIGURATION_ERROR_MESSAGE
assert "secret backend" not in str(exc_info.value)
def test_verify_credentials_uses_constant_time_comparison_for_both_fields(monkeypatch):
credentials = authentication.AdminCredentials(ADMIN_ID, ADMIN_PASSWORD)
comparisons: list[tuple[str, str]] = []
def record_compare(left: str, right: str) -> bool:
comparisons.append((left, right))
return left == right
monkeypatch.setattr(authentication.hmac, "compare_digest", record_compare)
assert authentication.verify_credentials(credentials, ADMIN_ID, ADMIN_PASSWORD) is True
assert comparisons == [(ADMIN_ID, ADMIN_ID), (ADMIN_PASSWORD, ADMIN_PASSWORD)]
comparisons.clear()
assert authentication.verify_credentials(credentials, "wrong-id", "wrong-password") is False
assert comparisons == [("wrong-id", ADMIN_ID), ("wrong-password", ADMIN_PASSWORD)]
def test_failed_login_delay_waits_only_for_remaining_minimum_duration():
sleeps: list[float] = []
authentication.enforce_failed_login_delay(
started_at=10.0,
minimum_seconds=1.0,
monotonic=lambda: 10.25,
sleep=sleeps.append,
)
assert sleeps == [pytest.approx(0.75)]
def test_failed_login_delay_does_not_sleep_after_minimum_duration():
sleeps: list[float] = []
authentication.enforce_failed_login_delay(
started_at=10.0,
minimum_seconds=1.0,
monotonic=lambda: 11.25,
sleep=sleeps.append,
)
assert sleeps == []
def test_authentication_correlation_id_is_noncredential_session_state():
session_state: dict[str, object] = {}
correlation_id = authentication.get_auth_correlation_id(session_state, token_hex=lambda size: "safe-correlation")
assert correlation_id == "safe-correlation"
assert session_state == {authentication.AUTH_CORRELATION_SESSION_KEY: "safe-correlation"}
def test_authentication_event_log_contains_only_event_and_correlation(monkeypatch):
calls: list[tuple[str, tuple[object, ...]]] = []
monkeypatch.setattr(authentication.logger, "info", lambda message, *args: calls.append((message, args)))
authentication.log_auth_event("login_failed", "safe-correlation")
assert calls == [("dashboard_auth event=%s correlation_id=%s", ("login_failed", "safe-correlation"))]
assert ADMIN_ID not in repr(calls)
assert ADMIN_PASSWORD not in repr(calls)
+174
View File
@@ -0,0 +1,174 @@
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]))
from dashboard import authentication
ADMIN_ID = "test-admin"
ADMIN_PASSWORD = "test-password"
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)
return "\n".join(str(item.value) for item in elements)
def _configured_app(monkeypatch, *, page: str = "요약") -> AppTest:
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)
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
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.delenv(authentication.ADMIN_ID_ENV, raising=False)
monkeypatch.delenv(authentication.ADMIN_PASSWORD_ENV, raising=False)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
at = AppTest.from_file(str(APP_PATH), default_timeout=10).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):
from dashboard import app
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: False)
def forbidden_database_access():
raise AssertionError("get_db_path must not run before authentication")
monkeypatch.setattr(app, "get_db_path", forbidden_database_access)
app.main()
def test_query_parameter_cannot_bypass_login_or_reach_import_page(monkeypatch):
at = _configured_app(monkeypatch, page="Import")
assert not at.exception
assert "관리자 로그인" in _body_text(at)
assert "PDF Import" not in _body_text(at)
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)
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 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 "이번 달 수입/지출 현황" not in _body_text(at)
def test_successful_login_persists_for_current_session_and_logout_clears_it(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 not at.exception
assert "이번 달 수입/지출 현황" in _body_text(at)
assert authentication.INVALID_CREDENTIALS_MESSAGE not in _body_text(at)
assert ADMIN_PASSWORD not in repr(at.session_state)
at = at.run()
assert "이번 달 수입/지출 현황" in _body_text(at)
logout_button = next(button for button in at.button if button.label == "로그아웃")
logout_button.click()
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)