From 4ee38b6241fb62bbca6911312025d89f6e9bd942 Mon Sep 17 00:00:00 2001 From: Saksham Das Date: Wed, 5 Aug 2026 23:41:21 -0400 Subject: [PATCH] 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 --- apps/api/app/api/v1/admin.py | 20 +- apps/api/app/api/v1/auth.py | 23 ++ apps/api/app/api/v1/db_viewer.py | 20 +- apps/api/app/auth/dependencies.py | 14 + apps/api/app/models/enums.py | 3 + apps/api/app/models/user.py | 6 + apps/api/app/repositories/user_repository.py | 7 + apps/api/app/schemas/auth.py | 18 ++ apps/api/app/schemas/user.py | 1 + apps/api/app/services/auth_service.py | 81 +++++- ...11da7_add_must_change_password_to_users.py | 32 +++ .../api/tests/unit/test_account_management.py | 260 ++++++++++++++++++ apps/api/tests/unit/test_db_viewer.py | 14 +- apps/web/app/(app)/layout.tsx | 18 ++ apps/web/app/(app)/settings/page.tsx | 66 ++++- apps/web/app/change-password/page.tsx | 100 +++++++ apps/web/hooks/use-auth.ts | 29 ++ apps/web/lib/api-client.ts | 14 + apps/web/lib/types.ts | 10 + apps/web/tests/app-layout.test.tsx | 86 ++++++ apps/web/tests/change-password-page.test.tsx | 86 ++++++ .../tests/settings-database-viewer.test.tsx | 7 +- .../tests/settings-delete-account.test.tsx | 148 ++++++++++ 23 files changed, 1056 insertions(+), 7 deletions(-) create mode 100644 apps/api/migrations/versions/9e6c80c11da7_add_must_change_password_to_users.py create mode 100644 apps/api/tests/unit/test_account_management.py create mode 100644 apps/web/app/change-password/page.tsx create mode 100644 apps/web/tests/app-layout.test.tsx create mode 100644 apps/web/tests/change-password-page.test.tsx create mode 100644 apps/web/tests/settings-delete-account.test.tsx diff --git a/apps/api/app/api/v1/admin.py b/apps/api/app/api/v1/admin.py index 88f0926..e9b232e 100644 --- a/apps/api/app/api/v1/admin.py +++ b/apps/api/app/api/v1/admin.py @@ -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) diff --git a/apps/api/app/api/v1/auth.py b/apps/api/app/api/v1/auth.py index 72f030f..0203267 100644 --- a/apps/api/app/api/v1/auth.py +++ b/apps/api/app/api/v1/auth.py @@ -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) diff --git a/apps/api/app/api/v1/db_viewer.py b/apps/api/app/api/v1/db_viewer.py index 4efb1c5..1006b54 100644 --- a/apps/api/app/api/v1/db_viewer.py +++ b/apps/api/app/api/v1/db_viewer.py @@ -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. /_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, diff --git a/apps/api/app/auth/dependencies.py b/apps/api/app/auth/dependencies.py index fdebb33..6781a16 100644 --- a/apps/api/app/auth/dependencies.py +++ b/apps/api/app/auth/dependencies.py @@ -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 diff --git a/apps/api/app/models/enums.py b/apps/api/app/models/enums.py index 0140132..5757eea 100644 --- a/apps/api/app/models/enums.py +++ b/apps/api/app/models/enums.py @@ -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): diff --git a/apps/api/app/models/user.py b/apps/api/app/models/user.py index 364d154..0a1a1b4 100644 --- a/apps/api/app/models/user.py +++ b/apps/api/app/models/user.py @@ -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" ) diff --git a/apps/api/app/repositories/user_repository.py b/apps/api/app/repositories/user_repository.py index 8f8a6ab..36a9804 100644 --- a/apps/api/app/repositories/user_repository.py +++ b/apps/api/app/repositories/user_repository.py @@ -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, *, diff --git a/apps/api/app/schemas/auth.py b/apps/api/app/schemas/auth.py index d4cbfe7..c3dc094 100644 --- a/apps/api/app/schemas/auth.py +++ b/apps/api/app/schemas/auth.py @@ -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 diff --git a/apps/api/app/schemas/user.py b/apps/api/app/schemas/user.py index 8a9f9bc..d86386a 100644 --- a/apps/api/app/schemas/user.py +++ b/apps/api/app/schemas/user.py @@ -14,6 +14,7 @@ class UserResponse(BaseModel): timezone: str is_active: bool is_admin: bool + must_change_password: bool class MeResponse(UserResponse): diff --git a/apps/api/app/services/auth_service.py b/apps/api/app/services/auth_service.py index 463346d..a9719c7 100644 --- a/apps/api/app/services/auth_service.py +++ b/apps/api/app/services/auth_service.py @@ -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 diff --git a/apps/api/migrations/versions/9e6c80c11da7_add_must_change_password_to_users.py b/apps/api/migrations/versions/9e6c80c11da7_add_must_change_password_to_users.py new file mode 100644 index 0000000..2a6221e --- /dev/null +++ b/apps/api/migrations/versions/9e6c80c11da7_add_must_change_password_to_users.py @@ -0,0 +1,32 @@ +"""add must_change_password to users + +Revision ID: 9e6c80c11da7 +Revises: c34c769afc07 +Create Date: 2026-08-06 03:10:00.000000 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "9e6c80c11da7" +down_revision: str | None = "c34c769afc07" +branch_labels: Sequence[str] | str | None = None +depends_on: Sequence[str] | str | None = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column( + "must_change_password", sa.Boolean(), nullable=False, server_default=sa.false() + ), + ) + + +def downgrade() -> None: + op.drop_column("users", "must_change_password") diff --git a/apps/api/tests/unit/test_account_management.py b/apps/api/tests/unit/test_account_management.py new file mode 100644 index 0000000..f262d9e --- /dev/null +++ b/apps/api/tests/unit/test_account_management.py @@ -0,0 +1,260 @@ +"""Account deletion (DELETE /auth/me) and admin-forced password change +(POST /admin/users/require-password-change -> POST /auth/change-password, +enforced by app.auth.dependencies.get_current_user).""" + +from __future__ import annotations + +import uuid + +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.enums import SecurityEventType +from app.repositories.user_repository import UserRepository +from app.repositories.user_security_event_repository import UserSecurityEventRepository + + +def _unique_email() -> str: + return f"user-{uuid.uuid4().hex[:12]}@example.com" + + +def _register_and_login(client: TestClient, password: str = "correct-horse-1") -> dict: + email = _unique_email() + client.post( + "/api/v1/auth/register", + json={"email": email, "password": password, "display_name": "T"}, + ) + tokens = client.post("/api/v1/auth/login", json={"email": email, "password": password}).json() + return {"email": email, "headers": {"Authorization": f"Bearer {tokens['access_token']}"}} + + +async def _register_admin_and_login(client: TestClient, db_session: AsyncSession) -> dict[str, str]: + email = f"admin-{uuid.uuid4().hex[:12]}@example.com" + client.post( + "/api/v1/auth/register", + json={"email": email, "password": "correct-horse-1", "display_name": "Admin"}, + ) + user = await UserRepository(db_session).get_by_email(email) + user.is_admin = True + await db_session.commit() + tokens = client.post( + "/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"} + ).json() + return {"Authorization": f"Bearer {tokens['access_token']}"} + + +# --- DELETE /auth/me ------------------------------------------------------- + + +def test_delete_account_requires_auth(client: TestClient): + resp = client.request( + "DELETE", "/api/v1/auth/me", json={"password": "whatever"} + ) + assert resp.status_code == 401 + + +def test_delete_account_rejects_wrong_password(client: TestClient): + account = _register_and_login(client) + resp = client.request( + "DELETE", + "/api/v1/auth/me", + json={"password": "not-the-right-password"}, + headers=account["headers"], + ) + assert resp.status_code == 401 + + +def test_delete_account_succeeds_with_correct_password_and_logs_out_the_account( + client: TestClient, +): + account = _register_and_login(client) + resp = client.request( + "DELETE", + "/api/v1/auth/me", + json={"password": "correct-horse-1"}, + headers=account["headers"], + ) + assert resp.status_code == 204 + + # The account is gone - the same token no longer resolves to anyone. + me_resp = client.get("/api/v1/auth/me", headers=account["headers"]) + assert me_resp.status_code == 401 + + # And a fresh login attempt with the same credentials fails too. + login_resp = client.post( + "/api/v1/auth/login", + json={"email": account["email"], "password": "correct-horse-1"}, + ) + assert login_resp.status_code == 401 + + +async def test_delete_account_cascades_to_owned_data(client: TestClient, db_session: AsyncSession): + """A company created by the account must be gone too (ON DELETE CASCADE), + not just the user row.""" + account = _register_and_login(client) + create_resp = client.post( + "/api/v1/companies", + json={"name": f"Co-{uuid.uuid4().hex[:8]}", "official_website": None}, + headers=account["headers"], + ) + assert create_resp.status_code == 201 + company_id = create_resp.json()["id"] + + client.request( + "DELETE", "/api/v1/auth/me", json={"password": "correct-horse-1"}, headers=account["headers"] + ) + + from sqlalchemy import text + + row = await db_session.execute( + text("SELECT 1 FROM companies WHERE id = :id"), {"id": company_id} + ) + assert row.first() is None + + +# --- POST /admin/users/require-password-change ----------------------------- + + +def test_require_password_change_requires_admin(client: TestClient): + account = _register_and_login(client) + resp = client.post( + "/api/v1/admin/users/require-password-change", + json={"email": account["email"]}, + headers=account["headers"], + ) + assert resp.status_code == 403 + + +async def test_require_password_change_rejects_unknown_email( + client: TestClient, db_session: AsyncSession +): + headers = await _register_admin_and_login(client, db_session) + resp = client.post( + "/api/v1/admin/users/require-password-change", + json={"email": "no-such-account@example.com"}, + headers=headers, + ) + assert resp.status_code == 404 + + +async def test_admin_flags_an_account_and_it_gets_logged_to_the_targets_activity( + client: TestClient, db_session: AsyncSession +): + """Checked directly against the DB, not via GET /auth/security-events - + that endpoint isn't in the must-change-password exempt list, so the + just-flagged account can't reach it until they change their password + (see test_flagged_account_is_blocked_from_other_endpoints).""" + admin_headers = await _register_admin_and_login(client, db_session) + account = _register_and_login(client) + + resp = client.post( + "/api/v1/admin/users/require-password-change", + json={"email": account["email"]}, + headers=admin_headers, + ) + assert resp.status_code == 204 + + user = await UserRepository(db_session).get_by_email(account["email"]) + assert user.must_change_password is True + events = await UserSecurityEventRepository(db_session).list_for_user(user.id) + assert any(e.event_type == SecurityEventType.PASSWORD_CHANGE_REQUIRED for e in events) + + +# --- Enforcement gate: everything except me/change-password/logout 403s ---- + + +async def test_flagged_account_is_blocked_from_other_endpoints( + client: TestClient, db_session: AsyncSession +): + admin_headers = await _register_admin_and_login(client, db_session) + account = _register_and_login(client) + client.post( + "/api/v1/admin/users/require-password-change", + json={"email": account["email"]}, + headers=admin_headers, + ) + + blocked = client.get("/api/v1/companies", headers=account["headers"]) + assert blocked.status_code == 403 + + still_ok = client.get("/api/v1/auth/me", headers=account["headers"]) + assert still_ok.status_code == 200 + assert still_ok.json()["must_change_password"] is True + + +async def test_change_password_clears_the_flag_and_unblocks_the_account( + client: TestClient, db_session: AsyncSession +): + admin_headers = await _register_admin_and_login(client, db_session) + account = _register_and_login(client) + client.post( + "/api/v1/admin/users/require-password-change", + json={"email": account["email"]}, + headers=admin_headers, + ) + + change_resp = client.post( + "/api/v1/auth/change-password", + json={"current_password": "correct-horse-1", "new_password": "brand-new-horse-2"}, + headers=account["headers"], + ) + assert change_resp.status_code == 200 + new_tokens = change_resp.json() + new_headers = {"Authorization": f"Bearer {new_tokens['access_token']}"} + + me_resp = client.get("/api/v1/auth/me", headers=new_headers) + assert me_resp.status_code == 200 + assert me_resp.json()["must_change_password"] is False + + unblocked = client.get("/api/v1/companies", headers=new_headers) + assert unblocked.status_code == 200 + + # The new password actually works on a fresh login. + login_resp = client.post( + "/api/v1/auth/login", + json={"email": account["email"], "password": "brand-new-horse-2"}, + ) + assert login_resp.status_code == 200 + + +def test_change_password_rejects_wrong_current_password(client: TestClient): + account = _register_and_login(client) + resp = client.post( + "/api/v1/auth/change-password", + json={"current_password": "totally-wrong", "new_password": "brand-new-horse-2"}, + headers=account["headers"], + ) + assert resp.status_code == 401 + + +def test_change_password_rejects_reusing_the_current_password(client: TestClient): + account = _register_and_login(client) + resp = client.post( + "/api/v1/auth/change-password", + json={"current_password": "correct-horse-1", "new_password": "correct-horse-1"}, + headers=account["headers"], + ) + assert resp.status_code == 400 + + +def test_change_password_revokes_the_old_refresh_token(client: TestClient): + email = _unique_email() + client.post( + "/api/v1/auth/register", + json={"email": email, "password": "correct-horse-1", "display_name": "T"}, + ) + tokens = client.post( + "/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"} + ).json() + headers = {"Authorization": f"Bearer {tokens['access_token']}"} + + client.post( + "/api/v1/auth/change-password", + json={"current_password": "correct-horse-1", "new_password": "brand-new-horse-2"}, + headers=headers, + ) + + stale_refresh_resp = client.post( + "/api/v1/auth/refresh", json={"refresh_token": tokens["refresh_token"]} + ) + assert stale_refresh_resp.status_code == 401 diff --git a/apps/api/tests/unit/test_db_viewer.py b/apps/api/tests/unit/test_db_viewer.py index 56d9826..bbb8723 100644 --- a/apps/api/tests/unit/test_db_viewer.py +++ b/apps/api/tests/unit/test_db_viewer.py @@ -100,12 +100,24 @@ async def test_bootstrap_with_a_valid_token_sets_a_session_cookie_and_redirects( "/api/v1/db-viewer/bootstrap", params={"token": token}, follow_redirects=False ) assert resp.status_code == 302 - assert resp.headers["location"] == "/" + assert resp.headers["location"] == "/?pgsql=postgres" assert "db_viewer_session" in resp.headers["set-cookie"] assert "HttpOnly" in resp.headers["set-cookie"] assert "Secure" in resp.headers["set-cookie"] +async def test_bootstrap_success_is_logged_to_the_admins_account_activity( + client: TestClient, db_session: AsyncSession +): + headers = await _register_admin_and_login(client, db_session) + token = client.post("/api/v1/db-viewer/session", headers=headers).json()["token"] + + client.get("/api/v1/db-viewer/bootstrap", params={"token": token}, follow_redirects=False) + + events = client.get("/api/v1/auth/security-events", headers=headers).json() + assert any(e["event_type"] == "db_viewer_accessed" for e in events) + + def test_bootstrap_rejects_a_garbage_token(client: TestClient): resp = client.get( "/api/v1/db-viewer/bootstrap", params={"token": "not-a-real-token"}, follow_redirects=False diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index ced5556..14adbc0 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -15,6 +15,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod const logout = useLogout(); const requiresLogin = systemStatus ? !isLocalConvenience(systemStatus) : false; + const mustChangePassword = user?.must_change_password ?? false; useEffect(() => { if (requiresLogin && !isLoading && isError) { @@ -22,6 +23,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod } }, [requiresLogin, isLoading, isError, router]); + useEffect(() => { + if (mustChangePassword) { + router.replace("/change-password"); + } + }, [mustChangePassword, router]); + if (requiresLogin && (isLoading || isError)) { return (
@@ -30,6 +37,17 @@ export default function DashboardLayout({ children }: { children: React.ReactNod ); } + // Every API call except /auth/me, /auth/change-password, and /auth/logout + // 403s server-side while this is set (app/auth/dependencies.py) - this is + // just the matching frontend redirect, not the actual enforcement. + if (mustChangePassword) { + return ( +
+ Redirecting… +
+ ); + } + return (
diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index a080e26..16a6ab5 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -28,6 +28,7 @@ import { useCreateDbViewerSession, useCreateIpBan, useCurrentUser, + useDeleteAccount, useDeleteIpBan, useIpBans, useRejectUnbanRequest, @@ -690,13 +691,14 @@ function DatabaseViewerBox() {

Open a web-based Postgres client (Adminer) in a new tab to view and edit rows directly. - Local developers and server admins only. + PostgreSQL is pre-selected - just enter the database password to sign in. Local developers + and server admins only.

{isLocal ? ( +
+ Delete account +
+

+ Permanently deletes your account and everything in it - companies, monitoring history, + reports, and API keys. This can't be undone. +

+
{ + e.preventDefault(); + deleteAccount.mutate({ password }); + }} + className="mt-4 flex flex-wrap items-end gap-2" + > +
+ + setPassword(e.target.value)} + className="focus-ring mt-1 block w-full rounded-md border border-red-300 px-3 py-2 text-sm text-slate-900 shadow-sm transition-colors duration-150" + /> +
+ +
+ {deleteAccount.isError && ( +

+ {authErrorMessage(deleteAccount.error)} +

+ )} +
+ ); +} + export default function SettingsPage() { const { data: user } = useCurrentUser(); const { data: systemStatus } = useSystemStatus(); @@ -820,6 +880,8 @@ export default function SettingsPage() { )} + +
); } diff --git a/apps/web/app/change-password/page.tsx b/apps/web/app/change-password/page.tsx new file mode 100644 index 0000000..9f4df54 --- /dev/null +++ b/apps/web/app/change-password/page.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { KeyRound, Loader2 } from "lucide-react"; +import { z } from "zod"; +import { PasswordField } from "@/components/ui/password-field"; +import { PasswordStrengthMeter } from "@/components/ui/password-strength-meter"; +import { authErrorMessage, useChangePassword } from "@/hooks/use-auth"; + +const changePasswordSchema = z.object({ + currentPassword: z.string().min(1, "Enter your current password"), + newPassword: z + .string() + .min(10, "Must be at least 10 characters") + .refine((v) => /[a-zA-Z]/.test(v) && /\d/.test(v), { + message: "Must contain at least one letter and one digit", + }), +}); + +type ChangePasswordForm = z.infer; + +export default function ChangePasswordPage() { + const router = useRouter(); + const changePassword = useChangePassword(); + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ resolver: zodResolver(changePasswordSchema) }); + + const onSubmit = handleSubmit(async (values) => { + try { + await changePassword.mutateAsync({ + current_password: values.currentPassword, + new_password: values.newPassword, + }); + router.replace("/dashboard"); + } catch { + // Surfaced via changePassword.isError below. + } + }); + + return ( +
+
+
+
+
+ + + +
+

Change your password

+

+ An admin requires a new password before you can continue. +

+
+
+ +
+ +
+ + +
+ + {changePassword.isError && ( +

+ {authErrorMessage(changePassword.error)} +

+ )} + + + +
+
+
+ ); +} diff --git a/apps/web/hooks/use-auth.ts b/apps/web/hooks/use-auth.ts index 9b19d8c..f135e65 100644 --- a/apps/web/hooks/use-auth.ts +++ b/apps/web/hooks/use-auth.ts @@ -5,7 +5,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiError, api, clearTokens, getRefreshToken, setTokens } from "@/lib/api-client"; import type { BanIpPayload, + ChangePasswordPayload, ConfirmPasswordResetPayload, + DeleteAccountPayload, LoginPayload, RegisterPayload, RequestPasswordResetPayload, @@ -110,6 +112,33 @@ export function useLogout() { }); } +export function useDeleteAccount() { + const router = useRouter(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: DeleteAccountPayload) => api.deleteAccount(payload), + onSuccess: () => { + // Same reasoning as useLogout: we know for certain the account is + // gone, so clear and navigate immediately rather than waiting on a + // background refetch of ["me"] to fail. + clearTokens(); + queryClient.clear(); + router.replace("/"); + }, + }); +} + +export function useChangePassword() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: ChangePasswordPayload) => api.changePassword(payload), + onSuccess: (tokens) => { + setTokens(tokens.access_token, tokens.refresh_token); + return queryClient.invalidateQueries({ queryKey: ["me"] }); + }, + }); +} + export function useVerifyEmail() { return useMutation({ mutationFn: (payload: VerifyEmailPayload) => api.verifyEmail(payload), diff --git a/apps/web/lib/api-client.ts b/apps/web/lib/api-client.ts index c5506d5..fb7285b 100644 --- a/apps/web/lib/api-client.ts +++ b/apps/web/lib/api-client.ts @@ -10,8 +10,10 @@ import type { CompanyResponse, CompanyUpdatePayload, ConfirmPasswordResetPayload, + ChangePasswordPayload, DashboardAnalytics, DbViewerSessionResponse, + DeleteAccountPayload, DiscoverCompanyRequest, DiscoveredCompanyProfile, IpBan, @@ -205,6 +207,18 @@ export const api = { me: () => request("/api/v1/auth/me"), + deleteAccount: (payload: DeleteAccountPayload) => + request("/api/v1/auth/me", { + method: "DELETE", + body: JSON.stringify(payload), + }), + + changePassword: (payload: ChangePasswordPayload) => + request("/api/v1/auth/change-password", { + method: "POST", + body: JSON.stringify(payload), + }), + verifyEmail: (payload: VerifyEmailPayload) => request("/api/v1/auth/verify-email", { method: "POST", diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index d4e409e..5d754a0 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -33,6 +33,15 @@ export interface DbViewerSessionResponse { token: string; } +export interface DeleteAccountPayload { + password: string; +} + +export interface ChangePasswordPayload { + current_password: string; + new_password: string; +} + export interface SetSystemSecretPayload { value: string; } @@ -70,6 +79,7 @@ export interface UserResponse { timezone: string; is_active: boolean; is_admin: boolean; + must_change_password: boolean; } export interface MeResponse extends UserResponse { diff --git a/apps/web/tests/app-layout.test.tsx b/apps/web/tests/app-layout.test.tsx new file mode 100644 index 0000000..696bc6e --- /dev/null +++ b/apps/web/tests/app-layout.test.tsx @@ -0,0 +1,86 @@ +import { screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import DashboardLayout from "@/app/(app)/layout"; +import { renderWithQueryClient } from "./test-utils"; + +const replaceMock = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: replaceMock }), + usePathname: () => "/dashboard", +})); + +function meBody(mustChangePassword: boolean) { + return { + id: "user-1", + email: "user@example.com", + display_name: "Regular User", + timezone: "America/New_York", + is_active: true, + is_admin: false, + auth_mode: "jwt", + must_change_password: mustChangePassword, + }; +} + +function systemStatusBody() { + return { + app_env: "development", + auth_mode: "jwt", + llm_provider: "mock", + search_provider: "mock", + sms_enabled: false, + sms_provider: "twilio", + ninjapear_configured: false, + ninjapear_credit_balance: null, + ninjapear_estimated_credits_per_company: null, + is_localhost: false, + turnstile_site_key: null, + components: [], + }; +} + +function mockFetchImplementation(mustChangePassword: boolean) { + return vi.fn().mockImplementation((url: string) => { + const path = url.replace("http://localhost:8000", ""); + if (path === "/api/v1/auth/me") { + return Promise.resolve({ ok: true, status: 200, json: async () => meBody(mustChangePassword) }); + } + if (path === "/api/v1/system/status") { + return Promise.resolve({ ok: true, status: 200, json: async () => systemStatusBody() }); + } + return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) }); + }); +} + +beforeEach(() => { + replaceMock.mockClear(); +}); + +describe("DashboardLayout - must_change_password redirect", () => { + it("redirects to /change-password and withholds the page content when the flag is set", async () => { + vi.stubGlobal("fetch", mockFetchImplementation(true)); + renderWithQueryClient( + +
Protected dashboard content
+
, + ); + + await waitFor(() => { + expect(replaceMock).toHaveBeenCalledWith("/change-password"); + }); + expect(screen.queryByText(/protected dashboard content/i)).not.toBeInTheDocument(); + }); + + it("renders normally when the flag is not set", async () => { + vi.stubGlobal("fetch", mockFetchImplementation(false)); + renderWithQueryClient( + +
Protected dashboard content
+
, + ); + + expect(await screen.findByText(/protected dashboard content/i)).toBeInTheDocument(); + expect(replaceMock).not.toHaveBeenCalledWith("/change-password"); + }); +}); diff --git a/apps/web/tests/change-password-page.test.tsx b/apps/web/tests/change-password-page.test.tsx new file mode 100644 index 0000000..cf6e43a --- /dev/null +++ b/apps/web/tests/change-password-page.test.tsx @@ -0,0 +1,86 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ChangePasswordPage from "@/app/change-password/page"; +import { renderWithQueryClient } from "./test-utils"; + +const replaceMock = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: replaceMock }), +})); + +beforeEach(() => { + replaceMock.mockClear(); +}); + +describe("ChangePasswordPage", () => { + it("rejects a weak new password before submitting", async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.type(screen.getByLabelText(/current password/i), "correct-horse-1"); + await user.type(screen.getByLabelText(/new password/i), "allletters"); + await user.click(screen.getByRole("button", { name: /update password/i })); + + await waitFor(() => { + expect( + screen.getByText(/must contain at least one letter and one digit/i), + ).toBeInTheDocument(); + }); + }); + + it("submits current + new password and redirects to the dashboard on success", async () => { + const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => { + const path = url.replace("http://localhost:8000", ""); + if (path === "/api/v1/auth/change-password" && init?.method === "POST") { + expect(JSON.parse(init.body as string)).toEqual({ + current_password: "correct-horse-1", + new_password: "brand-new-horse-2", + }); + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access", + refresh_token: "new-refresh", + token_type: "bearer", + expires_in_minutes: 15, + }), + }); + } + return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) }); + }); + vi.stubGlobal("fetch", fetchMock); + + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.type(screen.getByLabelText(/current password/i), "correct-horse-1"); + await user.type(screen.getByLabelText(/new password/i), "brand-new-horse-2"); + await user.click(screen.getByRole("button", { name: /update password/i })); + + await waitFor(() => { + expect(replaceMock).toHaveBeenCalledWith("/dashboard"); + }); + }); + + it("surfaces an error for the wrong current password", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ detail: "Incorrect current password" }), + }), + ); + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.type(screen.getByLabelText(/current password/i), "wrong-password-1"); + await user.type(screen.getByLabelText(/new password/i), "brand-new-horse-2"); + await user.click(screen.getByRole("button", { name: /update password/i })); + + expect(await screen.findByText(/incorrect current password/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/tests/settings-database-viewer.test.tsx b/apps/web/tests/settings-database-viewer.test.tsx index 41248d4..a79164d 100644 --- a/apps/web/tests/settings-database-viewer.test.tsx +++ b/apps/web/tests/settings-database-viewer.test.tsx @@ -4,6 +4,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import SettingsPage from "@/app/(app)/settings/page"; import { renderWithQueryClient } from "./test-utils"; +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + const ADMIN_USER = { id: "user-1", email: "admin@example.com", @@ -12,6 +16,7 @@ const ADMIN_USER = { is_active: true, is_admin: true, auth_mode: "jwt", + must_change_password: false, }; function systemStatusBody(isLocalhost: boolean, authMode: "local" | "jwt") { @@ -67,7 +72,7 @@ describe("Settings - Database viewer box", () => { renderWithQueryClient(); const link = await screen.findByRole("link", { name: /open database viewer/i }); - expect(link).toHaveAttribute("href", "http://localhost:8081"); + expect(link).toHaveAttribute("href", "http://localhost:8081/?pgsql=postgres&username=ciagent"); expect(link).toHaveAttribute("target", "_blank"); }); diff --git a/apps/web/tests/settings-delete-account.test.tsx b/apps/web/tests/settings-delete-account.test.tsx new file mode 100644 index 0000000..6dbc14d --- /dev/null +++ b/apps/web/tests/settings-delete-account.test.tsx @@ -0,0 +1,148 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import SettingsPage from "@/app/(app)/settings/page"; +import { renderWithQueryClient } from "./test-utils"; + +const replaceMock = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: replaceMock }), +})); + +const REGULAR_USER = { + id: "user-1", + email: "user@example.com", + display_name: "Regular User", + timezone: "America/New_York", + is_active: true, + is_admin: false, + auth_mode: "jwt", + must_change_password: false, +}; + +const LOCAL_DEV_USER = { + id: "00000000-0000-0000-0000-000000000001", + email: "local@ci-agent.local", + display_name: "Local Developer", + timezone: "America/New_York", + is_active: true, + is_admin: true, + auth_mode: "local", + must_change_password: false, +}; + +function systemStatusBody(isLocalhost: boolean, authMode: "local" | "jwt") { + return { + app_env: "development", + auth_mode: authMode, + llm_provider: "mock", + search_provider: "mock", + sms_enabled: false, + sms_provider: "twilio", + ninjapear_configured: false, + ninjapear_credit_balance: null, + ninjapear_estimated_credits_per_company: null, + is_localhost: isLocalhost, + turnstile_site_key: null, + components: [], + }; +} + +function mockFetchImplementation( + isLocalhost: boolean, + authMode: "local" | "jwt", + meUser: typeof REGULAR_USER, +) { + return vi.fn().mockImplementation((url: string, init?: RequestInit) => { + const path = url.replace("http://localhost:8000", ""); + const ok = (json: unknown, status = 200) => + Promise.resolve({ ok: true, status, json: async () => json }); + + if (path === "/api/v1/auth/me") return ok(meUser); + if (path === "/api/v1/system/status") return ok(systemStatusBody(isLocalhost, authMode)); + if (path === "/api/v1/notification-destinations") return ok([]); + if (path === "/api/v1/companies") return ok([]); + if (path === "/api/v1/system/logs") return ok([]); + if (path === "/api/v1/user-api-keys") return ok([]); + if (path === "/api/v1/system/secrets") return ok([]); + if (path === "/api/v1/auth/security-events") return ok([]); + if (path === "/api/v1/admin/ip-bans") return ok([]); + if (path === "/api/v1/admin/unban-requests") return ok([]); + if (path === "/api/v1/auth/me" && init?.method === "DELETE") return ok(undefined, 204); + return Promise.resolve({ ok: false, status: 404, json: async () => ({ detail: "not found" }) }); + }); +} + +beforeEach(() => { + replaceMock.mockClear(); +}); + +describe("Settings - Delete account box", () => { + it("renders for a regular (non-local-dev) user, disabled until a password is typed", async () => { + vi.stubGlobal("fetch", mockFetchImplementation(false, "jwt", REGULAR_USER)); + renderWithQueryClient(); + + const button = await screen.findByRole("button", { name: /delete account/i }); + expect(button).toBeDisabled(); + + const user = userEvent.setup(); + await user.type(screen.getByLabelText(/confirm your password/i), "correct-horse-1"); + expect(button).not.toBeDisabled(); + }); + + it("is hidden entirely for the local-dev bypass account", async () => { + vi.stubGlobal("fetch", mockFetchImplementation(true, "local", LOCAL_DEV_USER)); + renderWithQueryClient(); + + await screen.findByText(LOCAL_DEV_USER.email); + expect(screen.queryByRole("button", { name: /delete account/i })).not.toBeInTheDocument(); + }); + + it("submits the password, then clears tokens and redirects home on success", async () => { + const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => { + const path = url.replace("http://localhost:8000", ""); + if (path === "/api/v1/auth/me" && init?.method === "DELETE") { + expect(JSON.parse(init.body as string)).toEqual({ password: "correct-horse-1" }); + return Promise.resolve({ ok: true, status: 204, json: async () => undefined }); + } + return mockFetchImplementation(false, "jwt", REGULAR_USER)(url, init); + }); + vi.stubGlobal("fetch", fetchMock); + + const user = userEvent.setup(); + renderWithQueryClient(); + + const button = await screen.findByRole("button", { name: /delete account/i }); + await user.type(screen.getByLabelText(/confirm your password/i), "correct-horse-1"); + await user.click(button); + + await waitFor(() => { + expect(replaceMock).toHaveBeenCalledWith("/"); + }); + }); + + it("shows an error message when the password is wrong", async () => { + const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => { + const path = url.replace("http://localhost:8000", ""); + if (path === "/api/v1/auth/me" && init?.method === "DELETE") { + return Promise.resolve({ + ok: false, + status: 401, + json: async () => ({ detail: "Incorrect password" }), + }); + } + return mockFetchImplementation(false, "jwt", REGULAR_USER)(url, init); + }); + vi.stubGlobal("fetch", fetchMock); + + const user = userEvent.setup(); + renderWithQueryClient(); + + const button = await screen.findByRole("button", { name: /delete account/i }); + await user.type(screen.getByLabelText(/confirm your password/i), "wrong-password"); + await user.click(button); + + expect(await screen.findByText(/incorrect password/i)).toBeInTheDocument(); + }); +});