Add DB viewer access logging, account deletion, and forced password change
Logs a distinct db_viewer_accessed event (not just the earlier session_created "requested" event) when an admin's browser actually completes the hand-off into Adminer. Adds a password-confirmed account-deletion box to Settings, relying on the existing ON DELETE CASCADE foreign keys to clean up everything the account owns. Adds an admin-only "require password change" flag that get_current_user enforces server-side (403 on everything except /auth/me, /auth/change-password, /auth/logout) - meant for handing a demo account to someone with a known sample password. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -13,7 +13,13 @@ from datetime import UTC, datetime, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.errors import AuthenticationError, ConflictError, ThrottledError, ValidationAppError
|
||||
from app.core.errors import (
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
ThrottledError,
|
||||
ValidationAppError,
|
||||
)
|
||||
from app.core.security import (
|
||||
InvalidTokenError,
|
||||
TokenType,
|
||||
@@ -37,6 +43,7 @@ from app.repositories.user_known_ip_repository import UserKnownIpRepository
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.repositories.user_security_event_repository import UserSecurityEventRepository
|
||||
from app.schemas.auth import (
|
||||
ChangePasswordRequest,
|
||||
ConfirmPasswordResetRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
@@ -426,3 +433,75 @@ async def list_security_events(db: AsyncSession, user_id: uuid.UUID) -> list[Use
|
||||
"""The calling user's own security activity - the user-facing
|
||||
counterpart to the admin-only app-wide log feed (core/logging.py)."""
|
||||
return await UserSecurityEventRepository(db).list_for_user(user_id)
|
||||
|
||||
|
||||
async def delete_account(db: AsyncSession, user: User, password: str) -> None:
|
||||
"""Permanently deletes the account and, via ON DELETE CASCADE foreign
|
||||
keys (see migrations), everything it owns - companies and everything
|
||||
under them, refresh tokens, security events, API keys, etc. There is no
|
||||
soft-delete or recovery path. Rejected for accounts with no
|
||||
password_hash (the fixed AUTH_MODE=local user) - there's nothing to
|
||||
verify the caller's identity against."""
|
||||
if user.password_hash is None:
|
||||
raise ValidationAppError("Account deletion isn't available for this account.")
|
||||
if not verify_password(password, user.password_hash):
|
||||
raise AuthenticationError("Incorrect password")
|
||||
await UserRepository(db).delete(user)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def change_password(
|
||||
db: AsyncSession,
|
||||
settings: Settings,
|
||||
client_ip: str,
|
||||
user: User,
|
||||
payload: ChangePasswordRequest,
|
||||
) -> TokenResponse:
|
||||
"""Authenticated in-app password change - distinct from
|
||||
confirm_password_reset (which is the emailed-code flow for someone who
|
||||
can't log in at all). Also the only way to clear must_change_password,
|
||||
the admin-forced-reset flag (see require_password_change below)."""
|
||||
if user.password_hash is None:
|
||||
raise ValidationAppError("This account doesn't use password sign-in.")
|
||||
if not verify_password(payload.current_password, user.password_hash):
|
||||
raise AuthenticationError("Incorrect current password")
|
||||
|
||||
history_repo = PasswordHistoryRepository(db)
|
||||
previous_hashes = await history_repo.list_hashes_for_user(user.id)
|
||||
previous_hashes.append(user.password_hash)
|
||||
if any(verify_password(payload.new_password, h) for h in previous_hashes):
|
||||
raise ValidationAppError("You've used this password before. Choose a different one.")
|
||||
|
||||
await history_repo.add(user_id=user.id, password_hash=user.password_hash)
|
||||
user.password_hash = hash_password(payload.new_password)
|
||||
user.must_change_password = False
|
||||
|
||||
# Rotate every session, including the one making this request - the
|
||||
# fresh token pair returned below replaces it immediately, so the
|
||||
# caller keeps working without a forced re-login.
|
||||
await RefreshTokenRepository(db).revoke_all_for_user(user.id)
|
||||
await UserSecurityEventRepository(db).create(
|
||||
user_id=user.id, event_type=SecurityEventType.PASSWORD_CHANGED, ip_address=client_ip
|
||||
)
|
||||
return await _issue_token_pair(db, settings, user)
|
||||
|
||||
|
||||
async def require_password_change(db: AsyncSession, client_ip: str, email: str) -> User:
|
||||
"""Admin action: flags an account so its next request is blocked
|
||||
everywhere except /auth/me, /auth/change-password, and /auth/logout
|
||||
(enforced in app.auth.dependencies.get_current_user) until they set a
|
||||
new password. Meant for handing a demo account to someone with a known
|
||||
sample password."""
|
||||
user = await UserRepository(db).get_by_email(email)
|
||||
if user is None:
|
||||
raise NotFoundError("No account with that email")
|
||||
if user.password_hash is None:
|
||||
raise ValidationAppError("This account doesn't use password sign-in.")
|
||||
user.must_change_password = True
|
||||
await UserSecurityEventRepository(db).create(
|
||||
user_id=user.id,
|
||||
event_type=SecurityEventType.PASSWORD_CHANGE_REQUIRED,
|
||||
ip_address=client_ip,
|
||||
)
|
||||
await db.commit()
|
||||
return user
|
||||
|
||||
Reference in New Issue
Block a user