From 5aaf6cbf21fbd4d0b50d16d68f4468ef7456621e Mon Sep 17 00:00:00 2001 From: Apple Date: Wed, 26 Nov 2025 11:47:00 -0800 Subject: [PATCH] feat: Add Auth Service with JWT authentication --- apps/web/src/app/(auth)/login/page.tsx | 163 ++++++++++ apps/web/src/app/(auth)/register/page.tsx | 256 ++++++++++++++++ apps/web/src/app/layout.tsx | 23 +- apps/web/src/components/Navigation.tsx | 112 ++++++- apps/web/src/context/AuthContext.tsx | 86 ++++++ apps/web/src/lib/auth.ts | 228 ++++++++++++++ docs/security/AUTH_SPEC.md | 349 ++++++++++++++++++++++ migrations/011_create_auth_tables.sql | 72 +++++ services/auth-service/Dockerfile | 20 ++ services/auth-service/README.md | 220 ++++++++++++++ services/auth-service/actor_context.py | 129 ++++++++ services/auth-service/config.py | 35 +++ services/auth-service/database.py | 183 ++++++++++++ services/auth-service/main.py | 304 +++++++++++++++++++ services/auth-service/models.py | 86 ++++++ services/auth-service/passkey_store.py | 230 ++++++++++++++ services/auth-service/requirements.txt | 11 + services/auth-service/routes_api_keys.py | 127 ++++++++ services/auth-service/routes_passkey.py | 329 ++++++++++++++++++++ services/auth-service/routes_sessions.py | 129 ++++++++ services/auth-service/security.py | 102 +++++++ services/auth-service/webauthn_utils.py | 209 +++++++++++++ services/common/auth_middleware.py | 145 +++++++++ 23 files changed, 3522 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/app/(auth)/login/page.tsx create mode 100644 apps/web/src/app/(auth)/register/page.tsx create mode 100644 apps/web/src/context/AuthContext.tsx create mode 100644 apps/web/src/lib/auth.ts create mode 100644 docs/security/AUTH_SPEC.md create mode 100644 migrations/011_create_auth_tables.sql create mode 100644 services/auth-service/Dockerfile create mode 100644 services/auth-service/README.md create mode 100644 services/auth-service/actor_context.py create mode 100644 services/auth-service/config.py create mode 100644 services/auth-service/database.py create mode 100644 services/auth-service/main.py create mode 100644 services/auth-service/models.py create mode 100644 services/auth-service/passkey_store.py create mode 100644 services/auth-service/requirements.txt create mode 100644 services/auth-service/routes_api_keys.py create mode 100644 services/auth-service/routes_passkey.py create mode 100644 services/auth-service/routes_sessions.py create mode 100644 services/auth-service/security.py create mode 100644 services/auth-service/webauthn_utils.py create mode 100644 services/common/auth_middleware.py diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx new file mode 100644 index 00000000..5b895a7b --- /dev/null +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -0,0 +1,163 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { Mail, Lock, Loader2, Sparkles, AlertCircle } from 'lucide-react' +import { useAuth } from '@/context/AuthContext' +import { cn } from '@/lib/utils' + +export default function LoginPage() { + const router = useRouter() + const { login, isAuthenticated } = useAuth() + + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + // Redirect if already authenticated + if (isAuthenticated) { + router.push('/') + return null + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + + try { + await login(email, password) + router.push('/') + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed') + } finally { + setLoading(false) + } + } + + return ( +
+
+ {/* Logo */} +
+ + + + DAARION + + +

Вхід в акаунт

+

+ Увійдіть, щоб продовжити у DAARION.city +

+
+ + {/* Form */} +
+
+ {/* Error */} + {error && ( +
+ + {error} +
+ )} + + {/* Email */} +
+ +
+ + setEmail(e.target.value)} + placeholder="your@email.com" + required + className={cn( + 'w-full pl-10 pr-4 py-3 bg-slate-800/50 border border-white/10 rounded-xl', + 'text-white placeholder-slate-500', + 'focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20', + 'transition-all' + )} + /> +
+
+ + {/* Password */} +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + minLength={8} + className={cn( + 'w-full pl-10 pr-4 py-3 bg-slate-800/50 border border-white/10 rounded-xl', + 'text-white placeholder-slate-500', + 'focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20', + 'transition-all' + )} + /> +
+
+ + {/* Submit */} + +
+ + {/* Divider */} +
+
+ або +
+
+ + {/* Register link */} +

+ Немає акаунту?{' '} + + Зареєструватися + +

+
+ + {/* Back to home */} +

+ + ← Повернутися на головну + +

+
+
+ ) +} + diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx new file mode 100644 index 00000000..802ab172 --- /dev/null +++ b/apps/web/src/app/(auth)/register/page.tsx @@ -0,0 +1,256 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { Mail, Lock, User, Loader2, Sparkles, AlertCircle, CheckCircle2 } from 'lucide-react' +import { useAuth } from '@/context/AuthContext' +import { cn } from '@/lib/utils' + +export default function RegisterPage() { + const router = useRouter() + const { register, isAuthenticated } = useAuth() + + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [displayName, setDisplayName] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + // Redirect if already authenticated + if (isAuthenticated) { + router.push('/') + return null + } + + const passwordRequirements = [ + { met: password.length >= 8, text: 'Мінімум 8 символів' }, + { met: /[A-Z]/.test(password), text: 'Одна велика літера' }, + { met: /[a-z]/.test(password), text: 'Одна мала літера' }, + { met: /[0-9]/.test(password), text: 'Одна цифра' }, + ] + + const isPasswordValid = passwordRequirements.every(r => r.met) + const doPasswordsMatch = password === confirmPassword && password.length > 0 + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + + if (!isPasswordValid) { + setError('Пароль не відповідає вимогам') + return + } + + if (!doPasswordsMatch) { + setError('Паролі не співпадають') + return + } + + setLoading(true) + + try { + await register(email, password, displayName || undefined) + router.push('/') + } catch (err) { + setError(err instanceof Error ? err.message : 'Registration failed') + } finally { + setLoading(false) + } + } + + return ( +
+
+ {/* Logo */} +
+ + + + DAARION + + +

Створити акаунт

+

+ Приєднуйтесь до DAARION.city +

+
+ + {/* Form */} +
+
+ {/* Error */} + {error && ( +
+ + {error} +
+ )} + + {/* Display Name */} +
+ +
+ + setDisplayName(e.target.value)} + placeholder="Ваше ім'я" + maxLength={100} + className={cn( + 'w-full pl-10 pr-4 py-3 bg-slate-800/50 border border-white/10 rounded-xl', + 'text-white placeholder-slate-500', + 'focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20', + 'transition-all' + )} + /> +
+
+ + {/* Email */} +
+ +
+ + setEmail(e.target.value)} + placeholder="your@email.com" + required + className={cn( + 'w-full pl-10 pr-4 py-3 bg-slate-800/50 border border-white/10 rounded-xl', + 'text-white placeholder-slate-500', + 'focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20', + 'transition-all' + )} + /> +
+
+ + {/* Password */} +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + className={cn( + 'w-full pl-10 pr-4 py-3 bg-slate-800/50 border border-white/10 rounded-xl', + 'text-white placeholder-slate-500', + 'focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20', + 'transition-all' + )} + /> +
+ + {/* Password requirements */} + {password.length > 0 && ( +
+ {passwordRequirements.map((req, i) => ( +
+ + {req.text} +
+ ))} +
+ )} +
+ + {/* Confirm Password */} +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="••••••••" + required + className={cn( + 'w-full pl-10 pr-4 py-3 bg-slate-800/50 border border-white/10 rounded-xl', + 'text-white placeholder-slate-500', + 'focus:outline-none focus:ring-1 transition-all', + confirmPassword.length > 0 && ( + doPasswordsMatch + ? 'border-emerald-500/50 focus:border-emerald-500/50 focus:ring-emerald-500/20' + : 'border-red-500/50 focus:border-red-500/50 focus:ring-red-500/20' + ), + confirmPassword.length === 0 && 'border-white/10 focus:border-cyan-500/50 focus:ring-cyan-500/20' + )} + /> +
+ {confirmPassword.length > 0 && !doPasswordsMatch && ( +

Паролі не співпадають

+ )} +
+ + {/* Submit */} + +
+ + {/* Divider */} +
+
+ або +
+
+ + {/* Login link */} +

+ Вже маєте акаунт?{' '} + + Увійти + +

+
+ + {/* Back to home */} +

+ + ← Повернутися на головну + +

+
+
+ ) +} + diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 5edabc40..856f7a70 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from 'next' import { Inter } from 'next/font/google' import './globals.css' import { Navigation } from '@/components/Navigation' +import { AuthProvider } from '@/context/AuthContext' const inter = Inter({ subsets: ['latin', 'cyrillic'], @@ -31,16 +32,18 @@ export default function RootLayout({ return ( - {/* Ambient background effect */} -
- - {/* Navigation */} - - - {/* Main content */} -
- {children} -
+ + {/* Ambient background effect */} +
+ + {/* Navigation */} + + + {/* Main content */} +
+ {children} +
+ ) diff --git a/apps/web/src/components/Navigation.tsx b/apps/web/src/components/Navigation.tsx index 119d799c..1b32b131 100644 --- a/apps/web/src/components/Navigation.tsx +++ b/apps/web/src/components/Navigation.tsx @@ -2,9 +2,10 @@ import { useState } from 'react' import Link from 'next/link' -import { usePathname } from 'next/navigation' -import { Menu, X, Home, Building2, User, Sparkles, Bot, Wallet } from 'lucide-react' +import { usePathname, useRouter } from 'next/navigation' +import { Menu, X, Home, Building2, User, Sparkles, Bot, Wallet, LogOut, Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' +import { useAuth } from '@/context/AuthContext' const navItems = [ { href: '/', label: 'Головна', icon: Home }, @@ -17,6 +18,19 @@ const navItems = [ export function Navigation() { const [isOpen, setIsOpen] = useState(false) const pathname = usePathname() + const router = useRouter() + const { user, isAuthenticated, isLoading, logout } = useAuth() + + const handleLogout = async () => { + await logout() + router.push('/') + setIsOpen(false) + } + + // Don't show navigation on auth pages + if (pathname.startsWith('/login') || pathname.startsWith('/register')) { + return null + } return (
@@ -123,4 +204,3 @@ export function Navigation() { ) } - diff --git a/apps/web/src/context/AuthContext.tsx b/apps/web/src/context/AuthContext.tsx new file mode 100644 index 00000000..06c4d171 --- /dev/null +++ b/apps/web/src/context/AuthContext.tsx @@ -0,0 +1,86 @@ +'use client' + +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react' +import { User, getStoredUser, getMe, login as authLogin, logout as authLogout, register as authRegister, isAuthenticated } from '@/lib/auth' + +interface AuthContextType { + user: User | null + isLoading: boolean + isAuthenticated: boolean + login: (email: string, password: string) => Promise + register: (email: string, password: string, displayName?: string) => Promise + logout: () => Promise + refreshUser: () => Promise +} + +const AuthContext = createContext(undefined) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + const refreshUser = useCallback(async () => { + try { + const userData = await getMe() + setUser(userData) + } catch { + setUser(null) + } + }, []) + + useEffect(() => { + // Check for stored user on mount + const storedUser = getStoredUser() + if (storedUser) { + setUser(storedUser) + } + + // Verify token and refresh user data + if (isAuthenticated()) { + refreshUser().finally(() => setIsLoading(false)) + } else { + setIsLoading(false) + } + }, [refreshUser]) + + const login = async (email: string, password: string) => { + const response = await authLogin(email, password) + setUser(response.user) + } + + const register = async (email: string, password: string, displayName?: string) => { + await authRegister(email, password, displayName) + // Auto-login after registration + await login(email, password) + } + + const logout = async () => { + await authLogout() + setUser(null) + } + + return ( + + {children} + + ) +} + +export function useAuth() { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} + diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 00000000..70be1fe3 --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,228 @@ +'use client' + +/** + * Auth utilities for DAARION Frontend + */ + +export interface User { + id: string + email: string + display_name: string | null + avatar_url: string | null + roles: string[] + is_active: boolean + created_at: string +} + +export interface AuthTokens { + access_token: string + refresh_token: string + token_type: string + expires_in: number +} + +export interface LoginResponse extends AuthTokens { + user: User +} + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || '' + +// Token storage keys +const ACCESS_TOKEN_KEY = 'daarion_access_token' +const REFRESH_TOKEN_KEY = 'daarion_refresh_token' +const USER_KEY = 'daarion_user' + +// Token management +export function getAccessToken(): string | null { + if (typeof window === 'undefined') return null + return localStorage.getItem(ACCESS_TOKEN_KEY) +} + +export function getRefreshToken(): string | null { + if (typeof window === 'undefined') return null + return localStorage.getItem(REFRESH_TOKEN_KEY) +} + +export function getStoredUser(): User | null { + if (typeof window === 'undefined') return null + const stored = localStorage.getItem(USER_KEY) + if (!stored) return null + try { + return JSON.parse(stored) + } catch { + return null + } +} + +export function setTokens(tokens: AuthTokens, user?: User): void { + if (typeof window === 'undefined') return + localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token) + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token) + if (user) { + localStorage.setItem(USER_KEY, JSON.stringify(user)) + } +} + +export function clearTokens(): void { + if (typeof window === 'undefined') return + localStorage.removeItem(ACCESS_TOKEN_KEY) + localStorage.removeItem(REFRESH_TOKEN_KEY) + localStorage.removeItem(USER_KEY) +} + +export function isAuthenticated(): boolean { + return !!getAccessToken() +} + +// API calls +export async function register( + email: string, + password: string, + displayName?: string +): Promise<{ user_id: string; email: string }> { + const response = await fetch(`${API_BASE}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + password, + display_name: displayName + }) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || 'Registration failed') + } + + return response.json() +} + +export async function login(email: string, password: string): Promise { + const response = await fetch(`${API_BASE}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || 'Login failed') + } + + const data: LoginResponse = await response.json() + setTokens(data, data.user) + return data +} + +export async function refreshTokens(): Promise { + const refreshToken = getRefreshToken() + if (!refreshToken) return null + + try { + const response = await fetch(`${API_BASE}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }) + + if (!response.ok) { + clearTokens() + return null + } + + const data: AuthTokens = await response.json() + setTokens(data) + return data + } catch { + clearTokens() + return null + } +} + +export async function logout(): Promise { + const refreshToken = getRefreshToken() + + if (refreshToken) { + try { + await fetch(`${API_BASE}/api/auth/logout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }) + } catch { + // Ignore errors during logout + } + } + + clearTokens() +} + +export async function getMe(): Promise { + const token = getAccessToken() + if (!token) return null + + try { + const response = await fetch(`${API_BASE}/api/auth/me`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }) + + if (!response.ok) { + if (response.status === 401) { + // Try to refresh + const refreshed = await refreshTokens() + if (refreshed) { + return getMe() + } + clearTokens() + } + return null + } + + const user: User = await response.json() + localStorage.setItem(USER_KEY, JSON.stringify(user)) + return user + } catch { + return null + } +} + +// Auth header for API requests +export function getAuthHeader(): Record { + const token = getAccessToken() + if (!token) return {} + return { 'Authorization': `Bearer ${token}` } +} + +// Fetch with auth +export async function authFetch( + url: string, + options: RequestInit = {} +): Promise { + const token = getAccessToken() + + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record || {}) + } + + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + let response = await fetch(url, { ...options, headers }) + + // If 401, try to refresh and retry + if (response.status === 401 && token) { + const refreshed = await refreshTokens() + if (refreshed) { + headers['Authorization'] = `Bearer ${refreshed.access_token}` + response = await fetch(url, { ...options, headers }) + } + } + + return response +} + diff --git a/docs/security/AUTH_SPEC.md b/docs/security/AUTH_SPEC.md new file mode 100644 index 00000000..f33a2acb --- /dev/null +++ b/docs/security/AUTH_SPEC.md @@ -0,0 +1,349 @@ +# AUTH SPEC — DAARION.city +Version: 1.0.0 + +--- + +## 0. PURPOSE + +Цей документ описує базову систему автентифікації та авторизації для DAARION.city: + +- єдину модель користувача (`user_id`) для: + - фронтенду (web/PWA), + - Matrix/chat інтеграції, + - MicroDAO governance, + - Agents Service, + - SecondMe. +- механізм логіну/логауту (JWT access + refresh tokens), +- базову RBAC (roles/permissions), +- інтеграцію з існуючими сервісами (agents, microdao, city, secondme). + +Фокус цієї версії — **MVP-рівень**: + +- Password-based login (email + password) + готовність до OAuth (Google/Telegram) як наступний крок. +- JWT токени (access + refresh). +- Мінімальний набір ролей (`user`, `admin`, `agent-system`). +- Захист основних API (governance, agents, secondme private). + +--- + +## 1. ARCHITECTURE OVERVIEW + +### 1.1. Auth Service + +Окремий сервіс `auth-service` (порт: **7020**): + +```text +[ Web / PWA / Matrix Gateway ] + ↓ + [ Auth Service (7020) ] + ↓ +[ PostgreSQL (auth tables) + Redis (sessions cache) ] + ↓ +[ JWT токени для інших сервісів ] +``` + +Auth Service: + +* реєструє користувачів, +* зберігає хеші паролів, +* видає JWT access/refresh токени, +* перевіряє токени (через shared secret / public key), +* надає API для інших сервісів (`/auth/introspect`). + +### 1.2. Інші сервіси + +* `Agents Service`, `MicroDAO Service`, `SecondMe`, `City Service`: + * отримують JWT у `Authorization: Bearer `, + * валідують його (прямо або через Auth Service), + * витягують `user_id`, `roles`, `scopes`. + +--- + +## 2. DATA MODEL (PostgreSQL) + +### 2.1. auth_users + +```sql +CREATE TABLE auth_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT, + avatar_url TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + locale TEXT DEFAULT 'uk', + timezone TEXT DEFAULT 'Europe/Kyiv', + meta JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX ix_auth_users_email ON auth_users(email); +``` + +### 2.2. auth_roles + +```sql +CREATE TABLE auth_roles ( + id TEXT PRIMARY KEY, -- 'user' | 'admin' | 'agent-system' + description TEXT +); + +INSERT INTO auth_roles (id, description) VALUES + ('user', 'Regular user'), + ('admin', 'Administrator'), + ('agent-system', 'System agent'); +``` + +### 2.3. auth_user_roles + +```sql +CREATE TABLE auth_user_roles ( + user_id UUID NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + role_id TEXT NOT NULL REFERENCES auth_roles(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, role_id) +); +``` + +### 2.4. auth_sessions + +```sql +CREATE TABLE auth_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + user_agent TEXT, + ip_address INET, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + meta JSONB DEFAULT '{}'::jsonb +); + +CREATE INDEX ix_auth_sessions_user_id ON auth_sessions(user_id); +CREATE INDEX ix_auth_sessions_expires ON auth_sessions(expires_at); +``` + +--- + +## 3. TOKEN MODEL (JWT) + +### 3.1. Access token + +* Формат: JWT (HS256). +* Термін дії: 30 хвилин. +* Payload: + +```json +{ + "sub": "user_id-uuid", + "email": "user@example.com", + "name": "Display Name", + "roles": ["user"], + "iat": 1732590000, + "exp": 1732591800, + "iss": "daarion-auth", + "type": "access" +} +``` + +### 3.2. Refresh token + +* Формат: JWT (HS256). +* Термін дії: 7 днів. +* Payload: + +```json +{ + "sub": "user_id-uuid", + "session_id": "session-uuid", + "iat": 1732590000, + "exp": 1733194800, + "iss": "daarion-auth", + "type": "refresh" +} +``` + +--- + +## 4. HTTP API (PUBLIC) + +Базовий шлях: `/api/auth/...`. + +### 4.1. `POST /api/auth/register` + +**Request:** +```json +{ + "email": "user@example.com", + "password": "StrongPassword123", + "display_name": "Alex" +} +``` + +**Response (201):** +```json +{ + "user_id": "uuid", + "email": "user@example.com", + "display_name": "Alex", + "roles": ["user"] +} +``` + +### 4.2. `POST /api/auth/login` + +**Request:** +```json +{ + "email": "user@example.com", + "password": "StrongPassword123" +} +``` + +**Response (200):** +```json +{ + "access_token": "", + "refresh_token": "", + "token_type": "Bearer", + "expires_in": 1800, + "user": { + "id": "uuid", + "email": "user@example.com", + "display_name": "Alex", + "roles": ["user"] + } +} +``` + +### 4.3. `POST /api/auth/refresh` + +**Request:** +```json +{ + "refresh_token": "" +} +``` + +**Response (200):** +```json +{ + "access_token": "", + "refresh_token": "", + "token_type": "Bearer", + "expires_in": 1800 +} +``` + +### 4.4. `POST /api/auth/logout` + +**Request:** +```json +{ + "refresh_token": "" +} +``` + +**Response:** +```json +{ + "status": "ok" +} +``` + +### 4.5. `GET /api/auth/me` + +**Headers:** `Authorization: Bearer ` + +**Response (200):** +```json +{ + "id": "uuid", + "email": "user@example.com", + "display_name": "Alex", + "avatar_url": null, + "roles": ["user"], + "created_at": "2025-11-26T10:00:00Z" +} +``` + +--- + +## 5. HTTP API (INTERNAL) + +### 5.1. `POST /api/auth/introspect` + +**Request:** +```json +{ + "token": "" +} +``` + +**Response (200, valid):** +```json +{ + "active": true, + "sub": "user_id-uuid", + "email": "user@example.com", + "roles": ["user"], + "exp": 1732591800 +} +``` + +**Response (200, invalid):** +```json +{ + "active": false +} +``` + +--- + +## 6. HEALTHCHECK + +### `GET /healthz` + +```json +{ + "status": "ok", + "service": "auth-service", + "version": "1.0.0" +} +``` + +--- + +## 7. CONFIGURATION (ENV) + +```env +AUTH_SERVICE_PORT=7020 +AUTH_DB_DSN=postgresql://user:pass@postgres:5432/daarion +AUTH_JWT_SECRET=your-very-long-secret-key-here +AUTH_ACCESS_TOKEN_TTL=1800 +AUTH_REFRESH_TOKEN_TTL=604800 +AUTH_BCRYPT_ROUNDS=12 +``` + +--- + +## 8. SECURITY NOTES + +* Паролі зберігати тільки як `bcrypt` hash. +* JWT secret — довгий (мінімум 32 символи), збережений у `.env`. +* Rate limiting для `/auth/login` (захист від brute force). +* Логи не повинні писати паролі / токени. +* HTTPS обов'язковий у production. + +--- + +## 9. ROADMAP (POST-MVP) + +* OAuth2 / OIDC (Google, GitHub, Telegram). +* WebAuthn / passkeys. +* Device-level identity (звʼязок із Matrix devices). +* On-chain identity (wallet + DID). +* Email verification. +* Password reset flow. + diff --git a/migrations/011_create_auth_tables.sql b/migrations/011_create_auth_tables.sql new file mode 100644 index 00000000..9cc41526 --- /dev/null +++ b/migrations/011_create_auth_tables.sql @@ -0,0 +1,72 @@ +-- Migration: 011_create_auth_tables.sql +-- Auth system for DAARION.city + +-- Users table +CREATE TABLE IF NOT EXISTS auth_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT, + avatar_url TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + locale TEXT DEFAULT 'uk', + timezone TEXT DEFAULT 'Europe/Kyiv', + meta JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_auth_users_email ON auth_users(email); + +-- Roles table +CREATE TABLE IF NOT EXISTS auth_roles ( + id TEXT PRIMARY KEY, + description TEXT +); + +-- Default roles +INSERT INTO auth_roles (id, description) VALUES + ('user', 'Regular user'), + ('admin', 'Administrator'), + ('agent-system', 'System agent') +ON CONFLICT (id) DO NOTHING; + +-- User-Role mapping +CREATE TABLE IF NOT EXISTS auth_user_roles ( + user_id UUID NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + role_id TEXT NOT NULL REFERENCES auth_roles(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, role_id) +); + +-- Sessions table for refresh tokens +CREATE TABLE IF NOT EXISTS auth_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + user_agent TEXT, + ip_address INET, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + meta JSONB DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS ix_auth_sessions_user_id ON auth_sessions(user_id); +CREATE INDEX IF NOT EXISTS ix_auth_sessions_expires ON auth_sessions(expires_at); + +-- Function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_auth_users_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger for updated_at +DROP TRIGGER IF EXISTS trigger_auth_users_updated_at ON auth_users; +CREATE TRIGGER trigger_auth_users_updated_at + BEFORE UPDATE ON auth_users + FOR EACH ROW + EXECUTE FUNCTION update_auth_users_updated_at(); + diff --git a/services/auth-service/Dockerfile b/services/auth-service/Dockerfile new file mode 100644 index 00000000..ba894100 --- /dev/null +++ b/services/auth-service/Dockerfile @@ -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"] diff --git a/services/auth-service/README.md b/services/auth-service/README.md new file mode 100644 index 00000000..3fb6fd62 --- /dev/null +++ b/services/auth-service/README.md @@ -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 " +``` + +### POST /auth/logout +```bash +curl -X POST http://localhost:7011/auth/logout \ + -H "Authorization: Bearer " +``` + +### POST /auth/api-keys +Create API key: +```bash +curl -X POST http://localhost:7011/auth/api-keys \ + -H "Authorization: Bearer " \ + -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 " +``` + +### DELETE /auth/api-keys/{key_id} +```bash +curl -X DELETE http://localhost:7011/auth/api-keys/key-123 \ + -H "Authorization: Bearer " +``` + +## 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 ** (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 + + + + diff --git a/services/auth-service/actor_context.py b/services/auth-service/actor_context.py new file mode 100644 index 00000000..bf3fb685 --- /dev/null +++ b/services/auth-service/actor_context.py @@ -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) + + + + diff --git a/services/auth-service/config.py b/services/auth-service/config.py new file mode 100644 index 00000000..f5c3fd0f --- /dev/null +++ b/services/auth-service/config.py @@ -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() + diff --git a/services/auth-service/database.py b/services/auth-service/database.py new file mode 100644 index 00000000..b3a3c2e2 --- /dev/null +++ b/services/auth-service/database.py @@ -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' + """ + ) + diff --git a/services/auth-service/main.py b/services/auth-service/main.py new file mode 100644 index 00000000..236383dc --- /dev/null +++ b/services/auth-service/main.py @@ -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 + ) diff --git a/services/auth-service/models.py b/services/auth-service/models.py new file mode 100644 index 00000000..72d8a455 --- /dev/null +++ b/services/auth-service/models.py @@ -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 diff --git a/services/auth-service/passkey_store.py b/services/auth-service/passkey_store.py new file mode 100644 index 00000000..3b0637c0 --- /dev/null +++ b/services/auth-service/passkey_store.py @@ -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] + + + + diff --git a/services/auth-service/requirements.txt b/services/auth-service/requirements.txt new file mode 100644 index 00000000..66864c16 --- /dev/null +++ b/services/auth-service/requirements.txt @@ -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 diff --git a/services/auth-service/routes_api_keys.py b/services/auth-service/routes_api_keys.py new file mode 100644 index 00000000..7d03421c --- /dev/null +++ b/services/auth-service/routes_api_keys.py @@ -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} + + + + diff --git a/services/auth-service/routes_passkey.py b/services/auth-service/routes_passkey.py new file mode 100644 index 00000000..78b613bd --- /dev/null +++ b/services/auth-service/routes_passkey.py @@ -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 + ) + + + + diff --git a/services/auth-service/routes_sessions.py b/services/auth-service/routes_sessions.py new file mode 100644 index 00000000..117d6700 --- /dev/null +++ b/services/auth-service/routes_sessions.py @@ -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"} + + + + diff --git a/services/auth-service/security.py b/services/auth-service/security.py new file mode 100644 index 00000000..5b6c42b2 --- /dev/null +++ b/services/auth-service/security.py @@ -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 + diff --git a/services/auth-service/webauthn_utils.py b/services/auth-service/webauthn_utils.py new file mode 100644 index 00000000..b1ff0770 --- /dev/null +++ b/services/auth-service/webauthn_utils.py @@ -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() + + + + diff --git a/services/common/auth_middleware.py b/services/common/auth_middleware.py new file mode 100644 index 00000000..51c40950 --- /dev/null +++ b/services/common/auth_middleware.py @@ -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")) +