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