feat: Add Auth Service with JWT authentication
This commit is contained in:
20
services/auth-service/Dockerfile
Normal file
20
services/auth-service/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 7020
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import httpx; httpx.get('http://localhost:7020/healthz').raise_for_status()"
|
||||
|
||||
# Run
|
||||
CMD ["python", "main.py"]
|
||||
220
services/auth-service/README.md
Normal file
220
services/auth-service/README.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Auth Service
|
||||
|
||||
**Port:** 7011
|
||||
**Purpose:** Identity & session management for DAARION
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Session Management:**
|
||||
- Login with email (Phase 4: mock users)
|
||||
- Session tokens (7-day expiry)
|
||||
- Logout
|
||||
|
||||
✅ **API Keys:**
|
||||
- Create API keys for programmatic access
|
||||
- List/delete keys
|
||||
- Optional expiration
|
||||
|
||||
✅ **Actor Context:**
|
||||
- Unified ActorIdentity model
|
||||
- Supports: human, agent, service actors
|
||||
- MicroDAO membership + roles
|
||||
|
||||
## Actor Model
|
||||
|
||||
### ActorIdentity
|
||||
```json
|
||||
{
|
||||
"actor_id": "user:93",
|
||||
"actor_type": "human",
|
||||
"microdao_ids": ["microdao:daarion", "microdao:7"],
|
||||
"roles": ["member", "microdao_owner"]
|
||||
}
|
||||
```
|
||||
|
||||
**Actor Types:**
|
||||
- `human` — Real users
|
||||
- `agent` — AI agents
|
||||
- `service` — Internal services (llm-proxy, etc.)
|
||||
|
||||
**Roles:**
|
||||
- `system_admin` — Full system access
|
||||
- `microdao_owner` — Owner of a microDAO
|
||||
- `admin` — Admin in a microDAO
|
||||
- `member` — Regular member
|
||||
- `agent` — Agent role
|
||||
|
||||
## API
|
||||
|
||||
### POST /auth/login
|
||||
```bash
|
||||
curl -X POST http://localhost:7011/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@daarion.city",
|
||||
"password": "any"
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"session_token": "...",
|
||||
"actor": {
|
||||
"actor_id": "user:93",
|
||||
"actor_type": "human",
|
||||
"microdao_ids": ["microdao:daarion"],
|
||||
"roles": ["member"]
|
||||
},
|
||||
"expires_at": "2025-12-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Mock Users (Phase 4):**
|
||||
- `admin@daarion.city` → system_admin
|
||||
- `user@daarion.city` → regular user
|
||||
- `sofia@agents.daarion.city` → agent
|
||||
|
||||
### GET /auth/me
|
||||
Get current actor:
|
||||
```bash
|
||||
curl http://localhost:7011/auth/me \
|
||||
-H "Authorization: Bearer <session_token>"
|
||||
```
|
||||
|
||||
### POST /auth/logout
|
||||
```bash
|
||||
curl -X POST http://localhost:7011/auth/logout \
|
||||
-H "Authorization: Bearer <session_token>"
|
||||
```
|
||||
|
||||
### POST /auth/api-keys
|
||||
Create API key:
|
||||
```bash
|
||||
curl -X POST http://localhost:7011/auth/api-keys \
|
||||
-H "Authorization: Bearer <session_token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"description": "My API key",
|
||||
"expires_days": 30
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "key-123",
|
||||
"key": "dk_abc123...",
|
||||
"actor_id": "user:93",
|
||||
"description": "My API key",
|
||||
"created_at": "...",
|
||||
"expires_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **Key shown only once!**
|
||||
|
||||
### GET /auth/api-keys
|
||||
List keys:
|
||||
```bash
|
||||
curl http://localhost:7011/auth/api-keys \
|
||||
-H "Authorization: Bearer <session_token>"
|
||||
```
|
||||
|
||||
### DELETE /auth/api-keys/{key_id}
|
||||
```bash
|
||||
curl -X DELETE http://localhost:7011/auth/api-keys/key-123 \
|
||||
-H "Authorization: Bearer <session_token>"
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
### In Other Services
|
||||
|
||||
```python
|
||||
from actor_context import require_actor
|
||||
from models import ActorIdentity
|
||||
|
||||
@app.get("/protected")
|
||||
async def protected_route(
|
||||
actor: ActorIdentity = Depends(require_actor)
|
||||
):
|
||||
# actor.actor_id, actor.roles, etc.
|
||||
...
|
||||
```
|
||||
|
||||
### Authentication Priority
|
||||
|
||||
1. **X-API-Key header** (for services)
|
||||
2. **Authorization: Bearer <token>** (for API clients)
|
||||
3. **session_token cookie** (for web UI)
|
||||
|
||||
## Database Schema
|
||||
|
||||
### sessions
|
||||
```sql
|
||||
CREATE TABLE sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
actor_id TEXT NOT NULL,
|
||||
actor_data JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
is_valid BOOLEAN DEFAULT true
|
||||
);
|
||||
```
|
||||
|
||||
### api_keys
|
||||
```sql
|
||||
CREATE TABLE api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
actor_id TEXT NOT NULL,
|
||||
actor_data JSONB NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used TIMESTAMPTZ,
|
||||
is_active BOOLEAN DEFAULT true
|
||||
);
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
cd services/auth-service
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker build -t auth-service .
|
||||
docker run -p 7011:7011 \
|
||||
-e DATABASE_URL="postgresql://..." \
|
||||
auth-service
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 4 (Current):
|
||||
- ✅ Mock login
|
||||
- ✅ Session tokens
|
||||
- ✅ API keys
|
||||
- ✅ ActorContext helper
|
||||
|
||||
### Phase 5:
|
||||
- 🔜 Real Passkey integration
|
||||
- 🔜 OAuth2 providers
|
||||
- 🔜 Multi-factor auth
|
||||
- 🔜 Session refresh tokens
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ Phase 4 Ready
|
||||
**Version:** 1.0.0
|
||||
**Last Updated:** 2025-11-24
|
||||
|
||||
|
||||
|
||||
|
||||
129
services/auth-service/actor_context.py
Normal file
129
services/auth-service/actor_context.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Actor Context Builder
|
||||
|
||||
Extracts ActorIdentity from request (session token or API key)
|
||||
"""
|
||||
import asyncpg
|
||||
from fastapi import Header, HTTPException, Cookie
|
||||
from typing import Optional
|
||||
from models import ActorIdentity, ActorType
|
||||
import json
|
||||
|
||||
async def build_actor_context(
|
||||
db_pool: asyncpg.Pool,
|
||||
authorization: Optional[str] = Header(None),
|
||||
session_token: Optional[str] = Cookie(None),
|
||||
x_api_key: Optional[str] = Header(None, alias="X-API-Key")
|
||||
) -> ActorIdentity:
|
||||
"""
|
||||
Build ActorIdentity from request
|
||||
|
||||
Priority:
|
||||
1. X-API-Key header
|
||||
2. Authorization header (Bearer token)
|
||||
3. session_token cookie
|
||||
|
||||
Raises HTTPException(401) if no valid credentials
|
||||
"""
|
||||
|
||||
# Try API Key first
|
||||
if x_api_key:
|
||||
actor = await get_actor_from_api_key(db_pool, x_api_key)
|
||||
if actor:
|
||||
return actor
|
||||
|
||||
# Try Authorization header
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
token = authorization.replace("Bearer ", "")
|
||||
actor = await get_actor_from_session(db_pool, token)
|
||||
if actor:
|
||||
return actor
|
||||
|
||||
# Try session cookie
|
||||
if session_token:
|
||||
actor = await get_actor_from_session(db_pool, session_token)
|
||||
if actor:
|
||||
return actor
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Unauthorized: No valid session token or API key"
|
||||
)
|
||||
|
||||
async def get_actor_from_session(db_pool: asyncpg.Pool, token: str) -> Optional[ActorIdentity]:
|
||||
"""Get ActorIdentity from session token"""
|
||||
async with db_pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT actor_id, actor_data, expires_at
|
||||
FROM sessions
|
||||
WHERE token = $1 AND is_valid = true
|
||||
""",
|
||||
token
|
||||
)
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
# Check expiration
|
||||
from datetime import datetime, timezone
|
||||
if row['expires_at'] < datetime.now(timezone.utc):
|
||||
# Expired
|
||||
await conn.execute("UPDATE sessions SET is_valid = false WHERE token = $1", token)
|
||||
return None
|
||||
|
||||
# Parse actor data
|
||||
actor_data = row['actor_data']
|
||||
return ActorIdentity(**actor_data)
|
||||
|
||||
async def get_actor_from_api_key(db_pool: asyncpg.Pool, key: str) -> Optional[ActorIdentity]:
|
||||
"""Get ActorIdentity from API key"""
|
||||
async with db_pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT actor_id, actor_data, expires_at, last_used
|
||||
FROM api_keys
|
||||
WHERE key = $1 AND is_active = true
|
||||
""",
|
||||
key
|
||||
)
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
# Check expiration
|
||||
from datetime import datetime, timezone
|
||||
if row['expires_at'] and row['expires_at'] < datetime.now(timezone.utc):
|
||||
# Expired
|
||||
await conn.execute("UPDATE api_keys SET is_active = false WHERE key = $1", key)
|
||||
return None
|
||||
|
||||
# Update last_used
|
||||
await conn.execute(
|
||||
"UPDATE api_keys SET last_used = NOW() WHERE key = $1",
|
||||
key
|
||||
)
|
||||
|
||||
# Parse actor data
|
||||
actor_data = row['actor_data']
|
||||
return ActorIdentity(**actor_data)
|
||||
|
||||
async def require_actor(
|
||||
db_pool: asyncpg.Pool,
|
||||
authorization: Optional[str] = Header(None),
|
||||
session_token: Optional[str] = Cookie(None),
|
||||
x_api_key: Optional[str] = Header(None, alias="X-API-Key")
|
||||
) -> ActorIdentity:
|
||||
"""
|
||||
Dependency for routes that require authentication
|
||||
|
||||
Usage:
|
||||
@app.get("/protected")
|
||||
async def protected_route(actor: ActorIdentity = Depends(require_actor)):
|
||||
...
|
||||
"""
|
||||
return await build_actor_context(db_pool, authorization, session_token, x_api_key)
|
||||
|
||||
|
||||
|
||||
|
||||
35
services/auth-service/config.py
Normal file
35
services/auth-service/config.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Auth Service Configuration
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Service
|
||||
service_name: str = "auth-service"
|
||||
service_version: str = "1.0.0"
|
||||
port: int = 7020
|
||||
debug: bool = False
|
||||
|
||||
# Database
|
||||
database_url: str = "postgresql://postgres:postgres@localhost:5432/daarion"
|
||||
|
||||
# JWT
|
||||
jwt_secret: str = "your-very-long-secret-key-change-in-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_ttl: int = 1800 # 30 minutes
|
||||
refresh_token_ttl: int = 604800 # 7 days
|
||||
|
||||
# Security
|
||||
bcrypt_rounds: int = 12
|
||||
|
||||
class Config:
|
||||
env_prefix = "AUTH_"
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
183
services/auth-service/database.py
Normal file
183
services/auth-service/database.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Database connection and operations for Auth Service
|
||||
"""
|
||||
import asyncpg
|
||||
from typing import Optional, List, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
_pool: Optional[asyncpg.Pool] = None
|
||||
|
||||
|
||||
async def get_pool() -> asyncpg.Pool:
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = await asyncpg.create_pool(
|
||||
settings.database_url,
|
||||
min_size=2,
|
||||
max_size=10
|
||||
)
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_pool():
|
||||
global _pool
|
||||
if _pool:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_connection():
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
# User operations
|
||||
async def create_user(
|
||||
email: str,
|
||||
password_hash: str,
|
||||
display_name: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
async with get_connection() as conn:
|
||||
# Create user
|
||||
user = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO auth_users (email, password_hash, display_name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, display_name, avatar_url, is_active, is_admin, created_at
|
||||
""",
|
||||
email, password_hash, display_name
|
||||
)
|
||||
|
||||
user_id = user['id']
|
||||
|
||||
# Assign default 'user' role
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO auth_user_roles (user_id, role_id)
|
||||
VALUES ($1, 'user')
|
||||
""",
|
||||
user_id
|
||||
)
|
||||
|
||||
return dict(user)
|
||||
|
||||
|
||||
async def get_user_by_email(email: str) -> Optional[Dict[str, Any]]:
|
||||
async with get_connection() as conn:
|
||||
user = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, email, password_hash, display_name, avatar_url,
|
||||
is_active, is_admin, created_at, updated_at
|
||||
FROM auth_users
|
||||
WHERE email = $1
|
||||
""",
|
||||
email
|
||||
)
|
||||
return dict(user) if user else None
|
||||
|
||||
|
||||
async def get_user_by_id(user_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
async with get_connection() as conn:
|
||||
user = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, email, display_name, avatar_url,
|
||||
is_active, is_admin, created_at, updated_at
|
||||
FROM auth_users
|
||||
WHERE id = $1
|
||||
""",
|
||||
user_id
|
||||
)
|
||||
return dict(user) if user else None
|
||||
|
||||
|
||||
async def get_user_roles(user_id: UUID) -> List[str]:
|
||||
async with get_connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT role_id FROM auth_user_roles
|
||||
WHERE user_id = $1
|
||||
""",
|
||||
user_id
|
||||
)
|
||||
return [row['role_id'] for row in rows]
|
||||
|
||||
|
||||
# Session operations
|
||||
async def create_session(
|
||||
user_id: UUID,
|
||||
user_agent: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
ttl_seconds: int = 604800
|
||||
) -> UUID:
|
||||
async with get_connection() as conn:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO auth_sessions (user_id, user_agent, ip_address, expires_at)
|
||||
VALUES ($1, $2, $3::inet, $4)
|
||||
RETURNING id
|
||||
""",
|
||||
user_id, user_agent, ip_address, expires_at
|
||||
)
|
||||
return row['id']
|
||||
|
||||
|
||||
async def get_session(session_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
async with get_connection() as conn:
|
||||
session = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, user_id, expires_at, revoked_at
|
||||
FROM auth_sessions
|
||||
WHERE id = $1
|
||||
""",
|
||||
session_id
|
||||
)
|
||||
return dict(session) if session else None
|
||||
|
||||
|
||||
async def revoke_session(session_id: UUID) -> bool:
|
||||
async with get_connection() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE auth_sessions
|
||||
SET revoked_at = now()
|
||||
WHERE id = $1 AND revoked_at IS NULL
|
||||
""",
|
||||
session_id
|
||||
)
|
||||
return result == "UPDATE 1"
|
||||
|
||||
|
||||
async def is_session_valid(session_id: UUID) -> bool:
|
||||
async with get_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT 1 FROM auth_sessions
|
||||
WHERE id = $1
|
||||
AND revoked_at IS NULL
|
||||
AND expires_at > now()
|
||||
""",
|
||||
session_id
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
async def cleanup_expired_sessions():
|
||||
"""Remove expired sessions (can be run periodically)"""
|
||||
async with get_connection() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM auth_sessions
|
||||
WHERE expires_at < now() - INTERVAL '7 days'
|
||||
"""
|
||||
)
|
||||
|
||||
304
services/auth-service/main.py
Normal file
304
services/auth-service/main.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Auth Service - Main Application
|
||||
DAARION.city Authentication & Authorization
|
||||
"""
|
||||
from fastapi import FastAPI, HTTPException, Depends, Request, Header
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
import logging
|
||||
|
||||
from config import get_settings
|
||||
from models import (
|
||||
RegisterRequest, RegisterResponse,
|
||||
LoginRequest, TokenResponse,
|
||||
RefreshRequest, RefreshResponse,
|
||||
LogoutRequest, StatusResponse,
|
||||
IntrospectRequest, IntrospectResponse,
|
||||
UserResponse, HealthResponse, ErrorResponse
|
||||
)
|
||||
from database import (
|
||||
get_pool, close_pool,
|
||||
create_user, get_user_by_email, get_user_by_id, get_user_roles,
|
||||
create_session, get_session, revoke_session, is_session_valid
|
||||
)
|
||||
from security import (
|
||||
hash_password, verify_password,
|
||||
create_access_token, create_refresh_token,
|
||||
decode_access_token, decode_refresh_token
|
||||
)
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
logger.info(f"Starting {settings.service_name} v{settings.service_version}")
|
||||
await get_pool()
|
||||
yield
|
||||
# Shutdown
|
||||
await close_pool()
|
||||
logger.info("Auth service stopped")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="DAARION Auth Service",
|
||||
description="Authentication & Authorization for DAARION.city",
|
||||
version=settings.service_version,
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # In production, specify exact origins
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Dependency: Get current user from token
|
||||
async def get_current_user(
|
||||
authorization: Optional[str] = Header(None)
|
||||
) -> Optional[dict]:
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
return None
|
||||
|
||||
token = authorization[7:] # Remove "Bearer " prefix
|
||||
payload = decode_access_token(token)
|
||||
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
async def require_auth(
|
||||
authorization: Optional[str] = Header(None)
|
||||
) -> dict:
|
||||
user = await get_current_user(authorization)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
# Health check
|
||||
@app.get("/healthz", response_model=HealthResponse)
|
||||
async def health_check():
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
service=settings.service_name,
|
||||
version=settings.service_version
|
||||
)
|
||||
|
||||
|
||||
# Register
|
||||
@app.post("/api/auth/register", response_model=RegisterResponse, status_code=201)
|
||||
async def register(request: RegisterRequest):
|
||||
# Check if user exists
|
||||
existing = await get_user_by_email(request.email)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# Hash password and create user
|
||||
password_hash = hash_password(request.password)
|
||||
user = await create_user(
|
||||
email=request.email,
|
||||
password_hash=password_hash,
|
||||
display_name=request.display_name
|
||||
)
|
||||
|
||||
logger.info(f"User registered: {request.email}")
|
||||
|
||||
return RegisterResponse(
|
||||
user_id=user['id'],
|
||||
email=user['email'],
|
||||
display_name=user['display_name'],
|
||||
roles=["user"]
|
||||
)
|
||||
|
||||
|
||||
# Login
|
||||
@app.post("/api/auth/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
req: Request
|
||||
):
|
||||
# Get user
|
||||
user = await get_user_by_email(request.email)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
|
||||
# Verify password
|
||||
if not verify_password(request.password, user['password_hash']):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
|
||||
# Check if active
|
||||
if not user['is_active']:
|
||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
||||
|
||||
# Get roles
|
||||
roles = await get_user_roles(user['id'])
|
||||
if user['is_admin'] and 'admin' not in roles:
|
||||
roles.append('admin')
|
||||
|
||||
# Create session
|
||||
user_agent = req.headers.get("user-agent")
|
||||
ip_address = req.client.host if req.client else None
|
||||
session_id = await create_session(
|
||||
user_id=user['id'],
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
ttl_seconds=settings.refresh_token_ttl
|
||||
)
|
||||
|
||||
# Create tokens
|
||||
access_token = create_access_token(
|
||||
user_id=user['id'],
|
||||
email=user['email'],
|
||||
display_name=user['display_name'],
|
||||
roles=roles
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
user_id=user['id'],
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
logger.info(f"User logged in: {request.email}")
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="Bearer",
|
||||
expires_in=settings.access_token_ttl,
|
||||
user=UserResponse(
|
||||
id=user['id'],
|
||||
email=user['email'],
|
||||
display_name=user['display_name'],
|
||||
avatar_url=user['avatar_url'],
|
||||
roles=roles,
|
||||
is_active=user['is_active'],
|
||||
created_at=user['created_at']
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Refresh token
|
||||
@app.post("/api/auth/refresh", response_model=RefreshResponse)
|
||||
async def refresh(request: RefreshRequest):
|
||||
# Decode refresh token
|
||||
payload = decode_refresh_token(request.refresh_token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
|
||||
# Check session
|
||||
session_id = UUID(payload['session_id'])
|
||||
if not await is_session_valid(session_id):
|
||||
raise HTTPException(status_code=401, detail="Session expired or revoked")
|
||||
|
||||
# Get user
|
||||
user_id = UUID(payload['sub'])
|
||||
user = await get_user_by_id(user_id)
|
||||
if not user or not user['is_active']:
|
||||
raise HTTPException(status_code=401, detail="User not found or disabled")
|
||||
|
||||
# Get roles
|
||||
roles = await get_user_roles(user_id)
|
||||
if user['is_admin'] and 'admin' not in roles:
|
||||
roles.append('admin')
|
||||
|
||||
# Revoke old session and create new one
|
||||
await revoke_session(session_id)
|
||||
new_session_id = await create_session(
|
||||
user_id=user_id,
|
||||
ttl_seconds=settings.refresh_token_ttl
|
||||
)
|
||||
|
||||
# Create new tokens
|
||||
access_token = create_access_token(
|
||||
user_id=user_id,
|
||||
email=user['email'],
|
||||
display_name=user['display_name'],
|
||||
roles=roles
|
||||
)
|
||||
new_refresh_token = create_refresh_token(
|
||||
user_id=user_id,
|
||||
session_id=new_session_id
|
||||
)
|
||||
|
||||
return RefreshResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
token_type="Bearer",
|
||||
expires_in=settings.access_token_ttl
|
||||
)
|
||||
|
||||
|
||||
# Logout
|
||||
@app.post("/api/auth/logout", response_model=StatusResponse)
|
||||
async def logout(request: LogoutRequest):
|
||||
# Decode refresh token
|
||||
payload = decode_refresh_token(request.refresh_token)
|
||||
if payload:
|
||||
session_id = UUID(payload['session_id'])
|
||||
await revoke_session(session_id)
|
||||
|
||||
return StatusResponse(status="ok")
|
||||
|
||||
|
||||
# Get current user
|
||||
@app.get("/api/auth/me", response_model=UserResponse)
|
||||
async def get_me(current_user: dict = Depends(require_auth)):
|
||||
user_id = UUID(current_user['sub'])
|
||||
user = await get_user_by_id(user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
roles = await get_user_roles(user_id)
|
||||
if user['is_admin'] and 'admin' not in roles:
|
||||
roles.append('admin')
|
||||
|
||||
return UserResponse(
|
||||
id=user['id'],
|
||||
email=user['email'],
|
||||
display_name=user['display_name'],
|
||||
avatar_url=user['avatar_url'],
|
||||
roles=roles,
|
||||
is_active=user['is_active'],
|
||||
created_at=user['created_at']
|
||||
)
|
||||
|
||||
|
||||
# Introspect token (for other services)
|
||||
@app.post("/api/auth/introspect", response_model=IntrospectResponse)
|
||||
async def introspect(request: IntrospectRequest):
|
||||
payload = decode_access_token(request.token)
|
||||
|
||||
if not payload:
|
||||
return IntrospectResponse(active=False)
|
||||
|
||||
return IntrospectResponse(
|
||||
active=True,
|
||||
sub=payload.get('sub'),
|
||||
email=payload.get('email'),
|
||||
roles=payload.get('roles', []),
|
||||
exp=payload.get('exp')
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=settings.port,
|
||||
reload=settings.debug
|
||||
)
|
||||
86
services/auth-service/models.py
Normal file
86
services/auth-service/models.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Auth Service Data Models
|
||||
"""
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
# Request Models
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
display_name: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class IntrospectRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
# Response Models
|
||||
class UserResponse(BaseModel):
|
||||
id: UUID
|
||||
email: str
|
||||
display_name: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
roles: List[str] = []
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
user_id: UUID
|
||||
email: str
|
||||
display_name: Optional[str] = None
|
||||
roles: List[str] = ["user"]
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class RefreshResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class IntrospectResponse(BaseModel):
|
||||
active: bool
|
||||
sub: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
roles: Optional[List[str]] = None
|
||||
exp: Optional[int] = None
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
service: str
|
||||
version: str
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
230
services/auth-service/passkey_store.py
Normal file
230
services/auth-service/passkey_store.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Passkey Store - Database operations for WebAuthn credentials
|
||||
"""
|
||||
import asyncpg
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
import base64
|
||||
|
||||
class PasskeyStore:
|
||||
"""Database layer for passkey operations"""
|
||||
|
||||
def __init__(self, db_pool: asyncpg.Pool):
|
||||
self.db_pool = db_pool
|
||||
|
||||
# ========================================================================
|
||||
# User Operations
|
||||
# ========================================================================
|
||||
|
||||
async def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get user by email"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM users WHERE email = $1",
|
||||
email
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get user by ID"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM users WHERE id = $1::uuid",
|
||||
user_id
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
email: str,
|
||||
username: str,
|
||||
display_name: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Create new user"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
INSERT INTO users (email, username, display_name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
""", email, username, display_name)
|
||||
return dict(row)
|
||||
|
||||
async def update_last_login(self, user_id: str):
|
||||
"""Update user's last login timestamp"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE users SET last_login_at = now() WHERE id = $1::uuid",
|
||||
user_id
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Passkey Operations
|
||||
# ========================================================================
|
||||
|
||||
async def create_passkey(
|
||||
self,
|
||||
user_id: str,
|
||||
credential_id: str,
|
||||
public_key: str,
|
||||
sign_count: int = 0,
|
||||
device_name: Optional[str] = None,
|
||||
transports: Optional[List[str]] = None,
|
||||
aaguid: Optional[str] = None,
|
||||
attestation_format: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Store new passkey credential"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
INSERT INTO passkeys
|
||||
(user_id, credential_id, public_key, sign_count, device_name, transports, aaguid, attestation_format)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *
|
||||
""",
|
||||
user_id, credential_id, public_key, sign_count,
|
||||
device_name, transports, aaguid, attestation_format
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
async def get_passkeys_by_user_id(self, user_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all passkeys for a user"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM passkeys WHERE user_id = $1::uuid ORDER BY created_at DESC",
|
||||
user_id
|
||||
)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
async def get_passkey_by_credential_id(self, credential_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get passkey by credential ID"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM passkeys WHERE credential_id = $1",
|
||||
credential_id
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def update_sign_count(self, credential_id: str, new_sign_count: int):
|
||||
"""Update passkey sign count and last used timestamp"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
UPDATE passkeys
|
||||
SET sign_count = $2, last_used_at = now()
|
||||
WHERE credential_id = $1
|
||||
""", credential_id, new_sign_count)
|
||||
|
||||
# ========================================================================
|
||||
# Challenge Operations
|
||||
# ========================================================================
|
||||
|
||||
async def store_challenge(
|
||||
self,
|
||||
challenge: str,
|
||||
challenge_type: str,
|
||||
user_id: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
expires_in_seconds: int = 300 # 5 minutes
|
||||
):
|
||||
"""Store challenge for verification"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
INSERT INTO passkey_challenges
|
||||
(challenge, user_id, email, challenge_type, rp_id, origin, expires_at)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $6, now() + interval '%s seconds')
|
||||
""" % expires_in_seconds,
|
||||
challenge, user_id, email, challenge_type,
|
||||
"localhost", "http://localhost:3000"
|
||||
)
|
||||
|
||||
async def verify_challenge(
|
||||
self,
|
||||
challenge: str,
|
||||
challenge_type: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Verify and consume challenge"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
# Get challenge
|
||||
row = await conn.fetchrow("""
|
||||
SELECT * FROM passkey_challenges
|
||||
WHERE challenge = $1
|
||||
AND challenge_type = $2
|
||||
AND expires_at > now()
|
||||
""", challenge, challenge_type)
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
# Delete challenge (one-time use)
|
||||
await conn.execute(
|
||||
"DELETE FROM passkey_challenges WHERE challenge = $1",
|
||||
challenge
|
||||
)
|
||||
|
||||
return dict(row)
|
||||
|
||||
async def cleanup_expired_challenges(self):
|
||||
"""Remove expired challenges"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"DELETE FROM passkey_challenges WHERE expires_at < now()"
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Session Operations
|
||||
# ========================================================================
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
token: str,
|
||||
user_id: str,
|
||||
expires_in_days: int = 30
|
||||
) -> Dict[str, Any]:
|
||||
"""Create new session"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
INSERT INTO sessions (token, user_id, expires_at)
|
||||
VALUES ($1, $2::uuid, now() + interval '%s days')
|
||||
RETURNING *
|
||||
""" % expires_in_days, token, user_id)
|
||||
return dict(row)
|
||||
|
||||
async def get_session(self, token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get session by token"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT * FROM sessions
|
||||
WHERE token = $1 AND expires_at > now()
|
||||
""", token)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def delete_session(self, token: str):
|
||||
"""Delete session"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"DELETE FROM sessions WHERE token = $1",
|
||||
token
|
||||
)
|
||||
|
||||
async def cleanup_expired_sessions(self):
|
||||
"""Remove expired sessions"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"DELETE FROM sessions WHERE expires_at < now()"
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# MicroDAO Memberships (for ActorIdentity)
|
||||
# ========================================================================
|
||||
|
||||
async def get_user_microdao_memberships(self, user_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all microDAO memberships for a user"""
|
||||
async with self.db_pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT * FROM user_microdao_memberships
|
||||
WHERE user_id = $1::uuid AND left_at IS NULL
|
||||
ORDER BY joined_at DESC
|
||||
""", user_id)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
|
||||
|
||||
11
services/auth-service/requirements.txt
Normal file
11
services/auth-service/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
asyncpg==0.29.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.1.2
|
||||
python-multipart==0.0.6
|
||||
email-validator==2.1.0
|
||||
httpx==0.26.0
|
||||
127
services/auth-service/routes_api_keys.py
Normal file
127
services/auth-service/routes_api_keys.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
API Key management routes
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
import asyncpg
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import secrets
|
||||
from models import ApiKeyCreateRequest, ApiKey, ApiKeyResponse, ActorIdentity
|
||||
from actor_context import require_actor
|
||||
import json
|
||||
|
||||
router = APIRouter(prefix="/auth/api-keys", tags=["api_keys"])
|
||||
|
||||
def get_db_pool(request) -> asyncpg.Pool:
|
||||
"""Get database pool from app state"""
|
||||
return request.app.state.db_pool
|
||||
|
||||
@router.post("", response_model=ApiKey)
|
||||
async def create_api_key(
|
||||
request: ApiKeyCreateRequest,
|
||||
actor: ActorIdentity = Depends(require_actor),
|
||||
db_pool: asyncpg.Pool = Depends(get_db_pool)
|
||||
):
|
||||
"""
|
||||
Create new API key for current actor
|
||||
|
||||
Returns full key only once (on creation)
|
||||
"""
|
||||
|
||||
# Generate key
|
||||
key = f"dk_{secrets.token_urlsafe(32)}"
|
||||
key_id = secrets.token_urlsafe(16)
|
||||
|
||||
# Calculate expiration
|
||||
expires_at = None
|
||||
if request.expires_days:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=request.expires_days)
|
||||
|
||||
# Store in database
|
||||
async with db_pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO api_keys (id, key, actor_id, actor_data, description, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
""",
|
||||
key_id,
|
||||
key,
|
||||
actor.actor_id,
|
||||
json.dumps(actor.model_dump()),
|
||||
request.description,
|
||||
expires_at
|
||||
)
|
||||
|
||||
return ApiKey(
|
||||
id=key_id,
|
||||
key=key, # Full key shown only once
|
||||
actor_id=actor.actor_id,
|
||||
actor=actor,
|
||||
description=request.description,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
expires_at=expires_at,
|
||||
last_used=None,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
@router.get("", response_model=list[ApiKeyResponse])
|
||||
async def list_api_keys(
|
||||
actor: ActorIdentity = Depends(require_actor),
|
||||
db_pool: asyncpg.Pool = Depends(get_db_pool)
|
||||
):
|
||||
"""List all API keys for current actor"""
|
||||
|
||||
async with db_pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, key, description, created_at, expires_at, last_used, is_active
|
||||
FROM api_keys
|
||||
WHERE actor_id = $1
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
actor.actor_id
|
||||
)
|
||||
|
||||
keys = []
|
||||
for row in rows:
|
||||
# Show only preview of key
|
||||
key_preview = row['key'][:8] + "..." if len(row['key']) > 8 else row['key']
|
||||
|
||||
keys.append(ApiKeyResponse(
|
||||
id=row['id'],
|
||||
key_preview=key_preview,
|
||||
description=row['description'],
|
||||
created_at=row['created_at'],
|
||||
expires_at=row['expires_at'],
|
||||
last_used=row['last_used'],
|
||||
is_active=row['is_active']
|
||||
))
|
||||
|
||||
return keys
|
||||
|
||||
@router.delete("/{key_id}")
|
||||
async def delete_api_key(
|
||||
key_id: str,
|
||||
actor: ActorIdentity = Depends(require_actor),
|
||||
db_pool: asyncpg.Pool = Depends(get_db_pool)
|
||||
):
|
||||
"""Delete (deactivate) API key"""
|
||||
|
||||
async with db_pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE api_keys
|
||||
SET is_active = false
|
||||
WHERE id = $1 AND actor_id = $2
|
||||
""",
|
||||
key_id,
|
||||
actor.actor_id
|
||||
)
|
||||
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "API key not found")
|
||||
|
||||
return {"status": "deleted", "key_id": key_id}
|
||||
|
||||
|
||||
|
||||
|
||||
329
services/auth-service/routes_passkey.py
Normal file
329
services/auth-service/routes_passkey.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Passkey Routes (WebAuthn)
|
||||
4 endpoints: register/start, register/finish, authenticate/start, authenticate/finish
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import base64
|
||||
import json
|
||||
|
||||
from passkey_store import PasskeyStore
|
||||
from webauthn_utils import webauthn_manager, generate_session_token
|
||||
from models import ActorIdentity, ActorType
|
||||
|
||||
router = APIRouter(prefix="/auth/passkey", tags=["passkey"])
|
||||
|
||||
# Global store (injected at startup)
|
||||
passkey_store: Optional[PasskeyStore] = None
|
||||
|
||||
def get_store() -> PasskeyStore:
|
||||
if not passkey_store:
|
||||
raise HTTPException(500, "Passkey store not initialized")
|
||||
return passkey_store
|
||||
|
||||
# ============================================================================
|
||||
# Request/Response Models
|
||||
# ============================================================================
|
||||
|
||||
class RegistrationStartRequest(BaseModel):
|
||||
email: str = Field(..., min_length=3, max_length=255)
|
||||
username: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
|
||||
class RegistrationStartResponse(BaseModel):
|
||||
options: Dict[str, Any]
|
||||
challenge: str
|
||||
|
||||
class RegistrationFinishRequest(BaseModel):
|
||||
email: str
|
||||
credential: Dict[str, Any] # WebAuthn credential response
|
||||
|
||||
class RegistrationFinishResponse(BaseModel):
|
||||
success: bool
|
||||
user_id: str
|
||||
message: str
|
||||
|
||||
class AuthenticationStartRequest(BaseModel):
|
||||
email: Optional[str] = None # Optional for resident key
|
||||
|
||||
class AuthenticationStartResponse(BaseModel):
|
||||
options: Dict[str, Any]
|
||||
challenge: str
|
||||
|
||||
class AuthenticationFinishRequest(BaseModel):
|
||||
credential: Dict[str, Any] # WebAuthn assertion response
|
||||
|
||||
class AuthenticationFinishResponse(BaseModel):
|
||||
session_token: str
|
||||
actor: ActorIdentity
|
||||
|
||||
# ============================================================================
|
||||
# REGISTRATION FLOW
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/register/start", response_model=RegistrationStartResponse)
|
||||
async def register_start(
|
||||
request: RegistrationStartRequest,
|
||||
store: PasskeyStore = Depends(get_store)
|
||||
):
|
||||
"""
|
||||
Step 1 of registration: Generate WebAuthn challenge
|
||||
|
||||
Creates or finds user, generates registration options
|
||||
"""
|
||||
|
||||
# Check if user already exists
|
||||
user = await store.get_user_by_email(request.email)
|
||||
|
||||
if not user:
|
||||
# Create new user
|
||||
username = request.username or request.email.split('@')[0]
|
||||
display_name = request.display_name or username
|
||||
|
||||
user = await store.create_user(
|
||||
email=request.email,
|
||||
username=username,
|
||||
display_name=display_name
|
||||
)
|
||||
print(f"✅ Created new user: {user['id']}")
|
||||
else:
|
||||
print(f"✅ Found existing user: {user['id']}")
|
||||
|
||||
# Generate registration options
|
||||
result = webauthn_manager.generate_registration_challenge(
|
||||
user_id=str(user['id']),
|
||||
username=user['username'],
|
||||
display_name=user['display_name']
|
||||
)
|
||||
|
||||
# Store challenge
|
||||
await store.store_challenge(
|
||||
challenge=result['challenge'],
|
||||
challenge_type='register',
|
||||
user_id=str(user['id']),
|
||||
email=request.email
|
||||
)
|
||||
|
||||
print(f"✅ Generated registration challenge for {request.email}")
|
||||
|
||||
return RegistrationStartResponse(
|
||||
options=result['options'],
|
||||
challenge=result['challenge']
|
||||
)
|
||||
|
||||
@router.post("/register/finish", response_model=RegistrationFinishResponse)
|
||||
async def register_finish(
|
||||
request: RegistrationFinishRequest,
|
||||
store: PasskeyStore = Depends(get_store)
|
||||
):
|
||||
"""
|
||||
Step 2 of registration: Verify attestation and store credential
|
||||
|
||||
Validates WebAuthn response, stores public key
|
||||
"""
|
||||
|
||||
# Get user
|
||||
user = await store.get_user_by_email(request.email)
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
|
||||
# Extract challenge from credential
|
||||
client_data_json = base64.urlsafe_b64decode(
|
||||
request.credential['response']['clientDataJSON'] + "=="
|
||||
)
|
||||
client_data = json.loads(client_data_json)
|
||||
challenge_b64 = client_data['challenge']
|
||||
|
||||
# Verify challenge
|
||||
challenge_record = await store.verify_challenge(
|
||||
challenge=challenge_b64,
|
||||
challenge_type='register'
|
||||
)
|
||||
|
||||
if not challenge_record:
|
||||
raise HTTPException(400, "Invalid or expired challenge")
|
||||
|
||||
# Verify registration
|
||||
expected_challenge = base64.urlsafe_b64decode(challenge_b64 + "==")
|
||||
|
||||
verification = webauthn_manager.verify_registration(
|
||||
credential=request.credential,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_origin=webauthn_manager.origin,
|
||||
expected_rp_id=webauthn_manager.rp_id
|
||||
)
|
||||
|
||||
if not verification['verified']:
|
||||
raise HTTPException(400, f"Registration verification failed: {verification.get('error')}")
|
||||
|
||||
# Store passkey
|
||||
await store.create_passkey(
|
||||
user_id=str(user['id']),
|
||||
credential_id=verification['credential_id'],
|
||||
public_key=verification['public_key'],
|
||||
sign_count=verification['sign_count'],
|
||||
aaguid=verification['aaguid'],
|
||||
attestation_format=verification['attestation_format']
|
||||
)
|
||||
|
||||
print(f"✅ Registered passkey for user {user['id']}")
|
||||
|
||||
return RegistrationFinishResponse(
|
||||
success=True,
|
||||
user_id=str(user['id']),
|
||||
message="Passkey registered successfully"
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# AUTHENTICATION FLOW
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/authenticate/start", response_model=AuthenticationStartResponse)
|
||||
async def authenticate_start(
|
||||
request: AuthenticationStartRequest,
|
||||
store: PasskeyStore = Depends(get_store)
|
||||
):
|
||||
"""
|
||||
Step 1 of authentication: Generate WebAuthn challenge
|
||||
|
||||
Finds user's passkeys, generates authentication options
|
||||
"""
|
||||
|
||||
credentials = []
|
||||
user_id = None
|
||||
|
||||
if request.email:
|
||||
# Email-based authentication
|
||||
user = await store.get_user_by_email(request.email)
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
|
||||
user_id = str(user['id'])
|
||||
|
||||
# Get user's passkeys
|
||||
passkeys = await store.get_passkeys_by_user_id(user_id)
|
||||
credentials = [
|
||||
{
|
||||
"credential_id": pk['credential_id'],
|
||||
"transports": pk.get('transports', [])
|
||||
}
|
||||
for pk in passkeys
|
||||
]
|
||||
|
||||
if not credentials:
|
||||
raise HTTPException(404, "No passkeys found for this user")
|
||||
else:
|
||||
# Resident key authentication (discoverable credential)
|
||||
# Allow any passkey
|
||||
pass
|
||||
|
||||
# Generate authentication options
|
||||
result = webauthn_manager.generate_authentication_challenge(credentials)
|
||||
|
||||
# Store challenge
|
||||
await store.store_challenge(
|
||||
challenge=result['challenge'],
|
||||
challenge_type='authenticate',
|
||||
user_id=user_id,
|
||||
email=request.email
|
||||
)
|
||||
|
||||
print(f"✅ Generated authentication challenge")
|
||||
|
||||
return AuthenticationStartResponse(
|
||||
options=result['options'],
|
||||
challenge=result['challenge']
|
||||
)
|
||||
|
||||
@router.post("/authenticate/finish", response_model=AuthenticationFinishResponse)
|
||||
async def authenticate_finish(
|
||||
request: AuthenticationFinishRequest,
|
||||
store: PasskeyStore = Depends(get_store)
|
||||
):
|
||||
"""
|
||||
Step 2 of authentication: Verify assertion and create session
|
||||
|
||||
Validates WebAuthn response, returns session token
|
||||
"""
|
||||
|
||||
# Extract credential ID and challenge
|
||||
credential_id_b64 = request.credential['id']
|
||||
|
||||
client_data_json = base64.urlsafe_b64decode(
|
||||
request.credential['response']['clientDataJSON'] + "=="
|
||||
)
|
||||
client_data = json.loads(client_data_json)
|
||||
challenge_b64 = client_data['challenge']
|
||||
|
||||
# Verify challenge
|
||||
challenge_record = await store.verify_challenge(
|
||||
challenge=challenge_b64,
|
||||
challenge_type='authenticate'
|
||||
)
|
||||
|
||||
if not challenge_record:
|
||||
raise HTTPException(400, "Invalid or expired challenge")
|
||||
|
||||
# Get passkey
|
||||
passkey = await store.get_passkey_by_credential_id(credential_id_b64)
|
||||
if not passkey:
|
||||
raise HTTPException(404, "Passkey not found")
|
||||
|
||||
# Verify authentication
|
||||
expected_challenge = base64.urlsafe_b64decode(challenge_b64 + "==")
|
||||
public_key_bytes = base64.urlsafe_b64decode(passkey['public_key'] + "==")
|
||||
|
||||
verification = webauthn_manager.verify_authentication(
|
||||
credential=request.credential,
|
||||
expected_challenge=expected_challenge,
|
||||
credential_public_key=public_key_bytes,
|
||||
credential_current_sign_count=passkey['sign_count'],
|
||||
expected_origin=webauthn_manager.origin,
|
||||
expected_rp_id=webauthn_manager.rp_id
|
||||
)
|
||||
|
||||
if not verification['verified']:
|
||||
raise HTTPException(400, f"Authentication verification failed: {verification.get('error')}")
|
||||
|
||||
# Update sign count
|
||||
await store.update_sign_count(
|
||||
credential_id=credential_id_b64,
|
||||
new_sign_count=verification['new_sign_count']
|
||||
)
|
||||
|
||||
# Get user
|
||||
user = await store.get_user_by_id(str(passkey['user_id']))
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
|
||||
# Update last login
|
||||
await store.update_last_login(str(user['id']))
|
||||
|
||||
# Create session
|
||||
session_token = generate_session_token()
|
||||
await store.create_session(
|
||||
token=session_token,
|
||||
user_id=str(user['id'])
|
||||
)
|
||||
|
||||
# Build ActorIdentity
|
||||
memberships = await store.get_user_microdao_memberships(str(user['id']))
|
||||
|
||||
actor = ActorIdentity(
|
||||
actor_id=f"user:{user['id']}",
|
||||
actor_type=ActorType.HUMAN,
|
||||
microdao_ids=[m['microdao_id'] for m in memberships],
|
||||
roles=[m['role'] for m in memberships]
|
||||
)
|
||||
|
||||
print(f"✅ Authenticated user {user['id']}")
|
||||
|
||||
return AuthenticationFinishResponse(
|
||||
session_token=session_token,
|
||||
actor=actor
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
129
services/auth-service/routes_sessions.py
Normal file
129
services/auth-service/routes_sessions.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Session management routes
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, Response
|
||||
import asyncpg
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import secrets
|
||||
from models import LoginRequest, LoginResponse, ActorIdentity, ActorType
|
||||
from actor_context import require_actor
|
||||
import json
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["sessions"])
|
||||
|
||||
# Mock users for Phase 4
|
||||
# In production, this would be in database with proper password hashing
|
||||
MOCK_USERS = {
|
||||
"admin@daarion.city": {
|
||||
"actor_id": "user:1",
|
||||
"actor_type": "human",
|
||||
"microdao_ids": ["microdao:daarion"],
|
||||
"roles": ["system_admin", "microdao_owner"]
|
||||
},
|
||||
"user@daarion.city": {
|
||||
"actor_id": "user:93",
|
||||
"actor_type": "human",
|
||||
"microdao_ids": ["microdao:daarion", "microdao:7"],
|
||||
"roles": ["member", "microdao_owner"]
|
||||
},
|
||||
"sofia@agents.daarion.city": {
|
||||
"actor_id": "agent:sofia",
|
||||
"actor_type": "agent",
|
||||
"microdao_ids": ["microdao:daarion"],
|
||||
"roles": ["agent"]
|
||||
}
|
||||
}
|
||||
|
||||
def get_db_pool(request) -> asyncpg.Pool:
|
||||
"""Get database pool from app state"""
|
||||
return request.app.state.db_pool
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
response: Response,
|
||||
db_pool: asyncpg.Pool = Depends(get_db_pool)
|
||||
):
|
||||
"""
|
||||
Login and get session token
|
||||
|
||||
Phase 4: Mock implementation with predefined users
|
||||
Phase 5: Real Passkey integration
|
||||
"""
|
||||
|
||||
# Check mock users
|
||||
if request.email not in MOCK_USERS:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
|
||||
user_data = MOCK_USERS[request.email]
|
||||
|
||||
# Build ActorIdentity
|
||||
actor = ActorIdentity(
|
||||
actor_id=user_data["actor_id"],
|
||||
actor_type=ActorType(user_data["actor_type"]),
|
||||
microdao_ids=user_data["microdao_ids"],
|
||||
roles=user_data["roles"]
|
||||
)
|
||||
|
||||
# Generate session token
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=7)
|
||||
|
||||
# Store in database
|
||||
async with db_pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO sessions (token, actor_id, actor_data, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
""",
|
||||
token,
|
||||
actor.actor_id,
|
||||
json.dumps(actor.model_dump()),
|
||||
expires_at
|
||||
)
|
||||
|
||||
# Set cookie
|
||||
response.set_cookie(
|
||||
key="session_token",
|
||||
value=token,
|
||||
httponly=True,
|
||||
max_age=7 * 24 * 60 * 60, # 7 days
|
||||
samesite="lax"
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
session_token=token,
|
||||
actor=actor,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
@router.get("/me", response_model=ActorIdentity)
|
||||
async def get_me(
|
||||
actor: ActorIdentity = Depends(require_actor)
|
||||
):
|
||||
"""Get current actor identity"""
|
||||
return actor
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
response: Response,
|
||||
actor: ActorIdentity = Depends(require_actor),
|
||||
db_pool: asyncpg.Pool = Depends(get_db_pool)
|
||||
):
|
||||
"""Logout and invalidate session"""
|
||||
|
||||
# Invalidate all sessions for this actor
|
||||
async with db_pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE sessions SET is_valid = false WHERE actor_id = $1",
|
||||
actor.actor_id
|
||||
)
|
||||
|
||||
# Clear cookie
|
||||
response.delete_cookie("session_token")
|
||||
|
||||
return {"status": "logged_out"}
|
||||
|
||||
|
||||
|
||||
|
||||
102
services/auth-service/security.py
Normal file
102
services/auth-service/security.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Security utilities: password hashing, JWT tokens
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Password hashing
|
||||
pwd_context = CryptContext(
|
||||
schemes=["bcrypt"],
|
||||
deprecated="auto",
|
||||
bcrypt__rounds=settings.bcrypt_rounds
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
# JWT Token operations
|
||||
def create_access_token(
|
||||
user_id: UUID,
|
||||
email: str,
|
||||
display_name: Optional[str],
|
||||
roles: list[str]
|
||||
) -> str:
|
||||
"""Create a JWT access token"""
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + timedelta(seconds=settings.access_token_ttl)
|
||||
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"email": email,
|
||||
"name": display_name,
|
||||
"roles": roles,
|
||||
"type": "access",
|
||||
"iss": "daarion-auth",
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expire.timestamp())
|
||||
}
|
||||
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: UUID, session_id: UUID) -> str:
|
||||
"""Create a JWT refresh token"""
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + timedelta(seconds=settings.refresh_token_ttl)
|
||||
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"session_id": str(session_id),
|
||||
"type": "refresh",
|
||||
"iss": "daarion-auth",
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expire.timestamp())
|
||||
}
|
||||
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Decode and validate a JWT token"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.jwt_secret,
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
options={"verify_exp": True}
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Decode an access token and verify it's the correct type"""
|
||||
payload = decode_token(token)
|
||||
if payload and payload.get("type") == "access":
|
||||
return payload
|
||||
return None
|
||||
|
||||
|
||||
def decode_refresh_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Decode a refresh token and verify it's the correct type"""
|
||||
payload = decode_token(token)
|
||||
if payload and payload.get("type") == "refresh":
|
||||
return payload
|
||||
return None
|
||||
|
||||
209
services/auth-service/webauthn_utils.py
Normal file
209
services/auth-service/webauthn_utils.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
WebAuthn Utilities for DAARION
|
||||
Handles challenge generation, credential validation, and attestation
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import base64
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
|
||||
# WebAuthn library
|
||||
from webauthn import (
|
||||
generate_registration_options,
|
||||
verify_registration_response,
|
||||
generate_authentication_options,
|
||||
verify_authentication_response,
|
||||
options_to_json,
|
||||
)
|
||||
from webauthn.helpers.structs import (
|
||||
PublicKeyCredentialDescriptor,
|
||||
UserVerificationRequirement,
|
||||
AuthenticatorSelectionCriteria,
|
||||
ResidentKeyRequirement,
|
||||
AuthenticatorAttachment,
|
||||
)
|
||||
from webauthn.helpers.cose import COSEAlgorithmIdentifier
|
||||
|
||||
# Configuration
|
||||
RP_ID = os.getenv("RP_ID", "localhost")
|
||||
RP_NAME = os.getenv("RP_NAME", "DAARION")
|
||||
ORIGIN = os.getenv("ORIGIN", "http://localhost:3000")
|
||||
|
||||
class WebAuthnManager:
|
||||
"""Manages WebAuthn operations"""
|
||||
|
||||
def __init__(self):
|
||||
self.rp_id = RP_ID
|
||||
self.rp_name = RP_NAME
|
||||
self.origin = ORIGIN
|
||||
|
||||
def generate_registration_challenge(
|
||||
self,
|
||||
user_id: str,
|
||||
username: str,
|
||||
display_name: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate WebAuthn registration options
|
||||
|
||||
Returns PublicKeyCredentialCreationOptions
|
||||
"""
|
||||
|
||||
# Generate options using py_webauthn
|
||||
options = generate_registration_options(
|
||||
rp_id=self.rp_id,
|
||||
rp_name=self.rp_name,
|
||||
user_id=user_id.encode('utf-8'),
|
||||
user_name=username,
|
||||
user_display_name=display_name,
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
authenticator_attachment=AuthenticatorAttachment.PLATFORM,
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
),
|
||||
supported_pub_key_algs=[
|
||||
COSEAlgorithmIdentifier.ECDSA_SHA_256, # -7
|
||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, # -257
|
||||
],
|
||||
timeout=60000, # 60 seconds
|
||||
)
|
||||
|
||||
# Convert to JSON-serializable dict
|
||||
options_json = options_to_json(options)
|
||||
options_dict = json.loads(options_json)
|
||||
|
||||
return {
|
||||
"options": options_dict,
|
||||
"challenge": base64.urlsafe_b64encode(options.challenge).decode('utf-8').rstrip('=')
|
||||
}
|
||||
|
||||
def verify_registration(
|
||||
self,
|
||||
credential: Dict[str, Any],
|
||||
expected_challenge: bytes,
|
||||
expected_origin: str,
|
||||
expected_rp_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify WebAuthn registration response
|
||||
|
||||
Returns verified credential data
|
||||
"""
|
||||
|
||||
try:
|
||||
verification = verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_origin=expected_origin,
|
||||
expected_rp_id=expected_rp_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"verified": True,
|
||||
"credential_id": base64.urlsafe_b64encode(verification.credential_id).decode('utf-8').rstrip('='),
|
||||
"public_key": base64.urlsafe_b64encode(verification.credential_public_key).decode('utf-8').rstrip('='),
|
||||
"sign_count": verification.sign_count,
|
||||
"aaguid": verification.aaguid.hex() if verification.aaguid else None,
|
||||
"attestation_format": verification.fmt,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"verified": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def generate_authentication_challenge(
|
||||
self,
|
||||
credentials: list[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate WebAuthn authentication options
|
||||
|
||||
credentials: list of user's passkeys with credential_id
|
||||
"""
|
||||
|
||||
# Convert stored credentials to PublicKeyCredentialDescriptor
|
||||
allow_credentials = [
|
||||
PublicKeyCredentialDescriptor(
|
||||
id=base64.urlsafe_b64decode(cred["credential_id"] + "=="),
|
||||
transports=cred.get("transports", [])
|
||||
)
|
||||
for cred in credentials
|
||||
]
|
||||
|
||||
options = generate_authentication_options(
|
||||
rp_id=self.rp_id,
|
||||
allow_credentials=allow_credentials if allow_credentials else None,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
timeout=60000,
|
||||
)
|
||||
|
||||
# Convert to JSON-serializable dict
|
||||
options_json = options_to_json(options)
|
||||
options_dict = json.loads(options_json)
|
||||
|
||||
return {
|
||||
"options": options_dict,
|
||||
"challenge": base64.urlsafe_b64encode(options.challenge).decode('utf-8').rstrip('=')
|
||||
}
|
||||
|
||||
def verify_authentication(
|
||||
self,
|
||||
credential: Dict[str, Any],
|
||||
expected_challenge: bytes,
|
||||
credential_public_key: bytes,
|
||||
credential_current_sign_count: int,
|
||||
expected_origin: str,
|
||||
expected_rp_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify WebAuthn authentication response
|
||||
|
||||
Returns verification result with new sign count
|
||||
"""
|
||||
|
||||
try:
|
||||
verification = verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_rp_id=expected_rp_id,
|
||||
expected_origin=expected_origin,
|
||||
credential_public_key=credential_public_key,
|
||||
credential_current_sign_count=credential_current_sign_count,
|
||||
)
|
||||
|
||||
return {
|
||||
"verified": True,
|
||||
"new_sign_count": verification.new_sign_count
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"verified": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
# Global instance
|
||||
webauthn_manager = WebAuthnManager()
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
def generate_challenge() -> str:
|
||||
"""Generate a cryptographically secure random challenge"""
|
||||
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')
|
||||
|
||||
def generate_session_token() -> str:
|
||||
"""Generate a secure session token"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
def hash_credential_id(credential_id: str) -> str:
|
||||
"""Hash credential ID for storage"""
|
||||
return hashlib.sha256(credential_id.encode()).hexdigest()
|
||||
|
||||
|
||||
|
||||
|
||||
145
services/common/auth_middleware.py
Normal file
145
services/common/auth_middleware.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Shared Auth Middleware for DAARION Services
|
||||
Use this in agents-service, microdao-service, city-service, secondme-service
|
||||
"""
|
||||
from fastapi import Request, HTTPException, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from typing import Optional, List
|
||||
from jose import jwt, JWTError
|
||||
import os
|
||||
|
||||
# JWT Configuration - must match auth-service
|
||||
JWT_SECRET = os.getenv("AUTH_JWT_SECRET", "your-very-long-secret-key-change-in-production")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
class AuthUser:
|
||||
"""Authenticated user context"""
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
email: str,
|
||||
display_name: Optional[str],
|
||||
roles: List[str]
|
||||
):
|
||||
self.user_id = user_id
|
||||
self.email = email
|
||||
self.display_name = display_name
|
||||
self.roles = roles
|
||||
|
||||
def has_role(self, role: str) -> bool:
|
||||
return role in self.roles
|
||||
|
||||
def is_admin(self) -> bool:
|
||||
return "admin" in self.roles
|
||||
|
||||
def __repr__(self):
|
||||
return f"AuthUser(user_id={self.user_id}, email={self.email}, roles={self.roles})"
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Decode and validate a JWT token"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
JWT_SECRET,
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
options={"verify_exp": True}
|
||||
)
|
||||
# Verify it's an access token
|
||||
if payload.get("type") != "access":
|
||||
return None
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_user_optional(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||
) -> Optional[AuthUser]:
|
||||
"""
|
||||
Get current user if authenticated, None otherwise.
|
||||
Use this for endpoints that work both with and without auth.
|
||||
"""
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
return AuthUser(
|
||||
user_id=payload.get("sub"),
|
||||
email=payload.get("email"),
|
||||
display_name=payload.get("name"),
|
||||
roles=payload.get("roles", [])
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||
) -> AuthUser:
|
||||
"""
|
||||
Get current user, raise 401 if not authenticated.
|
||||
Use this for protected endpoints.
|
||||
"""
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
)
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
)
|
||||
|
||||
return AuthUser(
|
||||
user_id=payload.get("sub"),
|
||||
email=payload.get("email"),
|
||||
display_name=payload.get("name"),
|
||||
roles=payload.get("roles", [])
|
||||
)
|
||||
|
||||
|
||||
def require_role(role: str):
|
||||
"""
|
||||
Dependency that requires a specific role.
|
||||
Usage: @app.get("/admin", dependencies=[Depends(require_role("admin"))])
|
||||
"""
|
||||
async def role_checker(user: AuthUser = Depends(get_current_user)):
|
||||
if not user.has_role(role):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Role '{role}' required"
|
||||
)
|
||||
return user
|
||||
return role_checker
|
||||
|
||||
|
||||
def require_any_role(roles: List[str]):
|
||||
"""
|
||||
Dependency that requires any of the specified roles.
|
||||
"""
|
||||
async def role_checker(user: AuthUser = Depends(get_current_user)):
|
||||
if not any(user.has_role(r) for r in roles):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"One of roles {roles} required"
|
||||
)
|
||||
return user
|
||||
return role_checker
|
||||
|
||||
|
||||
# Convenience aliases
|
||||
RequireAuth = Depends(get_current_user)
|
||||
OptionalAuth = Depends(get_current_user_optional)
|
||||
RequireAdmin = Depends(require_role("admin"))
|
||||
|
||||
Reference in New Issue
Block a user