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''' +
+ Logged in as {username} + +
+ ''' if username else ''' + + ''' + + return f''' + + + + + {title} - Art DAG L2 + + + + +
+

Art DAG L2

+ {user_section} +
+ +
+ {content} +
+ +''' + + +def get_user_from_cookie(request: Request) -> Optional[str]: + """Get username from auth cookie.""" + token = request.cookies.get("auth_token") + if token: + return verify_token(token) + return None + + +# ============ UI Endpoints ============ + +@app.get("/ui", response_class=HTMLResponse) +async def ui_home(request: Request): + """Home page with README and stats.""" + username = get_user_from_cookie(request) + registry = load_registry() + activities = load_activities() + users = load_users(DATA_DIR) + + readme_html = markdown.markdown(README_CONTENT, extensions=['tables', 'fenced_code']) + + content = f''' +
+
+
{len(registry.get("assets", {}))}
+
Assets
+
+
+
{len(activities)}
+
Activities
+
+
+
{len(users)}
+
Users
+
+
+
+ {readme_html} +
+ ''' + return HTMLResponse(base_html("Home", content, username)) + + +@app.get("/ui/login", response_class=HTMLResponse) +async def ui_login_page(request: Request): + """Login page.""" + username = get_user_from_cookie(request) + if username: + return HTMLResponse(base_html("Already Logged In", f''' +
You are already logged in as {username}
+

Go to home page

+ ''', username)) + + content = ''' +

Login

+
+
+
+ + +
+
+ + +
+ +
+

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('
Username and password are required
') + + user = authenticate_user(DATA_DIR, username, password) + if not user: + return HTMLResponse('
Invalid username or password
') + + token = create_access_token(user.username) + + response = HTMLResponse(f''' +
Login successful! Redirecting...
+ + ''') + response.set_cookie( + key="auth_token", + value=token.access_token, + httponly=True, + max_age=60 * 60 * 24 * 30, # 30 days + samesite="lax" + ) + return response + + +@app.get("/ui/register", response_class=HTMLResponse) +async def ui_register_page(request: Request): + """Register page.""" + username = get_user_from_cookie(request) + if username: + return HTMLResponse(base_html("Already Logged In", f''' +
You are already logged in as {username}
+

Go to home page

+ ''', username)) + + content = ''' +

Register

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+

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('
Username and password are required
') + + if password != password2: + return HTMLResponse('
Passwords do not match
') + + if len(password) < 6: + return HTMLResponse('
Password must be at least 6 characters
') + + try: + user = create_user(DATA_DIR, username, password, email) + except ValueError as e: + return HTMLResponse(f'
{str(e)}
') + + token = create_access_token(user.username) + + response = HTMLResponse(f''' +
Registration successful! Redirecting...
+ + ''') + response.set_cookie( + key="auth_token", + value=token.access_token, + httponly=True, + max_age=60 * 60 * 24 * 30, # 30 days + samesite="lax" + ) + return response + + +@app.post("/ui/logout", response_class=HTMLResponse) +async def ui_logout(): + """Handle logout.""" + response = HTMLResponse(''' + + ''') + response.delete_cookie("auth_token") + return response + + +@app.get("/ui/registry", response_class=HTMLResponse) +async def ui_registry_page(request: Request): + """Registry page showing all assets.""" + username = get_user_from_cookie(request) + registry = load_registry() + assets = registry.get("assets", {}) + + if not assets: + content = '

Registry

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''' + + {name} + {asset.get("asset_type", "")} + {hash_short} + {", ".join(asset.get("tags", []))} + + ''' + content = f''' +

Registry ({len(assets)} assets)

+ + + + + + + + + + + {rows} + +
NameTypeContent HashTags
+ ''' + return HTMLResponse(base_html("Registry", content, username)) + + +@app.get("/ui/activities", response_class=HTMLResponse) +async def ui_activities_page(request: Request): + """Activities page showing all signed activities.""" + username = get_user_from_cookie(request) + activities = load_activities() + + if not activities: + content = '

Activities

No activities yet.

' + else: + rows = "" + for activity in reversed(activities): + obj = activity.get("object_data", {}) + rows += f''' + + {activity.get("activity_type", "")} + {obj.get("name", "")} + {obj.get("contentHash", {}).get("value", "")[:16]}... + {activity.get("published", "")[:10]} + + ''' + content = f''' +

Activities ({len(activities)} total)

+ + + + + + + + + + + {rows} + +
TypeObjectContent HashPublished
+ ''' + return HTMLResponse(base_html("Activities", content, username)) + + +# ============ API Endpoints ============ @app.get("/") -async def root(): - """Server info.""" +async def root(request: Request): + """Server info or redirect to UI.""" + # If browser, redirect to UI + accept = request.headers.get("accept", "") + if "text/html" in accept and "application/json" not in accept: + return HTMLResponse(status_code=302, headers={"Location": "/ui"}) + registry = load_registry() activities = load_activities() users = load_users(DATA_DIR)