diff --git a/README.md b/README.md index 836282d..e1f28df 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Ownership registry and ActivityPub federation for Art DAG. - **Activities**: Creates signed ownership claims (Create activities) - **Federation**: ActivityPub endpoints for follow/share - **L1 Integration**: Records completed L1 runs as owned assets +- **Authentication**: User registration, login, JWT tokens ## Setup @@ -27,6 +28,53 @@ python setup_keys.py python server.py ``` +## JWT Secret Configuration + +The JWT secret is used to sign authentication tokens. **Without a persistent secret, tokens are invalidated on server restart.** + +### Generate a secret + +```bash +# Generate a 64-character hex secret +openssl rand -hex 32 +# Or with Python +python -c "import secrets; print(secrets.token_hex(32))" +``` + +### Local development + +```bash +export JWT_SECRET="your-generated-secret-here" +python server.py +``` + +### Docker Swarm (recommended for production) + +Create a Docker secret: +```bash +# From a generated value +openssl rand -hex 32 | docker secret create jwt_secret - + +# Or from a file +echo "your-secret-here" > jwt_secret.txt +docker secret create jwt_secret jwt_secret.txt +rm jwt_secret.txt +``` + +Reference in docker-compose.yml: +```yaml +services: + l2-server: + secrets: + - jwt_secret + +secrets: + jwt_secret: + external: true +``` + +The server reads secrets from `/run/secrets/jwt_secret` automatically. + ## Key Setup ActivityPub requires RSA keys for signing activities. Generate them: diff --git a/auth.py b/auth.py index c67ed51..0bdacc5 100644 --- a/auth.py +++ b/auth.py @@ -20,11 +20,29 @@ from pydantic import BaseModel pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # JWT settings -SECRET_KEY = os.environ.get("JWT_SECRET", secrets.token_hex(32)) ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_DAYS = 30 +def load_jwt_secret() -> str: + """Load JWT secret from Docker secret, env var, or generate.""" + # Try Docker secret first + secret_path = Path("/run/secrets/jwt_secret") + if secret_path.exists(): + return secret_path.read_text().strip() + + # Try environment variable + if os.environ.get("JWT_SECRET"): + return os.environ["JWT_SECRET"] + + # Generate one (tokens won't persist across restarts!) + print("WARNING: No JWT_SECRET configured. Tokens will be invalidated on restart.") + return secrets.token_hex(32) + + +SECRET_KEY = load_jwt_secret() + + class User(BaseModel): """A registered user.""" username: str diff --git a/requirements.txt b/requirements.txt index e28ebb1..354f428 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ requests>=2.31.0 cryptography>=42.0.0 passlib[bcrypt]>=1.7.4 python-jose[cryptography]>=3.3.0 +markdown>=3.5.0 +python-multipart>=0.0.6 diff --git a/server.py b/server.py index e826ba0..f96811a 100644 --- a/server.py +++ b/server.py @@ -18,11 +18,12 @@ from pathlib import Path from typing import Optional from urllib.parse import urlparse -from fastapi import FastAPI, HTTPException, Request, Response, Depends -from fastapi.responses import JSONResponse +from fastapi import FastAPI, HTTPException, Request, Response, Depends, Cookie +from fastapi.responses import JSONResponse, HTMLResponse from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel import requests +import markdown from auth import ( UserCreate, UserLogin, Token, User, @@ -40,6 +41,12 @@ L1_SERVER = os.environ.get("ARTDAG_L1", "http://localhost:8100") DATA_DIR.mkdir(parents=True, exist_ok=True) (DATA_DIR / "assets").mkdir(exist_ok=True) +# Load README +README_PATH = Path(__file__).parent / "README.md" +README_CONTENT = "" +if README_PATH.exists(): + README_CONTENT = README_PATH.read_text() + app = FastAPI( title="Art DAG L2 Server", description="ActivityPub server for Art DAG ownership and federation", @@ -184,11 +191,482 @@ def sign_activity(activity: dict) -> dict: return activity -# ============ ActivityPub Endpoints ============ +# ============ HTML Templates ============ + +def base_html(title: str, content: str, username: str = None) -> str: + """Base HTML template.""" + user_section = f''' +
+ ''' if username else ''' + + ''' + + return f''' + + + + +Don't have an account? Register
+ ''' + return HTMLResponse(base_html("Login", content)) + + +@app.post("/ui/login", response_class=HTMLResponse) +async def ui_login_submit(request: Request): + """Handle login form submission.""" + form = await request.form() + username = form.get("username", "").strip() + password = form.get("password", "") + + if not username or not password: + return HTMLResponse('Already have an account? Login
+ ''' + return HTMLResponse(base_html("Register", content)) + + +@app.post("/ui/register", response_class=HTMLResponse) +async def ui_register_submit(request: Request): + """Handle register form submission.""" + form = await request.form() + username = form.get("username", "").strip() + email = form.get("email", "").strip() or None + password = form.get("password", "") + password2 = form.get("password2", "") + + if not username or not password: + return HTMLResponse('No assets registered yet.
' + else: + rows = "" + for name, asset in sorted(assets.items(), key=lambda x: x[1].get("created_at", ""), reverse=True): + hash_short = asset.get("content_hash", "")[:16] + "..." + rows += f''' +{hash_short}| Name | +Type | +Content Hash | +Tags | +
|---|
No activities yet.
' + else: + rows = "" + for activity in reversed(activities): + obj = activity.get("object_data", {}) + rows += f''' +{obj.get("contentHash", {}).get("value", "")[:16]}...| Type | +Object | +Content Hash | +Published | +
|---|