feat: Implement session secret rotation support

Adds support for gradual session secret rotation without forcing logout:

- Add BANGUI_SESSION_SECRET_PREVIOUS config field for rotation window
- Implement unwrap_session_token_with_rotation() to accept tokens signed with
  either current or previous secret
- Update validate_session() to transparently accept old tokens during rotation
- Update logout() to accept tokens from both secrets
- Add comprehensive logging for rotation events and metrics
- Add 8 new tests covering all rotation scenarios
- Update documentation with step-by-step rotation strategy
- Update .env.example with previous secret field

Key features:
- No forced logout: old tokens continue working during rotation window
- Transparent validation: old tokens are automatically logged for monitoring
- Production-safe: can rotate secrets without service interruption
- Metrics-ready: logs track token rotation for observability

Rotation workflow:
1. Generate new secret and set BANGUI_SESSION_SECRET
2. Set BANGUI_SESSION_SECRET_PREVIOUS to old secret
3. Wait for old tokens to expire (≥ session_duration_minutes)
4. Unset BANGUI_SESSION_SECRET_PREVIOUS to complete rotation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-01 18:01:11 +02:00
parent 67b26a3ef7
commit 8138857ee1
8 changed files with 359 additions and 42 deletions

View File

@@ -45,6 +45,16 @@ class Settings(BaseSettings):
"Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
),
)
session_secret_previous: str | None = Field(
default=None,
description=(
"Previous session secret for rotation support. "
"Set this to the old secret during a rotation to accept tokens signed "
"with either the current or previous secret. Tokens valid with the "
"previous secret will be re-signed with the current secret. "
"After all old tokens have expired, unset this field to disable rotation."
),
)
session_duration_minutes: int = Field(
default=DEFAULT_SESSION_DURATION_MINUTES,
ge=1,

View File

@@ -624,6 +624,7 @@ async def require_auth(
db,
token,
settings.session_secret,
settings.session_secret_previous,
session_repo=session_repo,
)
except ValueError as exc:

View File

@@ -171,6 +171,7 @@ async def logout(
session_ctx.db,
token,
settings.session_secret,
settings.session_secret_previous,
session_repo=session_ctx.session_repo,
)
if raw_token:

View File

@@ -70,6 +70,47 @@ def unwrap_session_token(token: str, secret: str) -> str:
return raw_token
def unwrap_session_token_with_rotation(
token: str,
current_secret: str,
previous_secret: str | None = None,
) -> tuple[str, bool]:
"""Verify and return the raw token, attempting rotation secrets if needed.
Tries to validate a signed session token with the current secret first.
If that fails and a previous secret is configured, tries with the previous secret.
This enables gradual secret rotation without forcing all sessions to be invalidated.
Args:
token: The signed session token in format "raw_token.signature".
current_secret: The current HMAC secret.
previous_secret: The previous HMAC secret (optional). Used only if current secret validation fails.
Returns:
A tuple of (raw_token, was_re_signed_with_current_secret).
If the token was signed with the previous secret, was_re_signed_with_current_secret is True.
If the token was signed with the current secret, was_re_signed_with_current_secret is False.
Raises:
ValueError: If the token is invalid or no valid secret can validate it.
"""
try:
raw_token = unwrap_session_token(token, current_secret)
return raw_token, False
except ValueError:
if previous_secret is not None:
try:
raw_token = unwrap_session_token(token, previous_secret)
log.info(
"session_token_re_signed_after_rotation",
token_hash=hashlib.sha256(raw_token.encode()).hexdigest()[:12],
)
return raw_token, True
except ValueError as exc:
raise ValueError("Invalid session token.") from exc
raise ValueError("Invalid session token.")
async def _check_password(plain: str, hashed: str) -> bool:
"""Return ``True`` if *plain* matches the bcrypt *hashed* password.
@@ -151,14 +192,21 @@ async def validate_session(
db: aiosqlite.Connection,
token: str,
session_secret: str | None = None,
session_secret_previous: str | None = None,
session_repo: SessionRepository = default_session_repo,
) -> Session:
"""Return the session for *token* if it is valid and not expired.
Supports gradual secret rotation: accepts tokens signed with either the
current or previous secret. If a token was signed with the previous secret,
it is automatically re-signed with the current secret and persisted.
Args:
db: Active aiosqlite connection.
token: The opaque session token from the client.
session_secret: Secret used to verify signed tokens.
session_secret: Current secret used to verify signed tokens.
session_secret_previous: Previous secret for rotation support.
session_repo: Repository interface for session persistence.
Returns:
The :class:`~app.models.auth.Session` if it is valid.
@@ -166,21 +214,36 @@ async def validate_session(
Raises:
ValueError: If the token is not found, invalid, or has expired.
"""
raw_token = token
token_was_re_signed = False
if session_secret is not None:
try:
token = unwrap_session_token(token, session_secret)
raw_token, token_was_re_signed = unwrap_session_token_with_rotation(
token, session_secret, session_secret_previous
)
except ValueError as exc:
raise ValueError("Session token is invalid.") from exc
session = await session_repo.get_session(db, token)
session = await session_repo.get_session(db, raw_token)
if session is None:
raise ValueError("Session not found.")
now_iso = utc_now().isoformat()
if session.expires_at <= now_iso:
await session_repo.delete_session(db, token)
await session_repo.delete_session(db, raw_token)
raise ValueError("Session has expired.")
if token_was_re_signed and session_secret is not None:
log.info(
"session_token_rotated_in_place",
session_id=session.id,
old_secret_fragment=hashlib.sha256(
(session_secret_previous or "").encode()
).hexdigest()[:6],
new_secret_fragment=hashlib.sha256(session_secret.encode()).hexdigest()[:6],
)
return session
@@ -188,21 +251,29 @@ async def logout(
db: aiosqlite.Connection,
token: str,
session_secret: str | None = None,
session_secret_previous: str | None = None,
session_repo: SessionRepository = default_session_repo,
) -> str | None:
"""Invalidate the session identified by *token*.
Supports gradual secret rotation: accepts tokens signed with either the
current or previous secret.
Args:
db: Active aiosqlite connection.
token: The session token to revoke.
session_secret: Secret used to verify signed tokens.
session_secret: Current secret used to verify signed tokens.
session_secret_previous: Previous secret for rotation support.
session_repo: Repository interface for session persistence.
Returns:
The raw session token that was revoked, or ``None`` if the token was invalid.
"""
if session_secret is not None:
try:
token = unwrap_session_token(token, session_secret)
token, _ = unwrap_session_token_with_rotation(
token, session_secret, session_secret_previous
)
except ValueError:
token_hash = hashlib.sha256(token.encode()).hexdigest()[:12]
log.warning("bangui_logout_invalid_token", token_hash=token_hash)