feat(dashboard): persist administrator login #7

Merged
genichin merged 11 commits from gitea-agent/issue-6-dashboard-persistent-auth into main 2026-10-04 07:13:14 +00:00
2 changed files with 117 additions and 7 deletions
Showing only changes of commit e7242a89f7 - Show all commits
+11 -6
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import secrets
import time
from urllib.parse import parse_qs, urlsplit
@@ -91,13 +92,15 @@ async def login(request: Request) -> Response:
if content_type != "application/x-www-form-urlencoded":
authentication.log_auth_event("login_request_rejected", correlation_id)
return PlainTextResponse("Bad Request", status_code=400)
body = await request.body()
if len(body) > _MAX_LOGIN_BODY_BYTES:
authentication.log_auth_event("login_request_rejected", correlation_id)
return PlainTextResponse("Bad Request", status_code=400)
body = bytearray()
async for chunk in request.stream():
if len(body) + len(chunk) > _MAX_LOGIN_BODY_BYTES:
authentication.log_auth_event("login_request_rejected", correlation_id)
return PlainTextResponse("Bad Request", status_code=400)
body.extend(chunk)
try:
values = parse_qs(
body.decode("utf-8"),
bytes(body).decode("utf-8"),
keep_blank_values=True,
strict_parsing=True,
max_num_fields=3,
@@ -121,7 +124,9 @@ async def login(request: Request) -> Response:
if not authentication.verify_credentials(
settings.credentials, submitted_id, submitted_password
):
authentication.enforce_failed_login_delay(started_at=started_at)
await asyncio.to_thread(
authentication.enforce_failed_login_delay, started_at=started_at
)
authentication.log_auth_event("login_failed", correlation_id)
return _redirect("/?auth_error=invalid")
+106 -1
View File
@@ -1,18 +1,22 @@
from __future__ import annotations
import asyncio
import sys
import threading
from collections.abc import Callable
from http.cookies import SimpleCookie
from pathlib import Path
import pytest
from starlette.testclient import TestClient
from starlette.requests import Request
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
from dashboard.auth_server import create_auth_routes, login
ADMIN_ID = "admin"
@@ -218,3 +222,104 @@ def test_cookie_token_round_trip(client: TestClient) -> None:
assert authentication.verify_persistent_token(
authentication.load_auth_settings(), token
) is not None
def _streaming_login_request(
chunks: list[bytes],
) -> tuple[Request, Callable[[], int]]:
messages = [
{
"type": "http.request",
"body": chunk,
"more_body": index < len(chunks) - 1,
}
for index, chunk in enumerate(chunks)
]
calls = 0
async def receive():
nonlocal calls
calls += 1
return messages.pop(0)
scope = {
"type": "http",
"method": "POST",
"scheme": "https",
"path": "/api/auth/login",
"raw_path": b"/api/auth/login",
"query_string": b"",
"headers": [
(b"host", b"testserver"),
(b"origin", b"https://testserver"),
(b"content-type", b"application/x-www-form-urlencoded"),
],
"client": ("testclient", 50000),
"server": ("testserver", 443),
}
return Request(scope, receive), lambda: calls
def test_login_offloads_failed_login_delay_from_event_loop(monkeypatch) -> None:
entered = threading.Event()
release = threading.Event()
probe_ran = threading.Event()
probe_before_release = threading.Event()
def blocking_delay(*, started_at):
entered.set()
if probe_ran.wait(timeout=1):
probe_before_release.set()
release.set()
monkeypatch.setattr(authentication, "enforce_failed_login_delay", blocking_delay)
async def exercise() -> None:
request, _ = _streaming_login_request([b"admin_id=admin&admin_password=wrong"])
task = asyncio.create_task(login(request))
async def probe() -> None:
probe_ran.set()
asyncio.create_task(probe())
assert await asyncio.to_thread(entered.wait, 1)
release.set()
response = await task
assert response.status_code == 303
assert response.headers["location"] == "/?auth_error=invalid"
assert "set-cookie" not in response.headers
asyncio.run(exercise())
assert probe_before_release.is_set()
def test_login_rejects_exactly_one_byte_over_streaming_limit(monkeypatch) -> None:
def unexpected_verification(*_args, **_kwargs):
raise AssertionError("oversized body reached credential verification")
monkeypatch.setattr(authentication, "verify_credentials", unexpected_verification)
request, calls = _streaming_login_request([b"x" * 4097])
response = asyncio.run(login(request))
assert response.status_code == 400
assert "set-cookie" not in response.headers
assert calls() == 1
def test_login_accepts_body_at_streaming_limit() -> None:
prefix = b"admin_id=admin&admin_password="
body = prefix + (b"x" * (4096 - len(prefix)))
assert len(body) == 4096
request, calls = _streaming_login_request([body])
response = asyncio.run(login(request))
assert response.status_code == 303
assert calls() == 1
def test_login_stops_reading_stream_after_limit_is_exceeded() -> None:
request, calls = _streaming_login_request([b"x" * 4096, b"y", b"z"])
response = asyncio.run(login(request))
assert response.status_code == 400
assert calls() == 2