feat(docker-watcher): phase 12 - hardening
Blue-green zero-downtime deploys, promote flow validation. Dual auth: local (bcrypt + JWT) and OAuth2/OIDC (any provider). Auth middleware, login page, auth settings UI. Structured logging (slog JSON), config export to YAML. Graceful shutdown with deploy draining. Multi-stage Dockerfile and production docker-compose.yml. Swap phase order: Volumes & Env before UI Polish.
This commit is contained in:
+33
-4
@@ -22,13 +22,26 @@ class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthToken(): string | null {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers as Record<string, string>)
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers
|
||||
}
|
||||
headers
|
||||
});
|
||||
|
||||
const envelope: ApiEnvelope<T> = await res.json();
|
||||
@@ -208,4 +221,20 @@ export function regenerateWebhookUrl(): Promise<{ url: string }> {
|
||||
return post<{ url: string }>('/api/settings/webhook-url/regenerate');
|
||||
}
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function login(username: string, password: string): Promise<{ token: string; expires_at: string }> {
|
||||
return post<{ token: string; expires_at: string }>('/api/auth/login', { username, password });
|
||||
}
|
||||
|
||||
export function getCurrentUser(): Promise<{ id: string; username: string; email: string; role: string }> {
|
||||
return get<{ id: string; username: string; email: string; role: string }>('/api/auth/me');
|
||||
}
|
||||
|
||||
// ── Config Export ────────────────────────────────────────────────────
|
||||
|
||||
export function exportConfigUrl(): string {
|
||||
return '/api/config/export';
|
||||
}
|
||||
|
||||
export { ApiError };
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
// Check if we got a token from OIDC callback redirect.
|
||||
const urlToken = $page.url.searchParams.get('token');
|
||||
if (urlToken) {
|
||||
localStorage.setItem('auth_token', urlToken);
|
||||
goto('/');
|
||||
}
|
||||
|
||||
// If already logged in, redirect to dashboard.
|
||||
const existingToken = localStorage.getItem('auth_token');
|
||||
if (existingToken) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
error = '';
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const envelope = await res.json();
|
||||
|
||||
if (!envelope.success) {
|
||||
error = envelope.error ?? 'Login failed';
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('auth_token', envelope.data.token);
|
||||
goto('/');
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Network error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleOIDCLogin() {
|
||||
window.location.href = '/api/auth/oidc/login';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-8 shadow-sm">
|
||||
<div class="mb-6 text-center">
|
||||
<svg class="mx-auto h-10 w-10 text-indigo-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
|
||||
</svg>
|
||||
<h1 class="mt-3 text-xl font-bold text-gray-900">Docker Watcher</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }} class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
required
|
||||
autocomplete="username"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-200"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs">
|
||||
<span class="bg-white px-2 text-gray-400">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={handleOIDCLogin}
|
||||
class="mt-4 w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
Sign in with SSO (OIDC)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -11,7 +11,8 @@
|
||||
const navItems = [
|
||||
{ href: '/settings', label: 'General' },
|
||||
{ href: '/settings/registries', label: 'Registries' },
|
||||
{ href: '/settings/credentials', label: 'Credentials' }
|
||||
{ href: '/settings/credentials', label: 'Credentials' },
|
||||
{ href: '/settings/auth', label: 'Authentication' }
|
||||
];
|
||||
|
||||
let currentPath = $derived($page.url.pathname);
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface AuthSettings {
|
||||
auth_mode: string;
|
||||
oidc_client_id: string;
|
||||
oidc_client_secret: string;
|
||||
oidc_issuer_url: string;
|
||||
oidc_redirect_url: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
let settings = $state<AuthSettings>({
|
||||
auth_mode: 'local',
|
||||
oidc_client_id: '',
|
||||
oidc_client_secret: '',
|
||||
oidc_issuer_url: '',
|
||||
oidc_redirect_url: ''
|
||||
});
|
||||
let users = $state<User[]>([]);
|
||||
let saving = $state(false);
|
||||
let message = $state('');
|
||||
let error = $state('');
|
||||
|
||||
// New user form
|
||||
let newUsername = $state('');
|
||||
let newPassword = $state('');
|
||||
let newEmail = $state('');
|
||||
let newRole = $state('viewer');
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem('auth_token') ?? '';
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${getToken()}`
|
||||
};
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadSettings(), loadUsers()]);
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', { headers: authHeaders() });
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
settings = envelope.data;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load settings';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/users', { headers: authHeaders() });
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
users = envelope.data ?? [];
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load users';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving = true;
|
||||
message = '';
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
message = 'Settings saved';
|
||||
} else {
|
||||
error = envelope.error ?? 'Failed to save';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Network error';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addUser() {
|
||||
if (!newUsername || !newPassword) {
|
||||
error = 'Username and password are required';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/users', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({
|
||||
username: newUsername,
|
||||
password: newPassword,
|
||||
email: newEmail,
|
||||
role: newRole
|
||||
})
|
||||
});
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
newUsername = '';
|
||||
newPassword = '';
|
||||
newEmail = '';
|
||||
newRole = 'viewer';
|
||||
await loadUsers();
|
||||
message = 'User created';
|
||||
} else {
|
||||
error = envelope.error ?? 'Failed to create user';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Network error';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id: string) {
|
||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/auth/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders()
|
||||
});
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
await loadUsers();
|
||||
message = 'User deleted';
|
||||
} else {
|
||||
error = envelope.error ?? 'Failed to delete user';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Network error';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Authentication Settings</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Configure authentication mode and manage users.</p>
|
||||
</div>
|
||||
|
||||
{#if message}
|
||||
<div class="rounded-md bg-green-50 p-3 text-sm text-green-700">{message}</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Auth Mode Toggle -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Authentication Mode</h2>
|
||||
<div class="mt-4 flex gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" bind:group={settings.auth_mode} value="local" class="text-indigo-600" />
|
||||
<span class="text-sm font-medium text-gray-700">Local (username/password)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" bind:group={settings.auth_mode} value="oidc" class="text-indigo-600" />
|
||||
<span class="text-sm font-medium text-gray-700">OIDC (SSO)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OIDC Configuration -->
|
||||
{#if settings.auth_mode === 'oidc'}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">OIDC Provider Configuration</h2>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label for="issuer" class="block text-sm font-medium text-gray-700">Issuer URL</label>
|
||||
<input
|
||||
id="issuer"
|
||||
type="url"
|
||||
bind:value={settings.oidc_issuer_url}
|
||||
placeholder="https://auth.example.com/application/o/docker-watcher/"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_id" class="block text-sm font-medium text-gray-700">Client ID</label>
|
||||
<input
|
||||
id="client_id"
|
||||
type="text"
|
||||
bind:value={settings.oidc_client_id}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
|
||||
<input
|
||||
id="client_secret"
|
||||
type="password"
|
||||
bind:value={settings.oidc_client_secret}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="redirect" class="block text-sm font-medium text-gray-700">Redirect URL</label>
|
||||
<input
|
||||
id="redirect"
|
||||
type="url"
|
||||
bind:value={settings.oidc_redirect_url}
|
||||
placeholder="https://watcher.example.com/api/auth/oidc/callback"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={saveSettings}
|
||||
disabled={saving}
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
|
||||
<!-- Local Users Management -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Local Users</h2>
|
||||
|
||||
{#if users.length > 0}
|
||||
<table class="mt-4 min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Username</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Email</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Role</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Created</th>
|
||||
<th class="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{#each users as user}
|
||||
<tr>
|
||||
<td class="px-3 py-2 text-sm text-gray-900">{user.username}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-500">{user.email || '-'}</td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="inline-flex rounded-full px-2 text-xs font-semibold {user.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-800'}">
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-500">{user.created_at}</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<button
|
||||
onclick={() => deleteUser(user.id)}
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="mt-4 text-sm text-gray-500">No users found.</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 border-t border-gray-200 pt-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Add User</h3>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newUsername}
|
||||
placeholder="Username"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="Password"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
bind:value={newEmail}
|
||||
placeholder="Email (optional)"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<select
|
||||
bind:value={newRole}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onclick={addUser}
|
||||
class="mt-3 rounded-md bg-gray-800 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-gray-900"
|
||||
>
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user