Skip to content

Commit e9493e4

Browse files
StuMasonclaude
andauthored
feat: Admin authentication and dynamic OAuth URLs (#6)
* feat: Add admin authentication and dynamic OAuth URLs SECURITY: Previously the admin dashboard was completely open - anyone with the URL could access health data. This commit adds proper auth. ## Changes ### Authentication System - Add AdminUser model with Argon2 password hashing - Server-side session management (24hr expiry) - First-run setup flow: create admin account before accessing dashboard - Login/logout functionality with session cookies ### OAuth Fix - Dynamic redirect_uri based on BASE_URL config or request headers - No more hardcoded localhost:8000 URLs - Works correctly behind reverse proxies (Coolify, Traefik, etc.) ### New Files - src/polar_flow_server/admin/auth.py - Authentication logic - src/polar_flow_server/core/password.py - Argon2 password hashing - src/polar_flow_server/models/admin_user.py - AdminUser model - templates/admin/login.html - Login form - templates/admin/setup_account.html - First-run admin creation ### Flow 1. /admin → No admin exists? → /admin/setup/account 2. /admin → Not logged in? → /admin/login 3. /admin → No OAuth? → /admin/setup (with dynamic callback URL) 4. /admin → All good → /admin/dashboard ### Config - BASE_URL: Set for production deployments - SESSION_SECRET: Auto-generated if not set Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Address security review findings - Add auth checks to all chart/export endpoints - Add CSRF protection middleware with token validation - Configure HTMX to include CSRF tokens in requests - Fix deprecated datetime.utcnow() -> datetime.now(UTC) - Improve email validation with proper regex pattern - Fix email existence leak (generic "Invalid credentials" message) - Change logout from GET to POST with CSRF protection - Add index on is_active column in migration - Fix bcrypt->Argon2 in docstring - Fix SQLAlchemy session expire_on_commit for async context - Replace pool_pre_ping with pool_recycle for greenlet compatibility Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: Fix import order * fix: Use cookie-based CSRF token retrieval for mypy compatibility --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 69dcdc7 commit e9493e4

File tree

16 files changed

+1019
-34
lines changed

16 files changed

+1019
-34
lines changed

CLAUDE.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
Self-hosted health analytics server for Polar devices. Syncs data from Polar AccessLink API (9+ endpoints) to PostgreSQL and provides an HTMX-powered admin dashboard plus REST API.
8+
9+
## Development Commands
10+
11+
```bash
12+
# Install dependencies
13+
uv sync --all-extras
14+
15+
# Start PostgreSQL (required for local development)
16+
docker-compose up -d postgres
17+
18+
# Run server with hot reload
19+
uv run uvicorn polar_flow_server.app:app --reload
20+
21+
# Run tests
22+
uv run pytest
23+
24+
# Run single test file
25+
uv run pytest tests/test_api.py
26+
27+
# Run single test function
28+
uv run pytest tests/test_api.py::test_health_check -v
29+
30+
# Type check
31+
uv run mypy src/polar_flow_server
32+
33+
# Lint
34+
uv run ruff check src/
35+
36+
# Lint with auto-fix
37+
uv run ruff check src/ --fix
38+
```
39+
40+
## Architecture
41+
42+
### Stack
43+
- **Litestar** - Async web framework (not FastAPI)
44+
- **SQLAlchemy 2.0** - Async ORM with declarative models
45+
- **PostgreSQL** - Primary database (asyncpg driver)
46+
- **Alembic** - Database migrations (auto-run on container startup)
47+
- **polar-flow SDK** - Polar AccessLink API client
48+
- **HTMX + Tailwind** - Admin UI (server-rendered templates)
49+
50+
### Source Layout (`src/polar_flow_server/`)
51+
52+
```
53+
app.py # Litestar application factory
54+
routes.py # Root route (redirects to admin)
55+
core/
56+
config.py # Settings via pydantic-settings (reads .env)
57+
database.py # SQLAlchemy async engine/session
58+
auth.py # API key authentication guard
59+
security.py # Token encryption (Fernet)
60+
models/ # SQLAlchemy ORM models
61+
base.py # Base class, TimestampMixin, UserScopedMixin
62+
user.py # Polar user with encrypted tokens
63+
sleep.py, activity.py, exercise.py, etc.
64+
transformers/ # SDK model -> DB model mappers
65+
sleep.py, activity.py, etc.
66+
services/
67+
sync.py # SyncService - orchestrates Polar API sync
68+
api/
69+
health.py # /health endpoint
70+
sleep.py # /users/{user_id}/sleep endpoint
71+
data.py # All other data endpoints (activity, recharge, etc.)
72+
sync.py # Sync trigger endpoints
73+
admin/
74+
routes.py # Admin dashboard, OAuth flow, settings
75+
auth.py # Admin user authentication (session-based)
76+
templates/ # Jinja2 templates for admin UI
77+
```
78+
79+
### Key Patterns
80+
81+
**Multi-tenancy**: All data tables include `user_id` column via `UserScopedMixin`. Works for both self-hosted (one user) and SaaS (many users).
82+
83+
**Transformers**: Each Polar SDK model has a corresponding transformer that maps to database model. Located in `transformers/` with consistent interface:
84+
```python
85+
class SleepTransformer:
86+
@staticmethod
87+
def transform(sdk_model, user_id: str) -> dict:
88+
# Returns dict for SQLAlchemy upsert
89+
```
90+
91+
**Sync Service** (`services/sync.py`): Single entry point for syncing all data types. Uses PostgreSQL upsert (ON CONFLICT DO UPDATE) for idempotent syncs.
92+
93+
**Admin Auth**: Session-based authentication for admin panel (separate from API key auth). First-run flow: setup account -> OAuth credentials -> connect Polar.
94+
95+
**API Auth**: Optional API key via `X-API-Key` header. Controlled by `API_KEY` env var. If not set, data endpoints are open.
96+
97+
### Database
98+
99+
- Migrations in `alembic/versions/`
100+
- Create migration: `uv run alembic revision --autogenerate -m "description"`
101+
- Apply migrations: `uv run alembic upgrade head`
102+
- Migrations run automatically in Docker via `docker-entrypoint.sh`
103+
104+
### Configuration
105+
106+
Key environment variables (see `.env.example`):
107+
- `DATABASE_URL` - PostgreSQL connection string
108+
- `DEPLOYMENT_MODE` - `self_hosted` (default) or `saas`
109+
- `API_KEY` - Optional API authentication
110+
- `SYNC_DAYS_LOOKBACK` - Days of historical data to sync (default 30)
111+
112+
Secrets auto-generate in self-hosted mode and persist to `~/.polar-flow/`:
113+
- `encryption.key` - For Polar token encryption
114+
- `session.key` - For admin session cookies
115+
116+
### Testing
117+
118+
Tests use `pytest-asyncio` with auto mode. Test client:
119+
```python
120+
from litestar.testing import AsyncTestClient
121+
from polar_flow_server.app import create_app
122+
123+
client = AsyncTestClient(app=create_app())
124+
```

alembic/env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
ECG,
2121
Activity,
2222
ActivitySamples,
23+
AdminUser,
2324
APIKey,
2425
AppSettings,
2526
BodyTemperature,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Add admin_users table for dashboard auth.
2+
3+
Revision ID: b2c3d4e5f678
4+
Revises: 94e67d473306
5+
Create Date: 2025-01-12
6+
"""
7+
8+
from collections.abc import Sequence
9+
10+
import sqlalchemy as sa
11+
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "b2c3d4e5f678"
16+
down_revision: str | None = "94e67d473306"
17+
branch_labels: str | Sequence[str] | None = None
18+
depends_on: str | Sequence[str] | None = None
19+
20+
21+
def upgrade() -> None:
22+
"""Create admin_users table."""
23+
op.create_table(
24+
"admin_users",
25+
sa.Column("id", sa.String(36), primary_key=True),
26+
sa.Column("email", sa.String(255), nullable=False, unique=True, index=True),
27+
sa.Column("password_hash", sa.String(255), nullable=False),
28+
sa.Column("name", sa.String(255), nullable=True),
29+
sa.Column("is_active", sa.Boolean(), nullable=False, default=True, index=True),
30+
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
31+
sa.Column(
32+
"created_at",
33+
sa.DateTime(timezone=True),
34+
nullable=False,
35+
server_default=sa.func.now(),
36+
),
37+
sa.Column(
38+
"updated_at",
39+
sa.DateTime(timezone=True),
40+
nullable=False,
41+
server_default=sa.func.now(),
42+
onupdate=sa.func.now(),
43+
),
44+
)
45+
46+
47+
def downgrade() -> None:
48+
"""Drop admin_users table."""
49+
op.drop_table("admin_users")
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Admin authentication for dashboard access.
2+
3+
Security Model:
4+
- Admin users stored in database with Argon2 hashed passwords
5+
- Server-side sessions (not JWT) for simplicity and security
6+
- Sessions stored in memory (use Redis for multi-instance deployments)
7+
- Session cookie is HTTP-only and secure in production
8+
"""
9+
10+
from datetime import UTC, datetime
11+
from typing import Any
12+
13+
from litestar.connection import ASGIConnection
14+
from litestar.exceptions import NotAuthorizedException
15+
from litestar.handlers import BaseRouteHandler
16+
from litestar.response import Redirect
17+
from litestar.status_codes import HTTP_303_SEE_OTHER
18+
from sqlalchemy import func, select
19+
from sqlalchemy.ext.asyncio import AsyncSession
20+
21+
from polar_flow_server.core.password import hash_password, verify_password
22+
from polar_flow_server.models.admin_user import AdminUser
23+
24+
25+
async def admin_user_exists(session: AsyncSession) -> bool:
26+
"""Check if any admin user exists in the database.
27+
28+
Args:
29+
session: Database session
30+
31+
Returns:
32+
True if at least one admin user exists
33+
"""
34+
result = await session.execute(select(func.count(AdminUser.id)))
35+
count = result.scalar() or 0
36+
return count > 0
37+
38+
39+
async def get_admin_by_email(email: str, session: AsyncSession) -> AdminUser | None:
40+
"""Get admin user by email.
41+
42+
Args:
43+
email: Email address to look up
44+
session: Database session
45+
46+
Returns:
47+
AdminUser if found, None otherwise
48+
"""
49+
result = await session.execute(
50+
select(AdminUser).where(AdminUser.email == email, AdminUser.is_active == True) # noqa: E712
51+
)
52+
return result.scalar_one_or_none()
53+
54+
55+
async def authenticate_admin(email: str, password: str, session: AsyncSession) -> AdminUser | None:
56+
"""Authenticate admin user with email and password.
57+
58+
Uses constant-time comparison via Argon2 to prevent timing attacks.
59+
60+
Args:
61+
email: Admin email
62+
password: Plain text password
63+
session: Database session
64+
65+
Returns:
66+
AdminUser if authentication successful, None otherwise
67+
"""
68+
admin = await get_admin_by_email(email, session)
69+
if not admin:
70+
# Still hash something to prevent timing attacks
71+
hash_password("dummy_password_to_prevent_timing_attack")
72+
return None
73+
74+
# Access password_hash while in async context to avoid lazy loading issues
75+
stored_hash = admin.password_hash
76+
77+
if not verify_password(password, stored_hash):
78+
return None
79+
80+
# Update last login time
81+
admin.last_login_at = datetime.now(UTC)
82+
await session.commit()
83+
84+
return admin
85+
86+
87+
async def create_admin_user(
88+
email: str,
89+
password: str,
90+
session: AsyncSession,
91+
name: str | None = None,
92+
) -> AdminUser:
93+
"""Create a new admin user.
94+
95+
Args:
96+
email: Admin email (must be unique)
97+
password: Plain text password (will be hashed)
98+
session: Database session
99+
name: Optional display name
100+
101+
Returns:
102+
Created AdminUser
103+
104+
Raises:
105+
ValueError: If email already exists
106+
"""
107+
# Check if email already exists
108+
existing = await get_admin_by_email(email, session)
109+
if existing:
110+
raise ValueError(f"Admin user with email {email} already exists")
111+
112+
admin = AdminUser(
113+
email=email,
114+
password_hash=hash_password(password),
115+
name=name,
116+
)
117+
session.add(admin)
118+
await session.commit()
119+
await session.refresh(admin)
120+
121+
return admin
122+
123+
124+
def is_authenticated(connection: ASGIConnection[Any, Any, Any, Any]) -> bool:
125+
"""Check if the current request has an authenticated admin session.
126+
127+
Args:
128+
connection: The ASGI connection
129+
130+
Returns:
131+
True if authenticated, False otherwise
132+
"""
133+
session_data = connection.session
134+
return session_data.get("admin_id") is not None
135+
136+
137+
async def require_admin_auth(
138+
connection: ASGIConnection[Any, Any, Any, Any], _: BaseRouteHandler
139+
) -> None:
140+
"""Guard that requires admin authentication.
141+
142+
Use this guard on routes that should only be accessible to logged-in admins.
143+
144+
Raises:
145+
NotAuthorizedException: If not authenticated (will redirect to login)
146+
"""
147+
if not is_authenticated(connection):
148+
# For API endpoints, return 401
149+
# For browser requests, redirect to login
150+
accept = connection.headers.get("accept", "")
151+
if "text/html" in accept:
152+
raise NotAuthorizedException(
153+
detail="Authentication required",
154+
extra={"redirect_to": "/admin/login"},
155+
)
156+
raise NotAuthorizedException(detail="Authentication required")
157+
158+
159+
def login_admin(connection: ASGIConnection[Any, Any, Any, Any], admin: AdminUser) -> None:
160+
"""Set up authenticated session for admin.
161+
162+
Args:
163+
connection: The ASGI connection
164+
admin: The authenticated admin user
165+
"""
166+
connection.session["admin_id"] = admin.id
167+
connection.session["admin_email"] = admin.email
168+
169+
170+
def logout_admin(connection: ASGIConnection[Any, Any, Any, Any]) -> None:
171+
"""Clear admin session.
172+
173+
Args:
174+
connection: The ASGI connection
175+
"""
176+
connection.session.clear()
177+
178+
179+
def get_redirect_for_unauthenticated() -> Redirect:
180+
"""Get redirect response to login page.
181+
182+
Returns:
183+
Redirect to /admin/login
184+
"""
185+
return Redirect(path="/admin/login", status_code=HTTP_303_SEE_OTHER)

0 commit comments

Comments
 (0)