mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 11:19:00 +00:00
Implement M7: auth middleware, rate limiting, CORS, and GUI login flow
Add SHA-256 API key authentication with constant-time comparison, configurable token bucket rate limiter, CORS origin allowlist middleware, and React auth context with login page. Auth info endpoint bootstraps GUI without credentials. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+39
-1
@@ -2,11 +2,36 @@ import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEv
|
||||
|
||||
const BASE = '/api/v1';
|
||||
|
||||
// API key stored in memory (not localStorage for security)
|
||||
let apiKey: string | null = null;
|
||||
|
||||
export function setApiKey(key: string | null) {
|
||||
apiKey = key;
|
||||
}
|
||||
|
||||
export function getApiKey(): string | null {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
headers: { ...authHeaders(), ...init?.headers },
|
||||
...init,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
// Trigger re-auth
|
||||
const event = new CustomEvent('certctl:auth-required');
|
||||
window.dispatchEvent(event);
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(body.message || body.error || `HTTP ${res.status}`);
|
||||
@@ -14,6 +39,19 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Auth
|
||||
export const getAuthInfo = () =>
|
||||
fetch(`${BASE}/auth/info`, { headers: { 'Content-Type': 'application/json' } })
|
||||
.then(r => r.json() as Promise<{ auth_type: string; required: boolean }>);
|
||||
|
||||
export const checkAuth = (key: string) =>
|
||||
fetch(`${BASE}/auth/check`, {
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` },
|
||||
}).then(r => {
|
||||
if (!r.ok) throw new Error('Invalid API key');
|
||||
return r.json() as Promise<{ status: string }>;
|
||||
});
|
||||
|
||||
// Certificates
|
||||
export const getCertificates = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useAuth } from './AuthProvider';
|
||||
import LoginPage from '../pages/LoginPage';
|
||||
|
||||
export default function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { loading, authRequired, authenticated } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-blue-400 mb-2">certctl</h1>
|
||||
<p className="text-sm text-slate-400">Connecting...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (authRequired && !authenticated) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { getAuthInfo, checkAuth, setApiKey } from '../api/client';
|
||||
|
||||
interface AuthState {
|
||||
loading: boolean;
|
||||
authRequired: boolean;
|
||||
authenticated: boolean;
|
||||
authType: string;
|
||||
login: (key: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState>({
|
||||
loading: true,
|
||||
authRequired: false,
|
||||
authenticated: false,
|
||||
authType: 'none',
|
||||
login: async () => {},
|
||||
logout: () => {},
|
||||
error: null,
|
||||
});
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [authRequired, setAuthRequired] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [authType, setAuthType] = useState('none');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Check if server requires auth on mount
|
||||
useEffect(() => {
|
||||
getAuthInfo()
|
||||
.then((info) => {
|
||||
setAuthType(info.auth_type);
|
||||
setAuthRequired(info.required);
|
||||
if (!info.required) {
|
||||
setAuthenticated(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// If auth/info fails, assume no auth required (server may be old version)
|
||||
setAuthenticated(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Listen for 401 events from the API client
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setAuthenticated(false);
|
||||
setApiKey(null);
|
||||
setError('Session expired. Please re-enter your API key.');
|
||||
};
|
||||
window.addEventListener('certctl:auth-required', handler);
|
||||
return () => window.removeEventListener('certctl:auth-required', handler);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (key: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await checkAuth(key);
|
||||
setApiKey(key);
|
||||
setAuthenticated(true);
|
||||
} catch {
|
||||
setError('Invalid API key');
|
||||
throw new Error('Invalid API key');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setApiKey(null);
|
||||
setAuthenticated(false);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ loading, authRequired, authenticated, authType, login, logout, error }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from './AuthProvider';
|
||||
|
||||
const nav = [
|
||||
{ to: '/', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4' },
|
||||
@@ -21,6 +22,8 @@ function Icon({ d }: { d: string }) {
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { authRequired, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
@@ -48,8 +51,19 @@ export default function Layout() {
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-slate-700 text-xs text-slate-500">
|
||||
certctl v1.0-dev
|
||||
<div className="p-4 border-t border-slate-700 flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">certctl v1.0-dev</span>
|
||||
{authRequired && (
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
title="Sign out"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
+23
-17
@@ -2,6 +2,8 @@ import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import AuthProvider from './components/AuthProvider';
|
||||
import AuthGate from './components/AuthGate';
|
||||
import Layout from './components/Layout';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import CertificatesPage from './pages/CertificatesPage';
|
||||
@@ -29,23 +31,27 @@ const queryClient = new QueryClient({
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="certificates" element={<CertificatesPage />} />
|
||||
<Route path="certificates/:id" element={<CertificateDetailPage />} />
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="agents/:id" element={<AgentDetailPage />} />
|
||||
<Route path="jobs" element={<JobsPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="issuers" element={<IssuersPage />} />
|
||||
<Route path="targets" element={<TargetsPage />} />
|
||||
<Route path="audit" element={<AuditPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AuthGate>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="certificates" element={<CertificatesPage />} />
|
||||
<Route path="certificates/:id" element={<CertificateDetailPage />} />
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="agents/:id" element={<AgentDetailPage />} />
|
||||
<Route path="jobs" element={<JobsPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="issuers" element={<IssuersPage />} />
|
||||
<Route path="targets" element={<TargetsPage />} />
|
||||
<Route path="audit" element={<AuditPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthGate>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../components/AuthProvider';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login, error: authError } = useAuth();
|
||||
const [key, setKey] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const error = localError || authError;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!key.trim()) return;
|
||||
setSubmitting(true);
|
||||
setLocalError(null);
|
||||
try {
|
||||
await login(key.trim());
|
||||
} catch {
|
||||
setLocalError('Invalid API key. Check your key and try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-blue-400 mb-2">certctl</h1>
|
||||
<p className="text-sm text-slate-400 uppercase tracking-wider">Certificate Control Plane</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="card p-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="api-key" className="block text-sm font-medium text-slate-300 mb-1.5">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
id="api-key"
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="Enter your API key"
|
||||
autoFocus
|
||||
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2.5 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !key.trim()}
|
||||
className="w-full btn-primary py-2.5 text-sm font-medium rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? 'Verifying...' : 'Sign In'}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-slate-500 text-center">
|
||||
The API key is set via <code className="text-slate-400">CERTCTL_AUTH_SECRET</code> on the server.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user