diff --git a/.env.example b/.env.example index c1c4b78..50a3111 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,10 @@ POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5432 POSTGRES_DB=papyrus +# Optional public deletion notice for operator-managed backup/exceptional retention. +# Automated backups are not implemented. Planned future expiry: 30 days after deletion. +# ACCOUNT_DELETION_RETENTION_NOTICE="Your server's actual retention policy" + # Optional: fine-grained GitHub token with Issues write access to PapyrusReader/client. # Keep this secret on the server. Empty disables public support submissions. # Attachment uploads also require the token owner to have repository write access. diff --git a/alembic/versions/a8b9c0d1e234_order_account_notifications.py b/alembic/versions/a8b9c0d1e234_order_account_notifications.py new file mode 100644 index 0000000..18a7b46 --- /dev/null +++ b/alembic/versions/a8b9c0d1e234_order_account_notifications.py @@ -0,0 +1,23 @@ +"""Keep lifecycle notices ordered independently of delivery retries.""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "a8b9c0d1e234" +down_revision: str | Sequence[str] | None = "f7a8b9c0d123" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "account_notifications", + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("account_notifications", "created_at") diff --git a/alembic/versions/e6f7a8b9c012_add_account_deletion.py b/alembic/versions/e6f7a8b9c012_add_account_deletion.py new file mode 100644 index 0000000..3b2ebb0 --- /dev/null +++ b/alembic/versions/e6f7a8b9c012_add_account_deletion.py @@ -0,0 +1,34 @@ +"""Add scheduled account deletion and durable media cleanup.""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "e6f7a8b9c012" +down_revision: str | Sequence[str] | None = "b5c6d7e8f901" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("deletion_requested_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("users", sa.Column("deletion_due_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index("ix_users_deletion_due_at", "users", ["deletion_due_at"]) + op.add_column("auth_exchange_codes", sa.Column("code_challenge", sa.String(64), nullable=True)) + + op.create_table( + "account_media_cleanup", + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("user_id"), + ) + + +def downgrade() -> None: + op.drop_table("account_media_cleanup") + op.drop_column("auth_exchange_codes", "code_challenge") + op.drop_index("ix_users_deletion_due_at", table_name="users") + op.drop_column("users", "deletion_due_at") + op.drop_column("users", "deletion_requested_at") diff --git a/alembic/versions/f7a8b9c0d123_add_account_notifications.py b/alembic/versions/f7a8b9c0d123_add_account_notifications.py new file mode 100644 index 0000000..5a1c171 --- /dev/null +++ b/alembic/versions/f7a8b9c0d123_add_account_notifications.py @@ -0,0 +1,36 @@ +"""Queue account lifecycle notifications transactionally.""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "f7a8b9c0d123" +down_revision: str | Sequence[str] | None = "e6f7a8b9c012" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "account_notifications", + sa.Column("notification_id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("recipient", sa.String(255), nullable=False), + sa.Column("subject", sa.String(255), nullable=False), + sa.Column("text_body", sa.Text(), nullable=False), + sa.Column("html_body", sa.Text(), nullable=False), + sa.Column("attempts", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.user_id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("notification_id"), + ) + + op.create_index("ix_account_notifications_user_id", "account_notifications", ["user_id"]) + op.create_index("ix_account_notifications_next_attempt_at", "account_notifications", ["next_attempt_at"]) + + +def downgrade() -> None: + op.drop_table("account_notifications") diff --git a/papyrus/api/routes/__init__.py b/papyrus/api/routes/__init__.py index 30398bc..c24c05a 100644 --- a/papyrus/api/routes/__init__.py +++ b/papyrus/api/routes/__init__.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, FastAPI from papyrus.api.routes import ( + account_deletion, acquisition, annotations, auth, @@ -29,6 +30,8 @@ api_router = APIRouter() +api_router.include_router(account_deletion.router, prefix="/account-deletion", tags=["Account deletion"]) + api_router.include_router(support.router, prefix="/support", tags=["Support"]) api_router.include_router(opds.router, prefix="/opds", tags=["OPDS"]) diff --git a/papyrus/api/routes/account_deletion.py b/papyrus/api/routes/account_deletion.py new file mode 100644 index 0000000..b1eeef1 --- /dev/null +++ b/papyrus/api/routes/account_deletion.py @@ -0,0 +1,121 @@ +"""Public, narrowly authorized deletion and recovery routes.""" + +from pathlib import Path +from secrets import token_urlsafe +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request, Response +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.api.deps import CurrentSessionId, CurrentUserId +from papyrus.api.routes.auth import _auth_rate_limit, _auth_tokens_response, _public_callback_url +from papyrus.config import get_settings +from papyrus.core.database import get_db +from papyrus.core.rate_limit import limiter +from papyrus.schemas.account_deletion import ( + AccountActionRequest, + AccountDeletionAuthorization, + AccountDeletionStatus, + AccountGoogleExchangeRequest, + AccountPasswordRequest, + AccountRecoveryRequest, +) +from papyrus.schemas.auth import AuthTokens +from papyrus.services import account_deletion +from papyrus.services.auth.google import build_google_account_authorization_url + + +def no_store(response: Response) -> None: + response.headers["Cache-Control"] = "no-store" + response.headers["Referrer-Policy"] = "no-referrer" + + +router = APIRouter(dependencies=[Depends(no_store)]) +DBSession = Annotated[AsyncSession, Depends(get_db)] + + +@router.get("", response_class=HTMLResponse, include_in_schema=False) +async def account_deletion_page() -> HTMLResponse: + nonce = token_urlsafe(24) + template = Path(__file__).parents[2] / "web" / "account_deletion.html" + + return HTMLResponse( + template.read_text().replace("__NONCE__", nonce), + headers={ + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": ( + f"default-src 'none'; script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'; " + "connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" + ), + }, + ) + + +@router.post("/password", response_model=AccountDeletionAuthorization) +@limiter.limit(_auth_rate_limit) +async def authenticate_password( + request: Request, payload: AccountPasswordRequest, db: DBSession +) -> AccountDeletionAuthorization: + return await account_deletion.authenticate_password(db, payload) + + +@router.get("/google/start") +@limiter.limit(_auth_rate_limit) +async def google_start( + request: Request, + redirect_uri: str, + challenge: str = Query(pattern=r"^[0-9a-f]{64}$"), + expected_user_id: UUID | None = None, +) -> RedirectResponse: + url = build_google_account_authorization_url( + redirect_uri, + _public_callback_url(request, "google_oauth_callback"), + challenge, + expected_user_id, + ) + + return RedirectResponse(url, status_code=302) + + +@router.post("/exchange", response_model=AccountDeletionAuthorization) +@limiter.limit(_auth_rate_limit) +async def exchange( + request: Request, payload: AccountGoogleExchangeRequest, db: DBSession +) -> AccountDeletionAuthorization: + return await account_deletion.exchange_google(db, payload.code, payload.verifier) + + +@router.post("/schedule", response_model=AccountDeletionStatus) +@limiter.limit(_auth_rate_limit) +async def schedule(request: Request, payload: AccountActionRequest, db: DBSession) -> AccountDeletionStatus: + return await account_deletion.schedule_deletion(db, payload.authorization_token) + + +@router.post("/current", response_model=AccountDeletionStatus) +@limiter.limit(_auth_rate_limit) +async def schedule_current( + request: Request, db: DBSession, user_id: CurrentUserId, session_id: CurrentSessionId +) -> AccountDeletionStatus: + return await account_deletion.schedule_current_account(db, user_id, session_id) + + +@router.post("/cancel", response_model=AuthTokens) +@limiter.limit(_auth_rate_limit) +async def cancel(request: Request, payload: AccountRecoveryRequest, db: DBSession) -> AuthTokens: + result = await account_deletion.cancel_deletion( + db, + payload.authorization_token, + client_type=payload.client_type, + device_label=payload.device_label, + user_agent=request.headers.get("user-agent"), + ) + + return await _auth_tokens_response(db, result) + + +@router.get("/policy") +async def retention_policy() -> dict[str, str]: + return {"retention_notice": get_settings().account_deletion_retention_notice} diff --git a/papyrus/api/routes/auth.py b/papyrus/api/routes/auth.py index f390328..e601840 100644 --- a/papyrus/api/routes/auth.py +++ b/papyrus/api/routes/auth.py @@ -32,6 +32,7 @@ from papyrus.schemas.common import MessageResponse from papyrus.schemas.user import User from papyrus.services import auth as auth_service +from papyrus.services.users import user_response router = APIRouter() DBSession = Annotated[AsyncSession, Depends(get_db)] @@ -41,13 +42,13 @@ def _auth_rate_limit() -> str: return f"{get_settings().rate_limit_auth}/minute" -def _auth_tokens_response(result: auth_service.AuthResult) -> AuthTokens: +async def _auth_tokens_response(db: AsyncSession, result: auth_service.AuthResult) -> AuthTokens: return AuthTokens( access_token=result.access_token, refresh_token=result.refresh_token, token_type="Bearer", expires_in=result.expires_in, - user=User.model_validate(result.user), + user=await user_response(db, result.user), ) @@ -86,7 +87,7 @@ async def register_user( ) -> AuthTokens: """Create a new user account with email and password.""" result = await auth_service.register_user(db, payload, request.headers.get("user-agent")) - return _auth_tokens_response(result) + return await _auth_tokens_response(db, result) @router.post( @@ -102,7 +103,7 @@ async def login_user( ) -> AuthTokens: """Authenticate a user with email and password credentials.""" result = await auth_service.login_user(db, payload, request.headers.get("user-agent")) - return _auth_tokens_response(result) + return await _auth_tokens_response(db, result) @router.get( @@ -161,7 +162,7 @@ async def exchange_code( ) -> AuthTokens: """Exchange a one-time OAuth handoff code for Papyrus tokens.""" result = await auth_service.exchange_login_code(db, payload, request.headers.get("user-agent")) - return _auth_tokens_response(result) + return await _auth_tokens_response(db, result) @router.post( @@ -177,7 +178,7 @@ async def refresh_token( ) -> AuthTokens: """Exchange a valid refresh token for a new access token.""" result = await auth_service.refresh_tokens(db, payload) - return _auth_tokens_response(result) + return await _auth_tokens_response(db, result) @router.post( @@ -246,7 +247,7 @@ async def complete_google_link( ) -> User: """Consume a one-time code and link the Google identity to the authenticated user.""" user = await auth_service.complete_google_link(db, user_id, request.code) - return User.model_validate(user) + return await user_response(db, user) @router.post( diff --git a/papyrus/api/routes/users.py b/papyrus/api/routes/users.py index 9ca5263..7ca81a9 100644 --- a/papyrus/api/routes/users.py +++ b/papyrus/api/routes/users.py @@ -2,14 +2,14 @@ from typing import Annotated -from fastapi import APIRouter, Depends, Response, status +from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from papyrus.api.deps import CurrentUserId from papyrus.core.database import get_db from papyrus.schemas.auth import ChangePasswordRequest from papyrus.schemas.common import MessageResponse -from papyrus.schemas.user import DeleteAccountRequest, UpdateUserRequest, User, UserPreferences +from papyrus.schemas.user import UpdateUserRequest, User, UserPreferences from papyrus.services import users as user_service router = APIRouter() @@ -24,7 +24,7 @@ async def get_current_user(user_id: CurrentUserId, db: DBSession) -> User: """Return the authenticated user's profile information.""" user = await user_service.get_user_profile(db, user_id) - return User.model_validate(user) + return await user_service.user_response(db, user) @router.patch( @@ -39,22 +39,7 @@ async def update_current_user( ) -> User: """Update the authenticated user's profile information.""" user = await user_service.update_user_profile(db, user_id, request) - return User.model_validate(user) - - -@router.delete( - "/me", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete current user account", -) -async def delete_current_user( - user_id: CurrentUserId, - request: DeleteAccountRequest, - db: DBSession, -) -> Response: - """Disable the user account and revoke active sessions.""" - await user_service.delete_user_account(db, user_id, request.password) - return Response(status_code=status.HTTP_204_NO_CONTENT) + return await user_service.user_response(db, user) @router.get( diff --git a/papyrus/config.py b/papyrus/config.py index 3d2954b..0507ea4 100644 --- a/papyrus/config.py +++ b/papyrus/config.py @@ -24,6 +24,12 @@ class Settings(BaseSettings): postgres_db: str secret_key: str algorithm: str + account_deletion_retention_notice: str = ( + "Automated database and media backups are not currently implemented. " + "The planned backup retention limit is 30 days after permanent deletion. " + "Self-hosted operators must disclose any separate backups or exceptional retention." + ) + access_token_expire_minutes: int refresh_token_expire_days: int rate_limit_auth: int diff --git a/papyrus/main.py b/papyrus/main.py index 817a5cb..d27b21b 100644 --- a/papyrus/main.py +++ b/papyrus/main.py @@ -22,7 +22,7 @@ from papyrus.core.dev_pages import DEV_PAGES_DIST_DIR, DEV_PAGES_STATIC_URL from papyrus.core.exceptions import AppError from papyrus.core.rate_limit import limiter, rate_limit_exceeded_handler -from papyrus.services import acquisition_monitor +from papyrus.services import account_deletion, acquisition_monitor settings = get_settings() @@ -44,6 +44,9 @@ def configure_logging() -> None: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan events.""" + cleanup_task = asyncio.create_task( + account_deletion.run_cleanup(async_session_maker), name="account-deletion-cleanup" + ) monitor_task: asyncio.Task[None] | None = None if settings.acquisition_enabled and settings.acquisition_import_root is not None: @@ -58,6 +61,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: try: yield finally: + cleanup_task.cancel() + + with suppress(asyncio.CancelledError): + await cleanup_task + if monitor_task is not None: monitor_task.cancel() diff --git a/papyrus/models/__init__.py b/papyrus/models/__init__.py index 1d8c4d2..76e3807 100644 --- a/papyrus/models/__init__.py +++ b/papyrus/models/__init__.py @@ -1,6 +1,13 @@ from papyrus.core.database import Base from papyrus.models.acquisition import AcquisitionEndpoint, AcquisitionJob, AcquisitionRule -from papyrus.models.auth import AuthExchangeCode, AuthSession, EmailActionToken, PasswordCredential, UserIdentity +from papyrus.models.auth import ( + AccountNotification, + AuthExchangeCode, + AuthSession, + EmailActionToken, + PasswordCredential, + UserIdentity, +) from papyrus.models.library import ( SyncAnnotation, SyncBookmark, @@ -11,7 +18,7 @@ SyncTag, SyncTombstone, ) -from papyrus.models.media import MediaAsset +from papyrus.models.media import AccountMediaCleanup, MediaAsset from papyrus.models.powersync_demo import PowerSyncDemoItem from papyrus.models.sync import SyncBook from papyrus.models.tracking import SyncGoalPeriod, SyncReadingActivity, SyncReadingGoal @@ -32,11 +39,13 @@ "AcquisitionEndpoint", "AcquisitionJob", "AcquisitionRule", + "AccountNotification", "AuthExchangeCode", "AuthSession", "Base", "EmailActionToken", "MediaAsset", + "AccountMediaCleanup", "PasswordCredential", "PowerSyncDemoItem", "SyncBook", diff --git a/papyrus/models/auth.py b/papyrus/models/auth.py index 8f6f6bb..10b303d 100644 --- a/papyrus/models/auth.py +++ b/papyrus/models/auth.py @@ -5,7 +5,7 @@ from datetime import datetime from uuid import UUID, uuid4 -from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, Uuid, func, text +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, Uuid, func, text from sqlalchemy.orm import Mapped, mapped_column, relationship from papyrus.core.database import Base @@ -96,6 +96,7 @@ class AuthExchangeCode(Base): email_verified_at_provider: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) display_name: Mapped[str | None] = mapped_column(String(255), nullable=True) avatar_url: Mapped[str | None] = mapped_column(String(2048), nullable=True) + code_challenge: Mapped[str | None] = mapped_column(String(64), nullable=True) redirect_uri: Mapped[str] = mapped_column(Text, nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) @@ -117,3 +118,20 @@ class EmailActionToken(Base): from papyrus.models.user import User # noqa: E402 + + +class AccountNotification(Base): + """Pending notices are removed after delivery, expiry, or permanent account deletion.""" + + __tablename__ = "account_notifications" + + notification_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True) + recipient: Mapped[str] = mapped_column(String(255), nullable=False) + subject: Mapped[str] = mapped_column(String(255), nullable=False) + text_body: Mapped[str] = mapped_column(Text, nullable=False) + html_body: Mapped[str] = mapped_column(Text, nullable=False) + attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/papyrus/models/media.py b/papyrus/models/media.py index 1dc2c77..0bc36b7 100644 --- a/papyrus/models/media.py +++ b/papyrus/models/media.py @@ -35,3 +35,12 @@ class MediaAsset(Base): storage_path: Mapped[str] = mapped_column(String(2048), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + + +class AccountMediaCleanup(Base): + """Durable cleanup work surviving the account's cascading database deletion.""" + + __tablename__ = "account_media_cleanup" + + user_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/papyrus/models/user.py b/papyrus/models/user.py index f13b041..18d14e7 100644 --- a/papyrus/models/user.py +++ b/papyrus/models/user.py @@ -32,6 +32,9 @@ class User(Base): last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + deletion_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + deletion_due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + identities: Mapped[list[UserIdentity]] = relationship( back_populates="user", cascade="all, delete-orphan", diff --git a/papyrus/schemas/account_deletion.py b/papyrus/schemas/account_deletion.py new file mode 100644 index 0000000..2387755 --- /dev/null +++ b/papyrus/schemas/account_deletion.py @@ -0,0 +1,42 @@ +"""Restricted account deletion and recovery contracts.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, EmailStr, Field + +from papyrus.schemas.auth import ClientType + + +class AccountPasswordRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=1, max_length=128) + expected_user_id: UUID | None = None + + +class AccountGoogleExchangeRequest(BaseModel): + code: str = Field(min_length=1, max_length=512) + verifier: str = Field(min_length=43, max_length=128) + + +class AccountActionRequest(BaseModel): + authorization_token: str = Field(min_length=1, max_length=512) + + +class AccountRecoveryRequest(AccountActionRequest): + client_type: ClientType = "unknown" + device_label: str | None = Field(default=None, max_length=255) + + +class AccountDeletionAuthorization(BaseModel): + authorization_token: str + user_id: UUID + email: str | None + deletion_pending: bool + deletion_due_at: datetime + expires_at: datetime + + +class AccountDeletionStatus(BaseModel): + deletion_due_at: datetime | None + message: str diff --git a/papyrus/schemas/user.py b/papyrus/schemas/user.py index 95bf0a6..134609e 100644 --- a/papyrus/schemas/user.py +++ b/papyrus/schemas/user.py @@ -18,6 +18,7 @@ class User(BaseModel): email_verified: bool = False created_at: datetime last_login_at: datetime | None = None + connected_providers: list[str] = Field(default_factory=list) class UpdateUserRequest(BaseModel): @@ -36,9 +37,3 @@ class UserPreferences(BaseModel): notifications_enabled: bool | None = Field(None, examples=[True]) default_shelf: str | None = Field(None, examples=["Currently Reading"]) sync_wifi_only: bool | None = Field(None, examples=[False]) - - -class DeleteAccountRequest(BaseModel): - """Account deletion request.""" - - password: str = Field(..., description="Current password for confirmation") diff --git a/papyrus/services/account_deletion.py b/papyrus/services/account_deletion.py new file mode 100644 index 0000000..945ad50 --- /dev/null +++ b/papyrus/services/account_deletion.py @@ -0,0 +1,312 @@ +"""Reauthentication capabilities, explicit recovery, and retryable account erasure.""" + +import asyncio +import logging +import secrets +import shutil +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import NoReturn +from uuid import UUID + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from papyrus.config import get_settings +from papyrus.core.exceptions import AppError, ForbiddenError, UnauthorizedError, ValidationError +from papyrus.core.security import generate_opaque_token, hash_opaque_token, verify_password +from papyrus.models import ( + AccountMediaCleanup, + AuthExchangeCode, + AuthSession, + EmailActionToken, + PasswordCredential, + User, +) +from papyrus.schemas.account_deletion import AccountDeletionAuthorization, AccountDeletionStatus, AccountPasswordRequest +from papyrus.services.account_notifications import deliver_pending_notifications, queue_account_notification +from papyrus.services.auth._core import _create_session_for_user +from papyrus.services.auth.types import AuthResult +from papyrus.services.media import media_root + +RECOVERY_WINDOW = timedelta(days=30) +AUTHORIZATION_LIFETIME = timedelta(minutes=5) +AUTHORIZATION_PURPOSE = "account_deletion" +GOOGLE_HANDOFF_PURPOSE = "account_deletion_google" +logger = logging.getLogger(__name__) + + +def _now() -> datetime: + return datetime.now(UTC) + + +async def _lock_user(session: AsyncSession, user_id: UUID) -> User: + user = await session.scalar( + select(User).where(User.user_id == user_id).with_for_update().execution_options(populate_existing=True) + ) + + if user is None: + raise UnauthorizedError("Account is unavailable") + + if user.deletion_due_at is not None and user.deletion_due_at <= _now(): + raise ForbiddenError("The recovery deadline has passed") + + if user.disabled_at is not None and user.deletion_due_at is None: + raise ForbiddenError("User account is disabled") + + return user + + +async def _authorize(session: AsyncSession, user: User) -> AccountDeletionAuthorization: + now = _now() + token = generate_opaque_token() + expires_at = now + AUTHORIZATION_LIFETIME + + session.add( + AuthExchangeCode( + code_hash=hash_opaque_token(token), + purpose=AUTHORIZATION_PURPOSE, + user_id=user.user_id, + redirect_uri="", + created_at=now, + expires_at=expires_at, + ) + ) + + await session.commit() + + return AccountDeletionAuthorization( + authorization_token=token, + user_id=user.user_id, + email=user.primary_email, + deletion_pending=user.deletion_due_at is not None, + deletion_due_at=user.deletion_due_at or now + RECOVERY_WINDOW, + expires_at=expires_at, + ) + + +async def require_account_recovery(session: AsyncSession, user_id: UUID) -> NoReturn: + """Offer recovery after successful sign-in without granting a session.""" + user = await _lock_user(session, user_id) + + if user.deletion_due_at is None: + raise UnauthorizedError("Account status changed. Sign in again.") + + authorization = await _authorize(session, user) + + raise AppError( + "This account is scheduled for deletion.", + code="ACCOUNT_DELETION_PENDING", + status_code=403, + details={"authorization": authorization.model_dump(mode="json")}, + ) + + +async def authenticate_password( + session: AsyncSession, + request: AccountPasswordRequest, +) -> AccountDeletionAuthorization: + result = await session.execute( + select(User.user_id, PasswordCredential.password_hash) + .join(PasswordCredential, PasswordCredential.user_id == User.user_id) + .where(User.primary_email == request.email.strip().lower()) + ) + + credentials = result.one_or_none() + + if credentials is None or not verify_password(request.password, credentials.password_hash): + raise UnauthorizedError("Invalid email or password") + + if request.expected_user_id is not None and credentials.user_id != request.expected_user_id: + raise ForbiddenError("Authenticate with the account currently signed in") + + user = await _lock_user(session, credentials.user_id) + return await _authorize(session, user) + + +async def _lock_authorization(session: AsyncSession, token: str, purpose: str) -> tuple[User, AuthExchangeCode]: + """Lock the owner before the capability, matching scheduling and purge lock order.""" + token_hash = hash_opaque_token(token) + + user_id = await session.scalar( + select(AuthExchangeCode.user_id).where( + AuthExchangeCode.code_hash == token_hash, AuthExchangeCode.purpose == purpose + ) + ) + + if user_id is None: + raise UnauthorizedError("Authorization is invalid or expired. Authenticate again.") + + user = await _lock_user(session, user_id) + + authorization = await session.scalar( + select(AuthExchangeCode) + .where(AuthExchangeCode.code_hash == token_hash, AuthExchangeCode.purpose == purpose) + .with_for_update() + .execution_options(populate_existing=True) + ) + + if authorization is None or authorization.used_at is not None or authorization.expires_at <= _now(): + raise UnauthorizedError("Authorization is invalid or expired. Authenticate again.") + + return user, authorization + + +async def exchange_google(session: AsyncSession, code: str, verifier: str) -> AccountDeletionAuthorization: + user, handoff = await _lock_authorization(session, code, GOOGLE_HANDOFF_PURPOSE) + + if handoff.code_challenge is None or not secrets.compare_digest( + handoff.code_challenge, hash_opaque_token(verifier) + ): + raise UnauthorizedError("Google authorization does not match this browser session") + + handoff.used_at = _now() + return await _authorize(session, user) + + +async def _revoke_credentials(session: AsyncSession, user_id: UUID) -> None: + now = _now() + await session.execute(update(AuthSession).where(AuthSession.user_id == user_id).values(revoked_at=now)) + await session.execute(update(AuthExchangeCode).where(AuthExchangeCode.user_id == user_id).values(used_at=now)) + await session.execute(update(EmailActionToken).where(EmailActionToken.user_id == user_id).values(used_at=now)) + + +async def schedule_deletion(session: AsyncSession, token: str) -> AccountDeletionStatus: + user, authorization = await _lock_authorization(session, token, AUTHORIZATION_PURPOSE) + return await _schedule_deletion(session, user, authorization.created_at) + + +async def schedule_current_account(session: AsyncSession, user_id: UUID, session_id: UUID) -> AccountDeletionStatus: + """Serialize deletion with recovery and verify that the initiating session remains active.""" + user = await _lock_user(session, user_id) + + active_session = await session.scalar( + select(AuthSession) + .where( + AuthSession.session_id == session_id, + AuthSession.user_id == user_id, + AuthSession.revoked_at.is_(None), + AuthSession.expires_at > _now(), + ) + .execution_options(populate_existing=True) + ) + + if active_session is None: + raise UnauthorizedError("Session is no longer active") + + return await _schedule_deletion(session, user, _now()) + + +async def _schedule_deletion(session: AsyncSession, user: User, requested_at: datetime) -> AccountDeletionStatus: + if user.deletion_due_at is not None: + raise ValidationError("Deletion is already scheduled") + + user.deletion_requested_at = requested_at + user.deletion_due_at = requested_at + RECOVERY_WINDOW + user.disabled_at = _now() + await _revoke_credentials(session, user.user_id) + await queue_account_notification(session, user) + await session.commit() + + return AccountDeletionStatus( + deletion_due_at=user.deletion_due_at, + message="Account deletion scheduled. All sessions have been revoked. Local device data has not been deleted.", + ) + + +async def cancel_deletion( + session: AsyncSession, + token: str, + *, + client_type: str = "unknown", + device_label: str | None = None, + user_agent: str | None = None, +) -> AuthResult: + """Consume the recovery proof and restore access with a new session in one transaction.""" + user, _ = await _lock_authorization(session, token, AUTHORIZATION_PURPOSE) + + if user.deletion_due_at is None: + raise ValidationError("No account deletion is scheduled") + + user.deletion_requested_at = None + user.deletion_due_at = None + user.disabled_at = None + await _revoke_credentials(session, user.user_id) + result = await _create_session_for_user(session, user, client_type, device_label, user_agent) + await queue_account_notification(session, user) + await session.commit() + return result + + +async def purge_due_accounts(session: AsyncSession) -> int: + """Commit cascade deletion and cleanup work atomically before touching physical files.""" + users = ( + await session.scalars( + select(User).where(User.deletion_due_at <= _now()).with_for_update(skip_locked=True).limit(100) + ) + ).all() + + for user in users: + session.add(AccountMediaCleanup(user_id=user.user_id)) + await session.execute(delete(User).where(User.user_id == user.user_id)) + + await session.commit() + return len(users) + + +def _remove_owner_files(user_id: UUID) -> None: + """Only remove the UUID-scoped directories, never follow an owner symlink.""" + roots = [media_root()] + import_root = get_settings().acquisition_import_root + + if import_root is not None: + roots.append(Path(import_root)) + + for root in roots: + path = root / str(user_id) + + if path.is_symlink(): + path.unlink() + elif path.exists(): + shutil.rmtree(path) + + +async def cleanup_deleted_media(session: AsyncSession) -> int: + cleaned = 0 + user_ids = (await session.scalars(select(AccountMediaCleanup.user_id).limit(100))).all() + await session.commit() + + for user_id in user_ids: + try: + job = await session.scalar( + select(AccountMediaCleanup) + .where(AccountMediaCleanup.user_id == user_id) + .with_for_update(skip_locked=True) + ) + + if job is None: + await session.rollback() + continue + + await asyncio.to_thread(_remove_owner_files, user_id) + await session.delete(job) + await session.commit() + cleaned += 1 + except Exception: + await session.rollback() + logger.exception("Account media cleanup failed and will be retried") + + return cleaned + + +async def run_cleanup(session_maker: async_sessionmaker[AsyncSession]) -> None: + while True: + try: + async with session_maker() as session: + await purge_due_accounts(session) + await cleanup_deleted_media(session) + await deliver_pending_notifications(session) + except Exception: + logger.exception("Account deletion cleanup cycle failed and will be retried") + + await asyncio.sleep(60) diff --git a/papyrus/services/account_notifications.py b/papyrus/services/account_notifications.py new file mode 100644 index 0000000..3967990 --- /dev/null +++ b/papyrus/services/account_notifications.py @@ -0,0 +1,207 @@ +"""Branded account notices and a transactional, retryable delivery queue.""" + +import asyncio +import logging +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from urllib.parse import urlsplit + +from sqlalchemy import delete, exists, select, tuple_, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from papyrus.config import get_settings +from papyrus.models import AccountNotification, User +from papyrus.services import email + +logger = logging.getLogger(__name__) +NOTICE_LIFETIME = timedelta(days=7) +DELIVERY_LEASE = timedelta(minutes=5) + + +@dataclass(frozen=True) +class AccountEmail: + subject: str + text: str + html: str + + +def _sign_in_url() -> str | None: + settings = get_settings() + + if settings.app_public_base_url: + url = f"{settings.app_public_base_url.rstrip('/')}/login" + elif settings.public_base_url: + url = f"{settings.public_base_url.rstrip('/')}{settings.api_prefix}/account-deletion" + else: + return None + + parsed = urlsplit(url) + return url if parsed.scheme in {"https", "http"} and parsed.netloc else None + + +def render_account_email(display_name: str, deletion_due_at: datetime | None) -> AccountEmail: + """Keep email links informational: recovery still requires sign-in and explicit consent.""" + greeting = f"Hi {display_name}," if display_name else "Hello," + detail = None + deadline_text = "" + + if deletion_due_at is not None: + deadline = deletion_due_at.astimezone(UTC).strftime("%B %d, %Y at %H:%M UTC") + subject = "Your Papyrus account is scheduled for deletion" + heading = "Your account is scheduled for deletion" + preheader = f"You can keep your account until {deadline}." + summary = ( + "We received your request to delete your Papyrus account. " + "Your profile, synced library and reading data will be permanently deleted after the deadline below." + ) + + guidance = "Changed your mind? Sign in before this date and choose Keep account." + + security_note = ( + "If you didn't request this, sign in and choose Keep account before the deadline, " + "then secure the password or Google account you use to sign in." + ) + + action_label = "Sign in to keep your account" + deadline_text = f"Recovery deadline: {deadline}\n\n" + detail = ("RECOVERY DEADLINE", deadline) + else: + subject = "Your Papyrus account deletion was cancelled" + heading = "Your account is staying" + preheader = "Deletion has been cancelled. Your library and reading data are still here." + summary = "Your account deletion request has been cancelled. Your profile, library and reading data are safe." + guidance = "You can continue using Papyrus as usual. No further action is needed." + + security_note = ( + "If you didn't keep this account, secure the password or Google account you use to sign in. " + "You can review your account in Papyrus." + ) + + action_label = "Open Papyrus" + + url = _sign_in_url() + action_text = f"{action_label}: {url}\n\n" if url is not None else "" + + html = email.render_email_html( + subject=subject, + preheader=preheader, + heading=heading, + greeting=greeting, + summary=summary, + guidance=guidance, + security_note=security_note, + action_label=action_label, + action_url=url, + detail=detail, + ) + + text = ( + f"Papyrus\n\n{heading}\n\n{greeting}\n\n{summary}\n\n" + f"{deadline_text}{guidance}\n\n{action_text}{security_note}\n\n" + "This is an automatic account notification from Papyrus." + ) + + return AccountEmail(subject=subject, text=text, html=html) + + +async def queue_account_notification(session: AsyncSession, user: User) -> None: + """Keep a notice for each committed lifecycle event, including rapid deletion and recovery.""" + if not user.primary_email or not user.primary_email_verified: + return + + content = render_account_email(user.display_name, user.deletion_due_at) + now = datetime.now(UTC) + expires_at = now + NOTICE_LIFETIME + + if user.deletion_due_at is not None: + expires_at = min(expires_at, user.deletion_due_at) + + session.add( + AccountNotification( + user_id=user.user_id, + recipient=user.primary_email, + subject=content.subject, + text_body=content.text, + html_body=content.html, + created_at=now, + next_attempt_at=now, + expires_at=expires_at, + ) + ) + + +async def deliver_pending_notifications(session: AsyncSession) -> int: + """Lease before SMTP so delivery never holds account locks or blocks recovery. + + SMTP is at-least-once: a crash after acceptance but before deletion can repeat + a notice. Later notices for the same account wait for earlier deliveries or + their expiry. Pending messages expire after seven days and contain no auth tokens. + """ + await session.execute(delete(AccountNotification).where(AccountNotification.expires_at <= datetime.now(UTC))) + await session.commit() + + if not email.is_email_delivery_configured(): + return 0 + + delivered = 0 + earlier = aliased(AccountNotification) + + has_earlier_notice = exists().where( + earlier.user_id == AccountNotification.user_id, + tuple_(earlier.created_at, earlier.notification_id) + < tuple_(AccountNotification.created_at, AccountNotification.notification_id), + ) + + for _ in range(100): + now = datetime.now(UTC) + + notice = await session.scalar( + select(AccountNotification) + .where( + AccountNotification.next_attempt_at <= now, + AccountNotification.expires_at > now, + ~has_earlier_notice, + ) + .order_by(AccountNotification.created_at, AccountNotification.notification_id) + .with_for_update(skip_locked=True) + .limit(1) + ) + + if notice is None: + await session.rollback() + break + + notice.attempts += 1 + notice.next_attempt_at = now + DELIVERY_LEASE + await session.commit() + + try: + await asyncio.to_thread( + email.send_email, + notice.recipient, + notice.subject, + notice.text_body, + html_body=notice.html_body, + ) + except Exception: + retry_at = datetime.now(UTC) + timedelta(seconds=min(60 * 2 ** min(notice.attempts - 1, 6), 3600)) + + await session.execute( + update(AccountNotification) + .where(AccountNotification.notification_id == notice.notification_id) + .values(next_attempt_at=retry_at) + ) + + await session.commit() + logger.warning("Account notification delivery failed and will be retried") + continue + + await session.execute( + delete(AccountNotification).where(AccountNotification.notification_id == notice.notification_id) + ) + + await session.commit() + delivered += 1 + + return delivered diff --git a/papyrus/services/acquisition_monitor.py b/papyrus/services/acquisition_monitor.py index b265efa..4a333c9 100644 --- a/papyrus/services/acquisition_monitor.py +++ b/papyrus/services/acquisition_monitor.py @@ -15,7 +15,7 @@ from papyrus.config import get_settings from papyrus.core.exceptions import AppError, ConflictError, ForbiddenError, NotFoundError, ValidationError -from papyrus.models import AcquisitionEndpoint, AcquisitionJob, MediaAsset +from papyrus.models import AcquisitionEndpoint, AcquisitionJob, MediaAsset, User from papyrus.services import media as media_service from papyrus.services.acquisition import QbittorrentClient, QbittorrentFile, QbittorrentTorrent @@ -62,7 +62,9 @@ async def claim_due_jobs( AcquisitionEndpoint, AcquisitionEndpoint.endpoint_id == AcquisitionJob.endpoint_id, ) + .join(User, User.user_id == AcquisitionJob.owner_user_id) .where( + User.disabled_at.is_(None), AcquisitionEndpoint.kind == "qbittorrent", AcquisitionJob.status.in_(ACTIVE_JOB_STATUSES), AcquisitionJob.rule_id.is_(None), diff --git a/papyrus/services/auth/_core.py b/papyrus/services/auth/_core.py index ffcf86a..20dd47d 100644 --- a/papyrus/services/auth/_core.py +++ b/papyrus/services/auth/_core.py @@ -18,6 +18,7 @@ ) from papyrus.models import AuthExchangeCode, AuthSession, EmailActionToken, User from papyrus.services.auth.types import AuthResult, GoogleIdentity +from papyrus.services.email import render_email_html def _now() -> datetime: @@ -120,6 +121,7 @@ async def _create_exchange_code( email_verified_at_provider: datetime | None = None, display_name: str | None = None, avatar_url: str | None = None, + code_challenge: str | None = None, ) -> str: plain_code = generate_opaque_token() @@ -135,6 +137,7 @@ async def _create_exchange_code( display_name=display_name, avatar_url=avatar_url, redirect_uri=redirect_uri, + code_challenge=code_challenge, expires_at=_now() + timedelta(minutes=get_settings().auth_exchange_code_expire_minutes), ) ) @@ -278,3 +281,20 @@ def _password_reset_email_body(token: str) -> str: ) return "\n".join(lines) + + +def _password_reset_email_html(token: str, display_name: str) -> str: + expiry = get_settings().password_reset_token_expire_minutes + + return render_email_html( + subject="Reset your Papyrus password", + preheader=f"Choose a new password. This link expires in {expiry} minutes.", + category="ACCOUNT SECURITY", + heading="Reset your password", + greeting=f"Hi {display_name}," if display_name else "Hello,", + summary="We received a request to reset your Papyrus password. Choose a new password using the button below.", + guidance=f"This link expires in {expiry} minutes and can only be used once.", + security_note="If you didn't request this, you can ignore this email. Your password will stay the same.", + action_label="Reset password", + action_url=_build_app_url("/reset-password", {"token": token}), + ) diff --git a/papyrus/services/auth/email_flows.py b/papyrus/services/auth/email_flows.py index 58fc16a..d800096 100644 --- a/papyrus/services/auth/email_flows.py +++ b/papyrus/services/auth/email_flows.py @@ -17,6 +17,7 @@ _normalize_email, _now, _password_reset_email_body, + _password_reset_email_html, _revoke_user_sessions, _verification_email_body, ) @@ -81,6 +82,7 @@ async def begin_password_reset(session: AsyncSession, email: str) -> str: normalized_email, "Reset your Papyrus password", _password_reset_email_body(token), + html_body=_password_reset_email_html(token, user.display_name), ) await session.commit() diff --git a/papyrus/services/auth/google.py b/papyrus/services/auth/google.py index 5c6b0f2..2dda05a 100644 --- a/papyrus/services/auth/google.py +++ b/papyrus/services/auth/google.py @@ -4,7 +4,7 @@ import json from urllib.error import HTTPError, URLError -from urllib.parse import urlencode, urlsplit +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.request import Request, urlopen from uuid import UUID @@ -109,6 +109,7 @@ def exchange_code_for_identity(self, code: str, callback_uri: str) -> GoogleIden email_verified=bool(claims.get("email_verified", False)), display_name=str(claims["name"]) if isinstance(claims.get("name"), str) else None, avatar_url=str(claims["picture"]) if isinstance(claims.get("picture"), str) else None, + nonce=claims.get("nonce"), ) @@ -176,6 +177,29 @@ def build_google_login_authorization_url(redirect_uri: str, callback_uri: str) - return google_oauth_client.build_authorization_url(callback_uri, state) +def build_google_account_authorization_url( + redirect_uri: str, + callback_uri: str, + challenge: str, + expected_user_id: UUID | None = None, +) -> str: + _validate_oauth_redirect_uri(redirect_uri) + + state = create_state_token( + { + "redirect_uri": redirect_uri, + "mode": "account_deletion", + "challenge": challenge, + "user_id": str(expected_user_id) if expected_user_id is not None else None, + } + ) + + url = urlsplit(google_oauth_client.build_authorization_url(callback_uri, state)) + query = dict(parse_qsl(url.query)) + query.update({"prompt": "select_account consent", "nonce": challenge}) + return urlunsplit((url.scheme, url.netloc, url.path, urlencode(query), url.fragment)) + + async def build_google_link_authorization_url(user_id: UUID, redirect_uri: str, callback_uri: str) -> str: _validate_oauth_redirect_uri(redirect_uri) @@ -198,6 +222,9 @@ async def _resolve_google_login_user(session: AsyncSession, identity: GoogleIden existing_identity = identity_result.scalar_one_or_none() if existing_identity is not None: + if existing_identity.user.deletion_due_at is not None: + return existing_identity.user, None + if existing_identity.user.disabled_at is not None: return None, "account_disabled" @@ -262,6 +289,39 @@ async def handle_google_callback( identity = google_oauth_client.exchange_code_for_identity(code, callback_uri) mode = state.get("mode") + if mode == "account_deletion": + challenge = state.get("challenge") + + if not isinstance(challenge, str) or identity.nonce != challenge: + raise ValidationError("Google authorization does not match this request") + + user_id = await session.scalar( + select(UserIdentity.user_id).where( + UserIdentity.provider == GOOGLE_PROVIDER, + UserIdentity.provider_subject == identity.subject, + ) + ) + + expected_user_id = state.get("user_id") + + if user_id is None or (expected_user_id is not None and str(user_id) != expected_user_id): + return _build_redirect_uri(redirect_uri, {"error": "Use the Google account linked to your Papyrus account"}) + + from papyrus.services.account_deletion import GOOGLE_HANDOFF_PURPOSE, _lock_user + + await _lock_user(session, user_id) + + exchange_code = await _create_exchange_code( + session, + purpose=GOOGLE_HANDOFF_PURPOSE, + redirect_uri=redirect_uri, + user_id=user_id, + code_challenge=challenge, + ) + + await session.commit() + return _build_redirect_uri(redirect_uri, {"code": exchange_code}) + if mode == "login": user, callback_error = await _resolve_google_login_user(session, identity) diff --git a/papyrus/services/auth/sessions.py b/papyrus/services/auth/sessions.py index e2e6e5d..afbd1d7 100644 --- a/papyrus/services/auth/sessions.py +++ b/papyrus/services/auth/sessions.py @@ -88,12 +88,17 @@ async def login_user( if user is None or user.password_credential is None: raise UnauthorizedError("Invalid email or password") - if user.disabled_at is not None: - raise ForbiddenError("User account is disabled") - if not verify_password(request.password, user.password_credential.password_hash): raise UnauthorizedError("Invalid email or password") + if user.deletion_due_at is not None: + from papyrus.services.account_deletion import require_account_recovery + + await require_account_recovery(session, user.user_id) + + if user.disabled_at is not None: + raise ForbiddenError("User account is disabled") + auth_result = await _create_session_for_user( session, user, @@ -173,6 +178,13 @@ async def exchange_login_code( if exchange_code.user_id is None: raise UnauthorizedError("Exchange code is missing a user") + user = await session.get(User, exchange_code.user_id) + + if user is not None and user.deletion_due_at is not None: + from papyrus.services.account_deletion import require_account_recovery + + await require_account_recovery(session, user.user_id) + user = await _get_active_user(session, exchange_code.user_id) auth_result = await _create_session_for_user( diff --git a/papyrus/services/auth/types.py b/papyrus/services/auth/types.py index 3594700..37594af 100644 --- a/papyrus/services/auth/types.py +++ b/papyrus/services/auth/types.py @@ -30,3 +30,4 @@ class GoogleIdentity: email_verified: bool display_name: str | None avatar_url: str | None + nonce: str | None = None diff --git a/papyrus/services/email.py b/papyrus/services/email.py index ee4adbd..b8cc810 100644 --- a/papyrus/services/email.py +++ b/papyrus/services/email.py @@ -1,23 +1,83 @@ -"""SMTP email delivery service.""" +"""Shared transactional email styling and SMTP delivery.""" from __future__ import annotations import smtplib import ssl from email.message import EmailMessage +from html import escape +from pathlib import Path +from string import Template from papyrus.config import get_settings from papyrus.core.exceptions import ServiceUnavailableError, ValidationError +def render_email_html( + *, + subject: str, + preheader: str, + heading: str, + greeting: str, + summary: str, + guidance: str, + security_note: str, + action_label: str, + action_url: str | None, + category: str = "ACCOUNT UPDATE", + detail: tuple[str, str] | None = None, +) -> str: + """Escape message content and links inside the shared email layout.""" + detail_html = "" + + if detail is not None: + label, value = detail + + detail_html = ( + '
' + f'

{escape(label)}

' + f'

{escape(value)}

' + ) + + action_html = "" + + if action_url is not None: + escaped_url = escape(action_url, quote=True) + + action_html = ( + '' + '
' + f'' + f"{escape(action_label)}
" + '

' + f'Or open {escaped_url}

' + ) + + template = Template((Path(__file__).parents[1] / "templates" / "transactional_email.html").read_text()) + + return template.substitute( + subject=escape(subject), + preheader=escape(preheader), + category=escape(category), + heading=escape(heading), + greeting=escape(greeting), + summary=escape(summary), + detail=detail_html, + guidance=escape(guidance), + action=action_html, + security_note=escape(security_note), + ) + + def is_email_delivery_configured() -> bool: """Return whether SMTP-backed email delivery is configured.""" settings = get_settings() return bool(settings.email_delivery_enabled and settings.smtp_host and settings.smtp_from_email) -def send_email(recipient: str, subject: str, body: str) -> None: - """Send a plain-text email through the configured SMTP server.""" +def send_email(recipient: str, subject: str, body: str, *, html_body: str | None = None) -> None: + """Send plain text with an optional HTML alternative through the configured SMTP server.""" settings = get_settings() if not is_email_delivery_configured(): @@ -37,6 +97,10 @@ def send_email(recipient: str, subject: str, body: str) -> None: message["To"] = recipient message["Subject"] = subject message.set_content(body) + + if html_body is not None: + message.add_alternative(html_body, subtype="html") + smtp_host = settings.smtp_host if smtp_host is None: diff --git a/papyrus/services/media.py b/papyrus/services/media.py index e1ed681..0554ce5 100644 --- a/papyrus/services/media.py +++ b/papyrus/services/media.py @@ -270,7 +270,12 @@ async def _used_bytes(session: AsyncSession, user_id: UUID) -> int: async def _lock_user_uploads(session: AsyncSession, user_id: UUID) -> None: """Serialize quota and replacement decisions for one user's uploads.""" - await session.execute(select(User.user_id).where(User.user_id == user_id).with_for_update()) + user = await session.scalar( + select(User).where(User.user_id == user_id).with_for_update().execution_options(populate_existing=True) + ) + + if user is None or user.disabled_at is not None: + raise ForbiddenError("User account is unavailable") async def _write_upload_to_temp_file( diff --git a/papyrus/services/sync.py b/papyrus/services/sync.py index b7d4bd5..9681a46 100644 --- a/papyrus/services/sync.py +++ b/papyrus/services/sync.py @@ -6,6 +6,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from papyrus.core.exceptions import ForbiddenError from papyrus.models import User from papyrus.schemas.sync import PowerSyncCrudMutation from papyrus.services import media as media_service @@ -14,7 +15,12 @@ async def lock_owner_mutations(session: AsyncSession, user_id: UUID) -> None: """Hold the owner's mutation lock until the surrounding transaction ends.""" - await session.execute(select(User.user_id).where(User.user_id == user_id).with_for_update()) + user = await session.scalar( + select(User).where(User.user_id == user_id).with_for_update().execution_options(populate_existing=True) + ) + + if user is None or user.disabled_at is not None: + raise ForbiddenError("User account is unavailable") async def apply_powersync_upload_batch( diff --git a/papyrus/services/users.py b/papyrus/services/users.py index 17b8f4d..0e4be63 100644 --- a/papyrus/services/users.py +++ b/papyrus/services/users.py @@ -10,8 +10,9 @@ from papyrus.core.exceptions import ForbiddenError, NotFoundError, UnauthorizedError, ValidationError from papyrus.core.security import hash_password, verify_password -from papyrus.models import AuthSession, PasswordCredential, User +from papyrus.models import AuthSession, PasswordCredential, User, UserIdentity from papyrus.schemas.user import UpdateUserRequest +from papyrus.schemas.user import User as UserResponse def _now() -> datetime: @@ -57,18 +58,6 @@ async def update_user_profile(session: AsyncSession, user_id: UUID, request: Upd return user -async def delete_user_account(session: AsyncSession, user_id: UUID, password: str) -> None: - user = await _get_active_user(session, user_id) - credential = await session.get(PasswordCredential, user_id) - - if credential is not None and not verify_password(password, credential.password_hash): - raise UnauthorizedError("Current password is incorrect") - - user.disabled_at = _now() - await _revoke_user_sessions(session, user_id) - await session.commit() - - async def change_user_password(session: AsyncSession, user_id: UUID, current_password: str, new_password: str) -> None: user = await _get_active_user(session, user_id) credential = await session.get(PasswordCredential, user.user_id) @@ -83,3 +72,10 @@ async def change_user_password(session: AsyncSession, user_id: UUID, current_pas credential.password_changed_at = _now() await _revoke_user_sessions(session, user_id) await session.commit() + + +async def user_response(session: AsyncSession, user: User) -> UserResponse: + providers = await session.scalars(select(UserIdentity.provider).where(UserIdentity.user_id == user.user_id)) + response = UserResponse.model_validate(user) + response.connected_providers = sorted(set(providers)) + return response diff --git a/papyrus/templates/transactional_email.html b/papyrus/templates/transactional_email.html new file mode 100644 index 0000000..0b15366 --- /dev/null +++ b/papyrus/templates/transactional_email.html @@ -0,0 +1,31 @@ + + + + + +$subject + + +
$preheader
+ + +
+ + + + +
Papyrus
+

$category

+

$heading

+

$greeting

+

$summary

+$detail +

$guidance

+$action +

$security_note

+
+This is an automatic account notification from Papyrus.
You received it because this email address is linked to your account. +
+
+ + diff --git a/papyrus/web/account_deletion.html b/papyrus/web/account_deletion.html new file mode 100644 index 0000000..2875ebd --- /dev/null +++ b/papyrus/web/account_deletion.html @@ -0,0 +1,153 @@ + + + + +Delete your Papyrus account + + + +
+
Papyrus
+

Delete your account

+

Your account and synced library will be permanently deleted after 30 days. Sign in to continue.

+ +
+ + + +

or

+ +
+ +

+
+What happens to my data? +

Your profile, synced books, notes, bookmarks and reading history will be permanently deleted after 30 days. Sign in before then and choose Keep account to cancel deletion.

+

Files on your devices or in external storage won’t be removed.

+

+
+
+ + + diff --git a/tests/api/routes/test_account_deletion.py b/tests/api/routes/test_account_deletion.py new file mode 100644 index 0000000..a6c6924 --- /dev/null +++ b/tests/api/routes/test_account_deletion.py @@ -0,0 +1,335 @@ +"""Deletion capabilities cannot be used as ordinary login credentials.""" + +from datetime import UTC, datetime, timedelta +from urllib.parse import parse_qs, urlsplit +from uuid import UUID, uuid4 + +import pytest +from httpx import AsyncClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.config import get_settings +from papyrus.core.security import hash_opaque_token +from papyrus.models import AuthSession, User, UserIdentity +from papyrus.services import account_deletion, auth + + +async def authorize(client: AsyncClient, auth_user: dict[str, str]) -> dict: + response = await client.post( + "/v1/account-deletion/password", + json={"email": "user@example.com", "password": auth_user["password"]}, + ) + + assert response.status_code == 200, response.text + return response.json() + + +async def test_password_schedule_recovery_requires_explicit_cancellation( + client: AsyncClient, + auth_user: dict[str, str], + auth_headers: dict[str, str], + db_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + now = datetime(2026, 10, 23, 12, tzinfo=UTC) + monkeypatch.setattr(account_deletion, "_now", lambda: now) + preview = await authorize(client, auth_user) + assert preview["deletion_pending"] is False + assert datetime.fromisoformat(preview["deletion_due_at"]) == now + timedelta(days=30) + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 200 + second_preview = await authorize(client, auth_user) + + scheduled = await client.post( + "/v1/account-deletion/schedule", json={"authorization_token": preview["authorization_token"]} + ) + + assert scheduled.status_code == 200 + assert scheduled.json()["deletion_due_at"] == preview["deletion_due_at"] + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401 + + replay = await client.post( + "/v1/account-deletion/schedule", json={"authorization_token": second_preview["authorization_token"]} + ) + + assert replay.status_code == 401 + login = await client.post("/v1/auth/login", json={"email": "user@example.com", "password": auth_user["password"]}) + assert login.status_code == 403 + assert login.json()["error"]["code"] == "ACCOUNT_DELETION_PENDING" + recovery = login.json()["error"]["details"]["authorization"] + assert recovery["deletion_pending"] is True + assert recovery["deletion_due_at"] == preview["deletion_due_at"] + user = await db_session.get(User, UUID(auth_user["user_id"])) + assert user is not None + await db_session.refresh(user) + assert user.disabled_at is not None + + restricted = await client.get( + "/v1/users/me", headers={"Authorization": f"Bearer {recovery['authorization_token']}"} + ) + assert restricted.status_code == 401 + + cancelled = await client.post( + "/v1/account-deletion/cancel", json={"authorization_token": recovery["authorization_token"]} + ) + + assert cancelled.status_code == 200 + restored = cancelled.json() + assert restored["user"]["user_id"] == auth_user["user_id"] + assert ( + await client.get("/v1/users/me", headers={"Authorization": f"Bearer {restored['access_token']}"}) + ).status_code == 200 + assert (await client.post("/v1/auth/refresh", json={"refresh_token": restored["refresh_token"]})).status_code == 200 + assert ( + await client.post("/v1/account-deletion/cancel", json={"authorization_token": recovery["authorization_token"]}) + ).status_code == 401 + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401 + + +async def test_signed_in_deletion_uses_current_session_without_reauthentication( + client: AsyncClient, auth_user: dict[str, str], auth_headers: dict[str, str], db_session: AsyncSession +) -> None: + other = User(display_name="Other account") + db_session.add(other) + await db_session.commit() + assert (await client.post("/v1/account-deletion/current")).status_code == 401 + before = datetime.now(UTC) + scheduled = await client.post("/v1/account-deletion/current", headers=auth_headers) + assert scheduled.status_code == 200 + due = datetime.fromisoformat(scheduled.json()["deletion_due_at"]) + assert before + timedelta(days=30) <= due <= datetime.now(UTC) + timedelta(days=30) + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401 + assert (await client.post("/v1/account-deletion/current", headers=auth_headers)).status_code == 401 + await db_session.refresh(other) + assert other.disabled_at is None + assert other.deletion_due_at is None + login = await client.post("/v1/auth/login", json={"email": "user@example.com", "password": auth_user["password"]}) + assert login.json()["error"]["code"] == "ACCOUNT_DELETION_PENDING" + assert ( + await db_session.scalar(select(func.count()).select_from(AuthSession).where(AuthSession.revoked_at.is_(None))) + == 0 + ) + + +async def test_password_reauthentication_and_expected_account_are_required( + client: AsyncClient, auth_user: dict[str, str] +) -> None: + response = await client.post( + "/v1/account-deletion/password", json={"email": "user@example.com", "password": "incorrect"} + ) + + assert response.status_code == 401 + + response = await client.post( + "/v1/account-deletion/password", + json={"email": "user@example.com", "password": auth_user["password"], "expected_user_id": str(uuid4())}, + ) + + assert response.status_code == 403 + assert ( + await client.post("/v1/account-deletion/schedule", json={"authorization_token": "invalid"}) + ).status_code == 401 + + +async def test_expired_preview_and_recovery_deadline_are_enforced( + client: AsyncClient, auth_user: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + now = datetime(2026, 10, 23, 12, tzinfo=UTC) + monkeypatch.setattr(account_deletion, "_now", lambda: now) + preview = await authorize(client, auth_user) + now += timedelta(minutes=5) + + response = await client.post( + "/v1/account-deletion/schedule", json={"authorization_token": preview["authorization_token"]} + ) + + assert response.status_code == 401 + preview = await authorize(client, auth_user) + + response = await client.post( + "/v1/account-deletion/schedule", json={"authorization_token": preview["authorization_token"]} + ) + + assert response.status_code == 200 + now += timedelta(days=30) + + response = await client.post( + "/v1/account-deletion/password", json={"email": "user@example.com", "password": auth_user["password"]} + ) + + assert response.status_code == 403 + + +async def test_disabled_accounts_cannot_be_recovered_without_scheduled_deletion( + client: AsyncClient, auth_user: dict[str, str], db_session: AsyncSession +) -> None: + user = await db_session.get(User, UUID(auth_user["user_id"])) + assert user is not None + user.disabled_at = datetime.now(UTC) + await db_session.commit() + + response = await client.post( + "/v1/account-deletion/password", json={"email": "user@example.com", "password": auth_user["password"]} + ) + + assert response.status_code == 403 + + +async def test_google_reauthentication_is_bound_to_identity_and_browser( + client: AsyncClient, + auth_user: dict[str, str], + auth_headers: dict[str, str], + db_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = get_settings() + monkeypatch.setattr(settings, "google_oauth_client_id", "test-client") + monkeypatch.setattr(settings, "google_oauth_client_secret", "test-secret") + verifier = "v" * 43 + challenge = hash_opaque_token(verifier) + + db_session.add( + UserIdentity(user_id=UUID(auth_user["user_id"]), provider="google", provider_subject="linked-subject") + ) + await db_session.commit() + + monkeypatch.setattr( + auth.google_oauth_client, + "exchange_code_for_identity", + lambda *_: auth.GoogleIdentity( + subject="linked-subject", + email="different-provider-email@example.com", + email_verified=True, + display_name=None, + avatar_url=None, + nonce=challenge, + ), + ) + + start = await client.get( + "/v1/account-deletion/google/start", + params={ + "redirect_uri": "papyrus://auth/callback", + "challenge": challenge, + "expected_user_id": auth_user["user_id"], + }, + ) + + parameters = parse_qs(urlsplit(start.headers["location"]).query) + assert parameters["prompt"] == ["select_account consent"] + assert parameters["nonce"] == [challenge] + + callback = await client.get( + "/v1/auth/oauth/google/callback", params={"code": "provider-code", "state": parameters["state"][0]} + ) + + code = parse_qs(urlsplit(callback.headers["location"]).query)["code"][0] + assert (await client.post("/v1/auth/exchange-code", json={"code": code})).status_code == 401 + + wrong_browser = await client.post("/v1/account-deletion/exchange", json={"code": code, "verifier": "x" * 43}) + assert wrong_browser.status_code == 401 + response = await client.post("/v1/account-deletion/exchange", json={"code": code, "verifier": verifier}) + assert response.status_code == 200 + assert response.json()["user_id"] == auth_user["user_id"] + assert ( + await client.post("/v1/account-deletion/exchange", json={"code": code, "verifier": verifier}) + ).status_code == 401 + assert (await client.get("/v1/users/me", headers=auth_headers)).json()["connected_providers"] == ["google"] + assert await db_session.scalar(select(func.count()).select_from(AuthSession)) == 1 + authorization = response.json()["authorization_token"] + response = await client.post("/v1/account-deletion/schedule", json={"authorization_token": authorization}) + assert response.status_code == 200 + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401 + + login_start = await client.get("/v1/auth/oauth/google/start", params={"redirect_uri": "papyrus://auth/callback"}) + + login_state = parse_qs(urlsplit(login_start.headers["location"]).query)["state"][0] + + login_callback = await client.get( + "/v1/auth/oauth/google/callback", params={"code": "fresh-provider-code", "state": login_state} + ) + + login_code = parse_qs(urlsplit(login_callback.headers["location"]).query)["code"][0] + login = await client.post("/v1/auth/exchange-code", json={"code": login_code}) + assert login.status_code == 403 + assert login.json()["error"]["code"] == "ACCOUNT_DELETION_PENDING" + assert login.json()["error"]["details"]["authorization"]["deletion_pending"] is True + assert (await client.post("/v1/auth/exchange-code", json={"code": login_code})).status_code == 401 + assert await db_session.scalar(select(func.count()).select_from(AuthSession)) == 1 + + recovery_callback = await client.get( + "/v1/auth/oauth/google/callback", params={"code": "fresh-provider-code", "state": parameters["state"][0]} + ) + + recovery_code = parse_qs(urlsplit(recovery_callback.headers["location"]).query)["code"][0] + recovery = await client.post("/v1/account-deletion/exchange", json={"code": recovery_code, "verifier": verifier}) + assert recovery.status_code == 200 + assert recovery.json()["deletion_pending"] is True + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401 + + cancelled = await client.post( + "/v1/account-deletion/cancel", json={"authorization_token": recovery.json()["authorization_token"]} + ) + + assert cancelled.status_code == 200 + restored_headers = {"Authorization": f"Bearer {cancelled.json()['access_token']}"} + assert (await client.get("/v1/users/me", headers=restored_headers)).status_code == 200 + assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401 + + +@pytest.mark.parametrize("mismatch", ["identity", "nonce", "account"]) +async def test_google_cannot_authorize_a_different_account_or_flow( + client: AsyncClient, + auth_user: dict[str, str], + db_session: AsyncSession, + monkeypatch: pytest.MonkeyPatch, + mismatch: str, +) -> None: + monkeypatch.setattr(get_settings(), "google_oauth_client_id", "test-client") + challenge = hash_opaque_token("v" * 43) + db_session.add(UserIdentity(user_id=UUID(auth_user["user_id"]), provider="google", provider_subject="linked")) + await db_session.commit() + + monkeypatch.setattr( + auth.google_oauth_client, + "exchange_code_for_identity", + lambda *_: auth.GoogleIdentity( + subject="unlinked" if mismatch == "identity" else "linked", + email="user@example.com", + email_verified=True, + display_name=None, + avatar_url=None, + nonce="wrong" if mismatch == "nonce" else challenge, + ), + ) + + start = await client.get( + "/v1/account-deletion/google/start", + params={ + "redirect_uri": "papyrus://auth/callback", + "challenge": challenge, + "expected_user_id": str(uuid4()) if mismatch == "account" else auth_user["user_id"], + }, + ) + + state = parse_qs(urlsplit(start.headers["location"]).query)["state"][0] + callback = await client.get("/v1/auth/oauth/google/callback", params={"code": "provider-code", "state": state}) + + if mismatch == "nonce": + assert callback.status_code == 400 + else: + assert callback.status_code == 302 + assert "code" not in parse_qs(urlsplit(callback.headers["location"]).query) + + assert await db_session.scalar(select(func.count()).select_from(User)) == 1 + + +async def test_public_resource_and_policy(client: AsyncClient) -> None: + response = await client.get("/v1/account-deletion") + assert response.status_code == 200 + assert "Continue with Google" in response.text + assert "Keep account" in response.text + assert response.headers["cache-control"] == "no-store" + assert "frame-ancestors 'none'" in response.headers["content-security-policy"] + policy = await client.get("/v1/account-deletion/policy") + assert "not currently implemented" in policy.json()["retention_notice"] diff --git a/tests/api/routes/test_auth.py b/tests/api/routes/test_auth.py index 4d37d0b..07917dc 100644 --- a/tests/api/routes/test_auth.py +++ b/tests/api/routes/test_auth.py @@ -80,16 +80,16 @@ def unconfigured_powersync(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture -def configured_email_delivery(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str, str]]: +def configured_email_delivery(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str, str, str | None]]: settings = get_settings() monkeypatch.setattr(settings, "email_delivery_enabled", True) monkeypatch.setattr(settings, "smtp_host", "smtp.example.test") monkeypatch.setattr(settings, "smtp_from_email", "noreply@example.test") monkeypatch.setattr(settings, "app_public_base_url", "http://papyrus.localhost:3000") - sent_messages: list[tuple[str, str, str]] = [] + sent_messages: list[tuple[str, str, str, str | None]] = [] - def fake_send_email(recipient: str, subject: str, body: str) -> None: - sent_messages.append((recipient, subject, body)) + def fake_send_email(recipient: str, subject: str, body: str, *, html_body: str | None = None) -> None: + sent_messages.append((recipient, subject, body, html_body)) monkeypatch.setattr(email_service, "send_email", fake_send_email) return sent_messages @@ -368,6 +368,7 @@ async def test_google_oauth_flow_creates_user_and_exchanges_code( assert exchange_response.status_code == 200 data = exchange_response.json() assert data["user"]["email"] == "google_user@example.com" + assert data["user"]["connected_providers"] == ["google"] assert data["user"]["display_name"] == "Google User" @@ -514,6 +515,7 @@ async def test_google_link_flow_links_identity_to_existing_user( ) assert complete_response.status_code == 200 + assert complete_response.json()["connected_providers"] == ["google"] identity_result = await db_session.execute( select(UserIdentity).where(UserIdentity.provider_subject == "google-sub-link") @@ -614,7 +616,7 @@ async def test_verify_email_rejects_expired_token(client: AsyncClient, db_sessio async def test_resend_verification_sends_email_when_configured( client: AsyncClient, - configured_email_delivery: list[tuple[str, str, str]], + configured_email_delivery: list[tuple[str, str, str, str | None]], ): """Test resend verification sends an email when SMTP is configured.""" register_response = await client.post( @@ -636,10 +638,11 @@ async def test_resend_verification_sends_email_when_configured( assert response.status_code == 200 assert response.json()["message"] == "If the email is registered, a verification link has been sent" assert len(configured_email_delivery) == 1 - recipient, subject, body = configured_email_delivery[0] + recipient, subject, body, html = configured_email_delivery[0] assert recipient == "test@example.com" assert subject == "Verify your Papyrus email address" assert "Verification token:" in body + assert html is None async def test_forgot_password_returns_configuration_message( @@ -670,7 +673,7 @@ async def test_forgot_password_returns_configuration_message( async def test_forgot_password_requires_app_public_base_url( client: AsyncClient, monkeypatch: pytest.MonkeyPatch, - configured_email_delivery: list[tuple[str, str, str]], + configured_email_delivery: list[tuple[str, str, str, str | None]], ): """Test forgot password does not send linkless email when app URL is missing.""" register_response = await client.post( @@ -698,7 +701,7 @@ async def test_forgot_password_requires_app_public_base_url( async def test_forgot_password_sends_email_when_configured( client: AsyncClient, - configured_email_delivery: list[tuple[str, str, str]], + configured_email_delivery: list[tuple[str, str, str, str | None]], ): """Test forgot password issues a token and sends an email when SMTP is configured.""" register_response = await client.post( @@ -706,7 +709,7 @@ async def test_forgot_password_sends_email_when_configured( json={ "email": "test@example.com", "password": "SecureP@ss123", - "display_name": "Test User", + "display_name": '', }, ) @@ -720,13 +723,26 @@ async def test_forgot_password_sends_email_when_configured( assert response.status_code == 200 assert response.json()["message"] == "If the email is registered, a reset link has been sent" assert len(configured_email_delivery) == 1 - recipient, subject, body = configured_email_delivery[0] + recipient, subject, body, html = configured_email_delivery[0] assert recipient == "test@example.com" assert subject == "Reset your Papyrus password" assert "http://papyrus.localhost:3000/reset-password?token=" in body assert "This link expires in 60 minutes." in body assert "Reset token:" not in body assert "/v1/auth/reset-password" not in body + assert html is not None + assert "Reset your password" in html + assert "This link expires in 60 minutes and can only be used once." in html + assert "If you didn't request this" in html + assert "', deadline) + email.send_email("reader@example.test", content.subject, content.text, html_body=content.html) + message = smtp_messages[0] + assert message.get_content_type() == "multipart/alternative" + plain = message.get_body(preferencelist=("plain",)).get_content() + html = message.get_body(preferencelist=("html",)).get_content() + assert "November 22, 2026 at 14:04 UTC" in plain + assert "November 22, 2026 at 14:04 UTC" in html + assert "