Move auth session signing into auth_service.login
This commit is contained in:
@@ -20,7 +20,6 @@ from app.dependencies import (
|
|||||||
SettingsDep,
|
SettingsDep,
|
||||||
)
|
)
|
||||||
from app.models.auth import LoginRequest, LoginResponse, LogoutResponse
|
from app.models.auth import LoginRequest, LoginResponse, LogoutResponse
|
||||||
from app.services.auth_service import sign_session_token
|
|
||||||
from app.utils.constants import SESSION_COOKIE_NAME
|
from app.utils.constants import SESSION_COOKIE_NAME
|
||||||
|
|
||||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
@@ -59,10 +58,11 @@ async def login(
|
|||||||
HTTPException: 401 if the password is incorrect.
|
HTTPException: 401 if the password is incorrect.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
session = await auth_service.login(
|
signed_token, expires_at = await auth_service.login(
|
||||||
db,
|
db,
|
||||||
password=body.password,
|
password=body.password,
|
||||||
session_duration_minutes=settings.session_duration_minutes,
|
session_duration_minutes=settings.session_duration_minutes,
|
||||||
|
session_secret=settings.session_secret,
|
||||||
session_repo=session_repo,
|
session_repo=session_repo,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -71,10 +71,6 @@ async def login(
|
|||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
signed_token = sign_session_token(
|
|
||||||
session.token,
|
|
||||||
settings.session_secret,
|
|
||||||
)
|
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=SESSION_COOKIE_NAME,
|
key=SESSION_COOKIE_NAME,
|
||||||
value=signed_token,
|
value=signed_token,
|
||||||
@@ -83,7 +79,7 @@ async def login(
|
|||||||
secure=settings.session_cookie_secure,
|
secure=settings.session_cookie_secure,
|
||||||
max_age=settings.session_duration_minutes * 60,
|
max_age=settings.session_duration_minutes * 60,
|
||||||
)
|
)
|
||||||
return LoginResponse(token=signed_token, expires_at=session.expires_at)
|
return LoginResponse(token=signed_token, expires_at=expires_at)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|||||||
@@ -79,17 +79,19 @@ async def login(
|
|||||||
db: aiosqlite.Connection,
|
db: aiosqlite.Connection,
|
||||||
password: str,
|
password: str,
|
||||||
session_duration_minutes: int,
|
session_duration_minutes: int,
|
||||||
|
session_secret: str,
|
||||||
session_repo: SessionRepository = default_session_repo,
|
session_repo: SessionRepository = default_session_repo,
|
||||||
) -> Session:
|
) -> tuple[str, str]:
|
||||||
"""Verify *password* and create a new session on success.
|
"""Verify *password*, create a new session, and sign the token.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Active aiosqlite connection.
|
db: Active aiosqlite connection.
|
||||||
password: Plain-text password supplied by the user.
|
password: Plain-text password supplied by the user.
|
||||||
session_duration_minutes: How long the new session is valid for.
|
session_duration_minutes: How long the new session is valid for.
|
||||||
|
session_secret: Secret used to sign the session token.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A :class:`~app.models.auth.Session` domain model for the new session.
|
A tuple of the signed session token and its expiry timestamp.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If the password is incorrect or no password hash is stored.
|
ValueError: If the password is incorrect or no password hash is stored.
|
||||||
@@ -111,8 +113,9 @@ async def login(
|
|||||||
session = await session_repo.create_session(
|
session = await session_repo.create_session(
|
||||||
db, token=token, created_at=created_iso, expires_at=expires_iso
|
db, token=token, created_at=created_iso, expires_at=expires_iso
|
||||||
)
|
)
|
||||||
log.info("bangui_login_success", token_prefix=token[:8])
|
signed_token = sign_session_token(session.token, session_secret)
|
||||||
return session
|
log.info("bangui_login_success", token_prefix=session.token[:8])
|
||||||
|
return signed_token, session.expires_at
|
||||||
|
|
||||||
|
|
||||||
async def validate_session(
|
async def validate_session(
|
||||||
|
|||||||
@@ -6,41 +6,42 @@ layers depend on, without binding them to concrete module implementations.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||||
from typing import Protocol, runtime_checkable
|
|
||||||
|
|
||||||
import aiosqlite
|
if TYPE_CHECKING:
|
||||||
import aiohttp
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
from app.models.auth import Session
|
import aiohttp
|
||||||
from app.models.ban import BanOrigin, JailBannedIpsResponse, TimeRange
|
import aiosqlite
|
||||||
from app.models.blocklist import (
|
|
||||||
BlocklistSource,
|
from app.models.auth import Session
|
||||||
ImportLogListResponse,
|
from app.models.ban import BanOrigin, JailBannedIpsResponse, TimeRange
|
||||||
ImportRunResult,
|
from app.models.blocklist import (
|
||||||
ImportSourceResult,
|
BlocklistSource,
|
||||||
PreviewResponse,
|
ImportLogListResponse,
|
||||||
ScheduleConfig,
|
ImportRunResult,
|
||||||
ScheduleInfo,
|
ImportSourceResult,
|
||||||
)
|
PreviewResponse,
|
||||||
from app.models.config import (
|
ScheduleConfig,
|
||||||
AddLogPathRequest,
|
ScheduleInfo,
|
||||||
GlobalConfigResponse,
|
)
|
||||||
GlobalConfigUpdate,
|
from app.models.config import (
|
||||||
JailConfigListResponse,
|
AddLogPathRequest,
|
||||||
JailConfigResponse,
|
GlobalConfigResponse,
|
||||||
JailConfigUpdate,
|
GlobalConfigUpdate,
|
||||||
LogPreviewRequest,
|
JailConfigListResponse,
|
||||||
LogPreviewResponse,
|
JailConfigResponse,
|
||||||
MapColorThresholdsResponse,
|
JailConfigUpdate,
|
||||||
MapColorThresholdsUpdate,
|
LogPreviewRequest,
|
||||||
RegexTestResponse,
|
LogPreviewResponse,
|
||||||
Fail2BanLogResponse,
|
MapColorThresholdsResponse,
|
||||||
ServiceStatusResponse,
|
MapColorThresholdsUpdate,
|
||||||
)
|
RegexTestResponse,
|
||||||
from app.models.geo import GeoBatchLookup, GeoEnricher, GeoInfo
|
)
|
||||||
from app.models.history import HistoryListResponse, IpDetailResponse
|
from app.models.geo import GeoBatchLookup, GeoEnricher, GeoInfo
|
||||||
from app.models.server import ServerSettingsResponse, ServerSettingsUpdate, ServerStatus
|
from app.models.history import HistoryListResponse, IpDetailResponse
|
||||||
|
from app.models.jail import JailDetailResponse, JailListResponse
|
||||||
|
from app.models.server import ServerSettingsResponse, ServerSettingsUpdate, ServerStatus
|
||||||
|
|
||||||
|
|
||||||
class AuthService(Protocol):
|
class AuthService(Protocol):
|
||||||
@@ -51,8 +52,9 @@ class AuthService(Protocol):
|
|||||||
db: aiosqlite.Connection,
|
db: aiosqlite.Connection,
|
||||||
password: str,
|
password: str,
|
||||||
session_duration_minutes: int,
|
session_duration_minutes: int,
|
||||||
|
session_secret: str,
|
||||||
session_repo: object | None = None,
|
session_repo: object | None = None,
|
||||||
) -> Session:
|
) -> tuple[str, str]:
|
||||||
...
|
...
|
||||||
|
|
||||||
async def validate_session(
|
async def validate_session(
|
||||||
|
|||||||
@@ -77,37 +77,58 @@ class TestCheckPasswordAsync:
|
|||||||
|
|
||||||
|
|
||||||
class TestLogin:
|
class TestLogin:
|
||||||
async def test_login_returns_session_on_correct_password(
|
async def test_login_returns_signed_token_on_correct_password(
|
||||||
self, db: aiosqlite.Connection
|
self, db: aiosqlite.Connection
|
||||||
) -> None:
|
) -> None:
|
||||||
"""login() returns a Session on the correct password."""
|
"""login() returns a signed token and expiry on the correct password."""
|
||||||
session = await auth_service.login(db, password="correctpassword1", session_duration_minutes=60)
|
signed_token, expires_at = await auth_service.login(
|
||||||
assert session.token
|
db,
|
||||||
assert len(session.token) == 64 # 32 bytes → 64 hex chars
|
password="correctpassword1",
|
||||||
assert session.expires_at > session.created_at
|
session_duration_minutes=60,
|
||||||
|
session_secret="test-secret",
|
||||||
|
)
|
||||||
|
assert signed_token
|
||||||
|
assert "." in signed_token
|
||||||
|
assert expires_at
|
||||||
|
|
||||||
async def test_login_raises_on_wrong_password(
|
async def test_login_raises_on_wrong_password(
|
||||||
self, db: aiosqlite.Connection
|
self, db: aiosqlite.Connection
|
||||||
) -> None:
|
) -> None:
|
||||||
"""login() raises ValueError for an incorrect password."""
|
"""login() raises ValueError for an incorrect password."""
|
||||||
with pytest.raises(ValueError, match="Incorrect password"):
|
with pytest.raises(ValueError, match="Incorrect password"):
|
||||||
await auth_service.login(db, password="wrongpassword", session_duration_minutes=60)
|
await auth_service.login(
|
||||||
|
db,
|
||||||
|
password="wrongpassword",
|
||||||
|
session_duration_minutes=60,
|
||||||
|
session_secret="test-secret",
|
||||||
|
)
|
||||||
|
|
||||||
async def test_login_raises_when_no_hash_configured(
|
async def test_login_raises_when_no_hash_configured(
|
||||||
self, db_no_setup: aiosqlite.Connection
|
self, db_no_setup: aiosqlite.Connection
|
||||||
) -> None:
|
) -> None:
|
||||||
"""login() raises ValueError when setup has not been run."""
|
"""login() raises ValueError when setup has not been run."""
|
||||||
with pytest.raises(ValueError, match="No password is configured"):
|
with pytest.raises(ValueError, match="No password is configured"):
|
||||||
await auth_service.login(db_no_setup, password="any", session_duration_minutes=60)
|
await auth_service.login(
|
||||||
|
db_no_setup,
|
||||||
|
password="any",
|
||||||
|
session_duration_minutes=60,
|
||||||
|
session_secret="test-secret",
|
||||||
|
)
|
||||||
|
|
||||||
async def test_login_persists_session(self, db: aiosqlite.Connection) -> None:
|
async def test_login_persists_session(self, db: aiosqlite.Connection) -> None:
|
||||||
"""login() stores the session in the database."""
|
"""login() stores the session in the database."""
|
||||||
from app.repositories import session_repo
|
from app.repositories import session_repo
|
||||||
|
|
||||||
session = await auth_service.login(db, password="correctpassword1", session_duration_minutes=60)
|
signed_token, _ = await auth_service.login(
|
||||||
stored = await session_repo.get_session(db, session.token)
|
db,
|
||||||
|
password="correctpassword1",
|
||||||
|
session_duration_minutes=60,
|
||||||
|
session_secret="test-secret",
|
||||||
|
)
|
||||||
|
raw_token = auth_service.unwrap_session_token(signed_token, "test-secret")
|
||||||
|
stored = await session_repo.get_session(db, raw_token)
|
||||||
assert stored is not None
|
assert stored is not None
|
||||||
assert stored.token == session.token
|
assert stored.token == raw_token
|
||||||
|
|
||||||
|
|
||||||
class TestValidateSession:
|
class TestValidateSession:
|
||||||
@@ -115,27 +136,42 @@ class TestValidateSession:
|
|||||||
self, db: aiosqlite.Connection
|
self, db: aiosqlite.Connection
|
||||||
) -> None:
|
) -> None:
|
||||||
"""validate_session() returns the session for a valid token."""
|
"""validate_session() returns the session for a valid token."""
|
||||||
session = await auth_service.login(db, password="correctpassword1", session_duration_minutes=60)
|
signed_token, _ = await auth_service.login(
|
||||||
validated = await auth_service.validate_session(db, session.token)
|
db,
|
||||||
assert validated.token == session.token
|
password="correctpassword1",
|
||||||
|
session_duration_minutes=60,
|
||||||
|
session_secret="test-secret",
|
||||||
|
)
|
||||||
|
raw_token = auth_service.unwrap_session_token(signed_token, "test-secret")
|
||||||
|
validated = await auth_service.validate_session(db, raw_token)
|
||||||
|
assert validated.token == raw_token
|
||||||
|
|
||||||
async def test_validate_accepts_signed_token(
|
async def test_validate_accepts_signed_token(
|
||||||
self, db: aiosqlite.Connection
|
self, db: aiosqlite.Connection
|
||||||
) -> None:
|
) -> None:
|
||||||
"""validate_session() accepts a token signed with the configured secret."""
|
"""validate_session() accepts a token signed with the configured secret."""
|
||||||
session = await auth_service.login(db, password="correctpassword1", session_duration_minutes=60)
|
signed_token, _ = await auth_service.login(
|
||||||
signed_token = auth_service.sign_session_token(session.token, "test-secret")
|
db,
|
||||||
|
password="correctpassword1",
|
||||||
|
session_duration_minutes=60,
|
||||||
|
session_secret="test-secret",
|
||||||
|
)
|
||||||
validated = await auth_service.validate_session(
|
validated = await auth_service.validate_session(
|
||||||
db, signed_token, session_secret="test-secret"
|
db, signed_token, session_secret="test-secret"
|
||||||
)
|
)
|
||||||
assert validated.token == session.token
|
raw_token = auth_service.unwrap_session_token(signed_token, "test-secret")
|
||||||
|
assert validated.token == raw_token
|
||||||
|
|
||||||
async def test_validate_rejects_tampered_signed_token(
|
async def test_validate_rejects_tampered_signed_token(
|
||||||
self, db: aiosqlite.Connection
|
self, db: aiosqlite.Connection
|
||||||
) -> None:
|
) -> None:
|
||||||
"""validate_session() rejects signed tokens with an invalid signature."""
|
"""validate_session() rejects signed tokens with an invalid signature."""
|
||||||
session = await auth_service.login(db, password="correctpassword1", session_duration_minutes=60)
|
signed_token, _ = await auth_service.login(
|
||||||
signed_token = auth_service.sign_session_token(session.token, "test-secret")
|
db,
|
||||||
|
password="correctpassword1",
|
||||||
|
session_duration_minutes=60,
|
||||||
|
session_secret="test-secret",
|
||||||
|
)
|
||||||
tampered_token = signed_token[:-1] + ("0" if signed_token[-1] != "0" else "1")
|
tampered_token = signed_token[:-1] + ("0" if signed_token[-1] != "0" else "1")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="invalid"):
|
with pytest.raises(ValueError, match="invalid"):
|
||||||
@@ -177,18 +213,29 @@ class TestLogout:
|
|||||||
"""logout() deletes the session so it can no longer be validated."""
|
"""logout() deletes the session so it can no longer be validated."""
|
||||||
from app.repositories import session_repo
|
from app.repositories import session_repo
|
||||||
|
|
||||||
session = await auth_service.login(db, password="correctpassword1", session_duration_minutes=60)
|
signed_token, _ = await auth_service.login(
|
||||||
await auth_service.logout(db, session.token)
|
db,
|
||||||
stored = await session_repo.get_session(db, session.token)
|
password="correctpassword1",
|
||||||
|
session_duration_minutes=60,
|
||||||
|
session_secret="test-secret",
|
||||||
|
)
|
||||||
|
raw_token = auth_service.unwrap_session_token(signed_token, "test-secret")
|
||||||
|
await auth_service.logout(db, raw_token)
|
||||||
|
stored = await session_repo.get_session(db, raw_token)
|
||||||
assert stored is None
|
assert stored is None
|
||||||
|
|
||||||
async def test_logout_accepts_signed_token(self, db: aiosqlite.Connection) -> None:
|
async def test_logout_accepts_signed_token(self, db: aiosqlite.Connection) -> None:
|
||||||
"""logout() accepts a signed token and revokes the underlying raw session."""
|
"""logout() accepts a signed token and revokes the underlying raw session."""
|
||||||
from app.repositories import session_repo
|
from app.repositories import session_repo
|
||||||
|
|
||||||
session = await auth_service.login(db, password="correctpassword1", session_duration_minutes=60)
|
signed_token, _ = await auth_service.login(
|
||||||
signed_token = auth_service.sign_session_token(session.token, "test-secret")
|
db,
|
||||||
|
password="correctpassword1",
|
||||||
|
session_duration_minutes=60,
|
||||||
|
session_secret="test-secret",
|
||||||
|
)
|
||||||
|
raw_token = auth_service.unwrap_session_token(signed_token, "test-secret")
|
||||||
await auth_service.logout(db, signed_token, session_secret="test-secret")
|
await auth_service.logout(db, signed_token, session_secret="test-secret")
|
||||||
|
|
||||||
stored = await session_repo.get_session(db, session.token)
|
stored = await session_repo.get_session(db, raw_token)
|
||||||
assert stored is None
|
assert stored is None
|
||||||
|
|||||||
Reference in New Issue
Block a user