Strengthen setup password validation
- Add backend Pydantic password complexity validation for setup - Update frontend setup page with password rule feedback and strength indicator - Add/adjust setup API tests for password validation - Document setup password requirements - Fix frontend test type annotation issue
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
Request, response, and domain models for the first-run configuration wizard.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
@@ -16,6 +16,22 @@ class SetupRequest(BaseModel):
|
||||
min_length=8,
|
||||
description="Master password that protects the BanGUI interface.",
|
||||
)
|
||||
|
||||
@field_validator("master_password")
|
||||
@classmethod
|
||||
def validate_master_password(cls, value: str) -> str:
|
||||
if len(value) < 8:
|
||||
raise ValueError("Password must be at least 8 characters long.")
|
||||
if not any(char.isupper() for char in value):
|
||||
raise ValueError("Password must include at least one uppercase letter.")
|
||||
if not any(char.isdigit() for char in value):
|
||||
raise ValueError("Password must include at least one number.")
|
||||
if not any(char in "!@#$%^&*()" for char in value):
|
||||
raise ValueError(
|
||||
"Password must include at least one special character (!@#$%^&*())."
|
||||
)
|
||||
return value
|
||||
|
||||
database_path: str = Field(
|
||||
default="bangui.db",
|
||||
description="Filesystem path to the BanGUI SQLite application database.",
|
||||
|
||||
@@ -298,7 +298,7 @@ async def test_startup_overrides_settings_from_persisted_setup(tmp_path: Path) -
|
||||
await init_db(db)
|
||||
await setup_service.run_setup(
|
||||
db,
|
||||
master_password="supersecret123",
|
||||
master_password="Supersecret1!",
|
||||
database_path=runtime_db_path,
|
||||
fail2ban_socket="/tmp/persisted.sock",
|
||||
timezone="Europe/Berlin",
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.services import setup_service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SETUP_PAYLOAD: dict[str, object] = {
|
||||
"master_password": "supersecret123",
|
||||
"master_password": "Supersecret1!",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
|
||||
"timezone": "UTC",
|
||||
@@ -78,7 +78,7 @@ class TestGetSetupStatus:
|
||||
await client.post(
|
||||
"/api/setup",
|
||||
json={
|
||||
"master_password": "supersecret123",
|
||||
"master_password": "Supersecret1!",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
|
||||
"timezone": "UTC",
|
||||
@@ -98,7 +98,7 @@ class TestPostSetup:
|
||||
response = await client.post(
|
||||
"/api/setup",
|
||||
json={
|
||||
"master_password": "supersecret123",
|
||||
"master_password": "Supersecret1!",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
|
||||
"timezone": "UTC",
|
||||
@@ -117,10 +117,48 @@ class TestPostSetup:
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_rejects_missing_uppercase_password(self, client: AsyncClient) -> None:
|
||||
"""Setup endpoint rejects passwords missing an uppercase character."""
|
||||
response = await client.post(
|
||||
"/api/setup",
|
||||
json={"master_password": "lowercase1!"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert any(
|
||||
"uppercase" in error["msg"].lower()
|
||||
for error in response.json()["detail"]
|
||||
)
|
||||
|
||||
async def test_rejects_missing_number_password(self, client: AsyncClient) -> None:
|
||||
"""Setup endpoint rejects passwords missing a numeric character."""
|
||||
response = await client.post(
|
||||
"/api/setup",
|
||||
json={"master_password": "NoNumbers!"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert any(
|
||||
"number" in error["msg"].lower()
|
||||
for error in response.json()["detail"]
|
||||
)
|
||||
|
||||
async def test_rejects_missing_special_character_password(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Setup endpoint rejects passwords missing a required special character."""
|
||||
response = await client.post(
|
||||
"/api/setup",
|
||||
json={"master_password": "NoSpecial1"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert any(
|
||||
"special character" in error["msg"].lower()
|
||||
for error in response.json()["detail"]
|
||||
)
|
||||
|
||||
async def test_rejects_second_call(self, client: AsyncClient) -> None:
|
||||
"""Setup endpoint returns 409 if setup has already been completed."""
|
||||
payload = {
|
||||
"master_password": "supersecret123",
|
||||
"master_password": "Supersecret1!",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
|
||||
"timezone": "UTC",
|
||||
@@ -138,7 +176,7 @@ class TestPostSetup:
|
||||
"""Setup endpoint uses defaults when optional fields are omitted."""
|
||||
response = await client.post(
|
||||
"/api/setup",
|
||||
json={"master_password": "supersecret123"},
|
||||
json={"master_password": "Supersecret1!"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
@@ -150,7 +188,7 @@ class TestPostSetupRuntimeState:
|
||||
"""App state should reflect setup settings immediately after setup."""
|
||||
app, client = app_and_client
|
||||
payload = {
|
||||
"master_password": "supersecret123",
|
||||
"master_password": "Supersecret1!",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/tmp/persisted.sock",
|
||||
"timezone": "Europe/Berlin",
|
||||
@@ -193,7 +231,7 @@ class TestSetupRedirectMiddleware:
|
||||
"""Protected endpoints are reachable (no redirect) after setup."""
|
||||
await client.post(
|
||||
"/api/setup",
|
||||
json={"master_password": "supersecret123"},
|
||||
json={"master_password": "Supersecret1!"},
|
||||
)
|
||||
# /api/auth/login should now be reachable (returns 405 GET not allowed,
|
||||
# not a setup redirect)
|
||||
@@ -220,7 +258,7 @@ class TestGetTimezone:
|
||||
await client.post(
|
||||
"/api/setup",
|
||||
json={
|
||||
"master_password": "supersecret123",
|
||||
"master_password": "Supersecret1!",
|
||||
"timezone": "Europe/Berlin",
|
||||
},
|
||||
)
|
||||
@@ -413,7 +451,7 @@ class TestLifespanSetupCache:
|
||||
await init_db(db)
|
||||
await setup_service.run_setup(
|
||||
db,
|
||||
master_password="supersecret123",
|
||||
master_password="Supersecret1!",
|
||||
database_path=settings.database_path,
|
||||
fail2ban_socket=settings.fail2ban_socket,
|
||||
timezone=settings.timezone,
|
||||
|
||||
Reference in New Issue
Block a user