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:
@@ -15,13 +15,14 @@ from app.core.security import get_client_ip
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.repositories.unban_request_repository import UnbanRequestRepository
|
||||
from app.schemas.auth import RequirePasswordChangeRequest
|
||||
from app.schemas.unban import (
|
||||
BanIpRequest,
|
||||
IpBanResponse,
|
||||
UnbanRequestPayload,
|
||||
UnbanRequestResponse,
|
||||
)
|
||||
from app.services import unban_service
|
||||
from app.services import auth_service, unban_service
|
||||
|
||||
router = APIRouter(tags=["admin"])
|
||||
|
||||
@@ -89,3 +90,20 @@ async def reject_unban_request(
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> None:
|
||||
await unban_service.reject_unban_request(db, request_id)
|
||||
|
||||
|
||||
@router.post("/admin/users/require-password-change", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def require_password_change(
|
||||
request: Request,
|
||||
payload: RequirePasswordChangeRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""Flags an account (by email) so its next request is blocked
|
||||
everywhere except /auth/me, /auth/change-password, and /auth/logout
|
||||
until they set a new password - see app.auth.dependencies.get_current_user
|
||||
and app.services.auth_service.require_password_change. Meant for handing
|
||||
a demo account to someone with a known sample password."""
|
||||
client_ip = get_client_ip(request, settings)
|
||||
await auth_service.require_password_change(db, client_ip, payload.email)
|
||||
|
||||
@@ -14,7 +14,9 @@ from app.core.security import get_client_ip, is_localhost
|
||||
from app.db.session import get_db
|
||||
from app.models.user import LOCAL_DEV_USER_ID, User
|
||||
from app.schemas.auth import (
|
||||
ChangePasswordRequest,
|
||||
ConfirmPasswordResetRequest,
|
||||
DeleteAccountRequest,
|
||||
LoginRequest,
|
||||
LogoutRequest,
|
||||
RefreshRequest,
|
||||
@@ -166,3 +168,24 @@ async def me(user: User = Depends(get_current_user)) -> MeResponse:
|
||||
effective_auth_mode = "local" if user.id == LOCAL_DEV_USER_ID else "jwt"
|
||||
base = UserResponse.model_validate(user).model_dump()
|
||||
return MeResponse(**base, auth_mode=effective_auth_mode)
|
||||
|
||||
|
||||
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_account(
|
||||
payload: DeleteAccountRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
await auth_service.delete_account(db, user, payload.password)
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=TokenResponse)
|
||||
async def change_password(
|
||||
request: Request,
|
||||
payload: ChangePasswordRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> TokenResponse:
|
||||
client_ip = get_client_ip(request, settings)
|
||||
return await auth_service.change_password(db, settings, client_ip, user, payload)
|
||||
|
||||
@@ -68,8 +68,10 @@ async def create_db_viewer_session(
|
||||
|
||||
@router.get("/bootstrap")
|
||||
async def bootstrap_db_viewer_session(
|
||||
request: Request,
|
||||
token: str = Query(...),
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RedirectResponse:
|
||||
"""Reached only via Nginx's db.<domain> /_auth location - never called
|
||||
directly by the frontend. No `require_admin` dependency: the bootstrap
|
||||
@@ -82,8 +84,24 @@ async def bootstrap_db_viewer_session(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or expired token"
|
||||
) from exc
|
||||
|
||||
# Logged here, not at /session mint time, because this is the point the
|
||||
# admin's browser actually completes the hand-off into Adminer - minting
|
||||
# a token only proves they clicked the button, not that they got in.
|
||||
await UserSecurityEventRepository(db).create(
|
||||
user_id=decoded.user_id,
|
||||
event_type=SecurityEventType.DB_VIEWER_ACCESSED,
|
||||
ip_address=get_client_ip(request, settings),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
session_token = create_db_viewer_session_token(decoded.user_id, settings)
|
||||
response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
|
||||
# Adminer's own driver dropdown defaults to MySQL, not Postgres - a bare
|
||||
# "/" redirect leaves it selecting MySQL against a host that only speaks
|
||||
# Postgres, producing a "Connection refused" error that has nothing to
|
||||
# do with this app's own auth. `?pgsql=postgres` pre-selects the right
|
||||
# driver and server (the internal Compose service name, same in dev and
|
||||
# prod) - only the Postgres password itself is left for the admin to type.
|
||||
response = RedirectResponse(url="/?pgsql=postgres", status_code=status.HTTP_302_FOUND)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
session_token,
|
||||
|
||||
@@ -29,6 +29,15 @@ from app.models.user import User
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.services.auth_service import get_or_create_local_user
|
||||
|
||||
# Everything an account with must_change_password=True can still reach -
|
||||
# just enough to discover the flag (/me), fix it (/change-password), and
|
||||
# bail out (/logout, which doesn't even route through get_current_user but
|
||||
# is listed for clarity). Every other endpoint 403s until they change it -
|
||||
# see app.services.auth_service.require_password_change.
|
||||
_PASSWORD_CHANGE_EXEMPT_PATHS = frozenset(
|
||||
{"/api/v1/auth/me", "/api/v1/auth/change-password", "/api/v1/auth/logout"}
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
@@ -58,6 +67,11 @@ async def get_current_user(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired access token",
|
||||
)
|
||||
if user.must_change_password and request.url.path not in _PASSWORD_CHANGE_EXEMPT_PATHS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Password change required",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -154,6 +154,9 @@ class SecurityEventType(StrEnum):
|
||||
SERVER_SECRET_UPDATED = "server_secret_updated"
|
||||
API_KEY_UPDATED = "api_key_updated"
|
||||
DB_VIEWER_SESSION_CREATED = "db_viewer_session_created"
|
||||
DB_VIEWER_ACCESSED = "db_viewer_accessed"
|
||||
PASSWORD_CHANGED = "password_changed"
|
||||
PASSWORD_CHANGE_REQUIRED = "password_change_required"
|
||||
|
||||
|
||||
class ApiKeyProvider(StrEnum):
|
||||
|
||||
@@ -42,6 +42,12 @@ class User(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
failed_login_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Admin-forced reset (e.g. before handing a demo account to someone) -
|
||||
# see app.auth.dependencies.get_current_user, which blocks every
|
||||
# endpoint except /auth/me, /auth/change-password, and /auth/logout
|
||||
# while this is true.
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
refresh_tokens: Mapped[list[RefreshToken]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@@ -23,6 +23,13 @@ class UserRepository:
|
||||
result = await self.db.execute(select(User.email).where(User.is_admin.is_(True)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def delete(self, user: User) -> None:
|
||||
"""Cascades (ON DELETE CASCADE, see migrations) to every row the
|
||||
user owns - companies and everything under them, refresh tokens,
|
||||
security events, etc. Irreversible."""
|
||||
await self.db.delete(user)
|
||||
await self.db.flush()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -73,6 +73,24 @@ class ConfirmPasswordResetRequest(BaseModel):
|
||||
return _validate_password_strength(value)
|
||||
|
||||
|
||||
class DeleteAccountRequest(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(min_length=1, max_length=128)
|
||||
new_password: str = Field(min_length=10, max_length=128)
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def _password_strength(cls, value: str) -> str:
|
||||
return _validate_password_strength(value)
|
||||
|
||||
|
||||
class RequirePasswordChangeRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class SecurityEventResponse(BaseModel):
|
||||
event_type: str
|
||||
ip_address: str
|
||||
|
||||
@@ -14,6 +14,7 @@ class UserResponse(BaseModel):
|
||||
timezone: str
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
must_change_password: bool
|
||||
|
||||
|
||||
class MeResponse(UserResponse):
|
||||
|
||||
@@ -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