feat: Add Auth Service with JWT authentication

This commit is contained in:
Apple
2025-11-26 11:47:00 -08:00
parent 2c4eb7d432
commit 5aaf6cbf21
23 changed files with 3522 additions and 26 deletions

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-2">
<Sparkles className="w-10 h-10 text-cyan-400" />
<span className="text-2xl font-bold bg-gradient-to-r from-cyan-400 to-blue-500 bg-clip-text text-transparent">
DAARION
</span>
</Link>
<h1 className="mt-6 text-2xl font-bold text-white">Вхід в акаунт</h1>
<p className="mt-2 text-slate-400">
Увійдіть, щоб продовжити у DAARION.city
</p>
</div>
{/* Form */}
<div className="glass-panel p-8">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-300 mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
id="email"
type="email"
value={email}
onChange={(e) => 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'
)}
/>
</div>
</div>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-300 mb-2">
Пароль
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
id="password"
type="password"
value={password}
onChange={(e) => 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'
)}
/>
</div>
</div>
{/* Submit */}
<button
type="submit"
disabled={loading}
className={cn(
'w-full py-3 rounded-xl font-medium transition-all',
'bg-gradient-to-r from-cyan-500 to-blue-600 text-white',
'hover:from-cyan-400 hover:to-blue-500',
'shadow-lg shadow-cyan-500/20 hover:shadow-cyan-500/40',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{loading ? (
<Loader2 className="w-5 h-5 mx-auto animate-spin" />
) : (
'Увійти'
)}
</button>
</form>
{/* Divider */}
<div className="my-6 flex items-center gap-4">
<div className="flex-1 h-px bg-white/10" />
<span className="text-sm text-slate-500">або</span>
<div className="flex-1 h-px bg-white/10" />
</div>
{/* Register link */}
<p className="text-center text-slate-400">
Немає акаунту?{' '}
<Link href="/register" className="text-cyan-400 hover:text-cyan-300 font-medium">
Зареєструватися
</Link>
</p>
</div>
{/* Back to home */}
<p className="mt-6 text-center">
<Link href="/" className="text-slate-500 hover:text-slate-400 text-sm">
Повернутися на головну
</Link>
</p>
</div>
</div>
)
}

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-2">
<Sparkles className="w-10 h-10 text-cyan-400" />
<span className="text-2xl font-bold bg-gradient-to-r from-cyan-400 to-blue-500 bg-clip-text text-transparent">
DAARION
</span>
</Link>
<h1 className="mt-6 text-2xl font-bold text-white">Створити акаунт</h1>
<p className="mt-2 text-slate-400">
Приєднуйтесь до DAARION.city
</p>
</div>
{/* Form */}
<div className="glass-panel p-8">
<form onSubmit={handleSubmit} className="space-y-5">
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* Display Name */}
<div>
<label htmlFor="displayName" className="block text-sm font-medium text-slate-300 mb-2">
Ім&apos;я (опційно)
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
id="displayName"
type="text"
value={displayName}
onChange={(e) => 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'
)}
/>
</div>
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-300 mb-2">
Email *
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
id="email"
type="email"
value={email}
onChange={(e) => 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'
)}
/>
</div>
</div>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-300 mb-2">
Пароль *
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
id="password"
type="password"
value={password}
onChange={(e) => 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'
)}
/>
</div>
{/* Password requirements */}
{password.length > 0 && (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req, i) => (
<div key={i} className={cn(
'flex items-center gap-2 text-xs',
req.met ? 'text-emerald-400' : 'text-slate-500'
)}>
<CheckCircle2 className={cn('w-3 h-3', !req.met && 'opacity-50')} />
{req.text}
</div>
))}
</div>
)}
</div>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-300 mb-2">
Підтвердіть пароль *
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => 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'
)}
/>
</div>
{confirmPassword.length > 0 && !doPasswordsMatch && (
<p className="mt-1 text-xs text-red-400">Паролі не співпадають</p>
)}
</div>
{/* Submit */}
<button
type="submit"
disabled={loading || !isPasswordValid || !doPasswordsMatch}
className={cn(
'w-full py-3 rounded-xl font-medium transition-all',
'bg-gradient-to-r from-cyan-500 to-blue-600 text-white',
'hover:from-cyan-400 hover:to-blue-500',
'shadow-lg shadow-cyan-500/20 hover:shadow-cyan-500/40',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{loading ? (
<Loader2 className="w-5 h-5 mx-auto animate-spin" />
) : (
'Зареєструватися'
)}
</button>
</form>
{/* Divider */}
<div className="my-6 flex items-center gap-4">
<div className="flex-1 h-px bg-white/10" />
<span className="text-sm text-slate-500">або</span>
<div className="flex-1 h-px bg-white/10" />
</div>
{/* Login link */}
<p className="text-center text-slate-400">
Вже маєте акаунт?{' '}
<Link href="/login" className="text-cyan-400 hover:text-cyan-300 font-medium">
Увійти
</Link>
</p>
</div>
{/* Back to home */}
<p className="mt-6 text-center">
<Link href="/" className="text-slate-500 hover:text-slate-400 text-sm">
Повернутися на головну
</Link>
</p>
</div>
</div>
)
}

View File

@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from 'next'
import { Inter } from 'next/font/google' import { Inter } from 'next/font/google'
import './globals.css' import './globals.css'
import { Navigation } from '@/components/Navigation' import { Navigation } from '@/components/Navigation'
import { AuthProvider } from '@/context/AuthContext'
const inter = Inter({ const inter = Inter({
subsets: ['latin', 'cyrillic'], subsets: ['latin', 'cyrillic'],
@@ -31,16 +32,18 @@ export default function RootLayout({
return ( return (
<html lang="uk" className={inter.variable}> <html lang="uk" className={inter.variable}>
<body className={`${inter.className} antialiased`}> <body className={`${inter.className} antialiased`}>
{/* Ambient background effect */} <AuthProvider>
<div className="ambient-bg" /> {/* Ambient background effect */}
<div className="ambient-bg" />
{/* Navigation */}
<Navigation /> {/* Navigation */}
<Navigation />
{/* Main content */}
<main className="min-h-screen pt-16"> {/* Main content */}
{children} <main className="min-h-screen pt-16">
</main> {children}
</main>
</AuthProvider>
</body> </body>
</html> </html>
) )

View File

@@ -2,9 +2,10 @@
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { Menu, X, Home, Building2, User, Sparkles, Bot, Wallet } from 'lucide-react' import { Menu, X, Home, Building2, User, Sparkles, Bot, Wallet, LogOut, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuth } from '@/context/AuthContext'
const navItems = [ const navItems = [
{ href: '/', label: 'Головна', icon: Home }, { href: '/', label: 'Головна', icon: Home },
@@ -17,6 +18,19 @@ const navItems = [
export function Navigation() { export function Navigation() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const pathname = usePathname() 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 ( return (
<nav className="fixed top-0 left-0 right-0 z-50"> <nav className="fixed top-0 left-0 right-0 z-50">
@@ -64,12 +78,44 @@ export function Navigation() {
{/* Auth buttons (desktop) */} {/* Auth buttons (desktop) */}
<div className="hidden md:flex items-center gap-3"> <div className="hidden md:flex items-center gap-3">
<button className="px-4 py-2 text-sm text-slate-300 hover:text-white transition-colors"> {isLoading ? (
Увійти <Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</button> ) : isAuthenticated && user ? (
<button className="px-4 py-2 text-sm font-medium bg-gradient-to-r from-cyan-500 to-blue-600 rounded-xl hover:from-cyan-400 hover:to-blue-500 transition-all duration-200 shadow-lg shadow-cyan-500/20"> <div className="flex items-center gap-3">
Приєднатися <div className="flex items-center gap-2">
</button> <div className="w-8 h-8 rounded-full bg-gradient-to-br from-cyan-500/30 to-blue-600/30 flex items-center justify-center">
<span className="text-sm font-medium text-cyan-400">
{user.display_name?.[0]?.toUpperCase() || user.email[0].toUpperCase()}
</span>
</div>
<span className="text-sm text-slate-300 max-w-[120px] truncate">
{user.display_name || user.email}
</span>
</div>
<button
onClick={handleLogout}
className="p-2 text-slate-400 hover:text-white transition-colors"
title="Вийти"
>
<LogOut className="w-4 h-4" />
</button>
</div>
) : (
<>
<Link
href="/login"
className="px-4 py-2 text-sm text-slate-300 hover:text-white transition-colors"
>
Увійти
</Link>
<Link
href="/register"
className="px-4 py-2 text-sm font-medium bg-gradient-to-r from-cyan-500 to-blue-600 rounded-xl hover:from-cyan-400 hover:to-blue-500 transition-all duration-200 shadow-lg shadow-cyan-500/20"
>
Приєднатися
</Link>
</>
)}
</div> </div>
{/* Mobile menu button */} {/* Mobile menu button */}
@@ -108,13 +154,48 @@ export function Navigation() {
) )
})} })}
<div className="flex gap-3 mt-4 pt-4 border-t border-white/10"> <div className="mt-4 pt-4 border-t border-white/10">
<button className="flex-1 py-3 text-sm text-slate-300 hover:text-white transition-colors rounded-xl hover:bg-white/5"> {isAuthenticated && user ? (
Увійти <div className="space-y-3">
</button> <div className="flex items-center gap-3 px-4">
<button className="flex-1 py-3 text-sm font-medium bg-gradient-to-r from-cyan-500 to-blue-600 rounded-xl"> <div className="w-10 h-10 rounded-full bg-gradient-to-br from-cyan-500/30 to-blue-600/30 flex items-center justify-center">
Приєднатися <span className="font-medium text-cyan-400">
</button> {user.display_name?.[0]?.toUpperCase() || user.email[0].toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{user.display_name || 'User'}
</p>
<p className="text-xs text-slate-400 truncate">{user.email}</p>
</div>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center justify-center gap-2 py-3 text-sm text-red-400 hover:bg-red-500/10 rounded-xl transition-colors"
>
<LogOut className="w-4 h-4" />
Вийти
</button>
</div>
) : (
<div className="flex gap-3">
<Link
href="/login"
onClick={() => setIsOpen(false)}
className="flex-1 py-3 text-sm text-center text-slate-300 hover:text-white transition-colors rounded-xl hover:bg-white/5"
>
Увійти
</Link>
<Link
href="/register"
onClick={() => setIsOpen(false)}
className="flex-1 py-3 text-sm text-center font-medium bg-gradient-to-r from-cyan-500 to-blue-600 rounded-xl"
>
Приєднатися
</Link>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -123,4 +204,3 @@ export function Navigation() {
</nav> </nav>
) )
} }

View File

@@ -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<void>
register: (email: string, password: string, displayName?: string) => Promise<void>
logout: () => Promise<void>
refreshUser: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(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 (
<AuthContext.Provider
value={{
user,
isLoading,
isAuthenticated: !!user,
login,
register,
logout,
refreshUser
}}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

228
apps/web/src/lib/auth.ts Normal file
View File

@@ -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<LoginResponse> {
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<AuthTokens | null> {
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<void> {
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<User | null> {
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<string, string> {
const token = getAccessToken()
if (!token) return {}
return { 'Authorization': `Bearer ${token}` }
}
// Fetch with auth
export async function authFetch(
url: string,
options: RequestInit = {}
): Promise<Response> {
const token = getAccessToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {})
}
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
}

349
docs/security/AUTH_SPEC.md Normal file
View File

@@ -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 <token>`,
* валідують його (прямо або через 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": "<JWT_ACCESS>",
"refresh_token": "<JWT_REFRESH>",
"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": "<JWT_REFRESH>"
}
```
**Response (200):**
```json
{
"access_token": "<NEW_JWT_ACCESS>",
"refresh_token": "<NEW_JWT_REFRESH>",
"token_type": "Bearer",
"expires_in": 1800
}
```
### 4.4. `POST /api/auth/logout`
**Request:**
```json
{
"refresh_token": "<JWT_REFRESH>"
}
```
**Response:**
```json
{
"status": "ok"
}
```
### 4.5. `GET /api/auth/me`
**Headers:** `Authorization: Bearer <access_token>`
**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": "<JWT_ACCESS>"
}
```
**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.

View File

@@ -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();

View 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"]

View 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

View 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)

View 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()

View 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'
"""
)

View 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
)

View 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

View 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]

View 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

View 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}

View 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
)

View 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"}

View 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

View 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()

View 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"))