feat(auth): instance-owner gate — owner store, JWT sessions, setup/login UI (onboarding M1+M2)
lib/owner: argon2id credential store, HS256 access/refresh pair with kid, hashed-jti session store, throttler (5 free failures then 4^n backoff). handlers/auth: /api/auth/* + RequireSession/CSRF/GateWebSocket middleware; every /api/* route is now gated (428 in setup mode). --reset-owner recovery. Frontend: AuthScreen (setup/login), useAuth gate hook, gated data hooks + single-flight silent refresh, Owner account section in settings. spec.yaml documents the full surface.
This commit is contained in:
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
|||||||
info:
|
info:
|
||||||
title: Dcntrlized Maestro API
|
title: Dcntrlized Maestro API
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: The API of the Maestro orchestration router. Maestro manages the lifecycle of Dcntrlized service containers and routes inter-service action calls to them.
|
description: The API of the Maestro orchestration router. Maestro manages the lifecycle of Dcntrlized service containers and routes inter-service action calls to them. Every /api/* route except /api/auth/* requires an instance-owner session (cookies issued by the auth endpoints); in setup mode gated routes answer 428 ERR_SETUP_REQUIRED, otherwise 401 ERR_UNAUTHENTICATED. Mutating requests from browser origins are additionally checked against the request host (CSRF).
|
||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
@@ -29,6 +29,65 @@ components:
|
|||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
ApiError:
|
||||||
|
type: object
|
||||||
|
description: The uniform API error shape (onboarding theme M1).
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
example: "ERR_UNAUTHENTICATED"
|
||||||
|
|
||||||
|
AuthStatus:
|
||||||
|
type: object
|
||||||
|
description: The instance-owner gate state; the SPA bootstraps from it.
|
||||||
|
properties:
|
||||||
|
setup_required:
|
||||||
|
type: boolean
|
||||||
|
description: True until the first owner account exists.
|
||||||
|
authenticated:
|
||||||
|
type: boolean
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
Credentials:
|
||||||
|
type: object
|
||||||
|
required: [username, password]
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: "3-64 chars: lowercase letters, digits and . _ - ; must start alphanumeric."
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
minLength: 10
|
||||||
|
|
||||||
|
SessionResponse:
|
||||||
|
type: object
|
||||||
|
description: >-
|
||||||
|
Login/setup/rotation result. The access and refresh tokens ride as
|
||||||
|
HttpOnly SameSite=Lax cookies (maestro_access / maestro_refresh).
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
example: "admin"
|
||||||
|
access_ttl_s:
|
||||||
|
type: integer
|
||||||
|
example: 900
|
||||||
|
|
||||||
|
PasswordChange:
|
||||||
|
type: object
|
||||||
|
required: [current_password, new_password]
|
||||||
|
properties:
|
||||||
|
current_password:
|
||||||
|
type: string
|
||||||
|
new_password:
|
||||||
|
type: string
|
||||||
|
minLength: 10
|
||||||
|
|
||||||
PendingAuthorization:
|
PendingAuthorization:
|
||||||
type: object
|
type: object
|
||||||
description: A first-contact authorization request awaiting the instance owner (M2 §3).
|
description: A first-contact authorization request awaiting the instance owner (M2 §3).
|
||||||
@@ -447,14 +506,135 @@ paths:
|
|||||||
'502':
|
'502':
|
||||||
description: Failed to connect to upstream registry
|
description: Failed to connect to upstream registry
|
||||||
|
|
||||||
|
/api/auth/status:
|
||||||
|
get:
|
||||||
|
summary: Instance-owner gate state (public)
|
||||||
|
tags: ["auth"]
|
||||||
|
description: >-
|
||||||
|
The SPA bootstraps from this endpoint. All other /api/* routes are
|
||||||
|
gated by the instance-owner session (onboarding theme M1): in setup
|
||||||
|
mode they answer 428 ERR_SETUP_REQUIRED, otherwise 401
|
||||||
|
ERR_UNAUTHENTICATED without a valid session.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Gate state
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AuthStatus'
|
||||||
|
'500':
|
||||||
|
description: Internal server error
|
||||||
|
|
||||||
|
/api/auth/setup:
|
||||||
|
post:
|
||||||
|
summary: Create the first owner account (admin)
|
||||||
|
tags: ["auth"]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Credentials'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Owner created; session cookies issued
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SessionResponse'
|
||||||
|
'400':
|
||||||
|
description: Invalid credentials (ERR_INVALID_REQUEST / ERR_INVALID_CREDENTIALS)
|
||||||
|
'409':
|
||||||
|
description: Setup already completed (ERR_SETUP_COMPLETED)
|
||||||
|
'429':
|
||||||
|
description: Rate limited (ERR_RATE_LIMITED)
|
||||||
|
|
||||||
|
/api/auth/login:
|
||||||
|
post:
|
||||||
|
summary: Log in as the instance owner
|
||||||
|
tags: ["auth"]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Credentials'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Session cookies issued
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SessionResponse'
|
||||||
|
'400':
|
||||||
|
description: Invalid JSON payload
|
||||||
|
'401':
|
||||||
|
description: Unknown user or wrong password (ERR_INVALID_CREDENTIALS)
|
||||||
|
'429':
|
||||||
|
description: Rate limited (ERR_RATE_LIMITED) with Retry-After
|
||||||
|
|
||||||
|
/api/auth/refresh:
|
||||||
|
post:
|
||||||
|
summary: Rotate the access/refresh pair
|
||||||
|
tags: ["auth"]
|
||||||
|
description: >-
|
||||||
|
Uses the maestro_refresh cookie. Reuse of an already-rotated jti is a
|
||||||
|
theft signal and revokes every session of the user
|
||||||
|
(ERR_SESSION_REVOKED).
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Fresh pair issued
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SessionResponse'
|
||||||
|
'401':
|
||||||
|
description: Missing/invalid/replayed refresh cookie
|
||||||
|
|
||||||
|
/api/auth/logout:
|
||||||
|
post:
|
||||||
|
summary: Revoke the current refresh token and clear cookies
|
||||||
|
tags: ["auth"]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Logged out (idempotent, bodyless-safe)
|
||||||
|
|
||||||
|
/api/auth/change-password:
|
||||||
|
post:
|
||||||
|
summary: Change the owner's password (session required)
|
||||||
|
tags: ["auth"]
|
||||||
|
description: Revokes every existing session of the user on success.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PasswordChange'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Password changed; all sessions revoked
|
||||||
|
'400':
|
||||||
|
description: Invalid payload or wrong current password
|
||||||
|
'401':
|
||||||
|
description: Not authenticated
|
||||||
|
'429':
|
||||||
|
description: Rate limited (ERR_RATE_LIMITED)
|
||||||
|
|
||||||
/api/ws:
|
/api/ws:
|
||||||
get:
|
get:
|
||||||
summary: WebSocket endpoint for live container events
|
summary: WebSocket endpoint for live container events
|
||||||
tags: ["containers"]
|
tags: ["containers"]
|
||||||
description: Establishes a WebSocket connection that streams container lifecycle events as JSON.
|
description: >-
|
||||||
|
Establishes a WebSocket connection that streams container lifecycle
|
||||||
|
events as JSON. The upgrade is gated by the instance-owner session;
|
||||||
|
anonymous handshakes close with 401 (428 in setup mode).
|
||||||
responses:
|
responses:
|
||||||
'101':
|
'101':
|
||||||
description: Switching Protocols - WebSocket upgrade
|
description: Switching Protocols - WebSocket upgrade
|
||||||
|
'401':
|
||||||
|
description: Not authenticated
|
||||||
|
'428':
|
||||||
|
description: Setup required
|
||||||
|
|
||||||
/api/peers:
|
/api/peers:
|
||||||
get:
|
get:
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import { useMemo, useState } from 'preact/hooks'
|
|||||||
import { useContainerStore } from '@lib/hooks/useContainerStore'
|
import { useContainerStore } from '@lib/hooks/useContainerStore'
|
||||||
import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
|
import { useMasterRegistry } from '@lib/hooks/useMasterRegistry'
|
||||||
import { useTrustStore } from '@lib/hooks/useTrustStore'
|
import { useTrustStore } from '@lib/hooks/useTrustStore'
|
||||||
|
import { useAuth } from '@lib/hooks/useAuth'
|
||||||
import { mergeServices } from '@lib/domain/merge'
|
import { mergeServices } from '@lib/domain/merge'
|
||||||
import { filterServices, serviceCounts } from '@lib/domain/filter'
|
import { filterServices, serviceCounts } from '@lib/domain/filter'
|
||||||
import type { DatabaseStatus, ServiceFilter } from '@lib/types'
|
import type { DatabaseStatus, ServiceFilter } from '@lib/types'
|
||||||
|
import { AuthScreen } from '@components/auth/auth-screen'
|
||||||
import { Header } from '@components/layout/header'
|
import { Header } from '@components/layout/header'
|
||||||
import { Toolbar } from '@components/layout/toolbar'
|
import { Toolbar } from '@components/layout/toolbar'
|
||||||
import { ServiceGrid } from '@components/service/service-grid'
|
import { ServiceGrid } from '@components/service/service-grid'
|
||||||
@@ -32,12 +34,14 @@ import { EmptyState, LoadingState, RegistryErrorState } from '@components/layout
|
|||||||
const DATABASE_NAME = 'dcntrlized-db'
|
const DATABASE_NAME = 'dcntrlized-db'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { containers, status } = useContainerStore()
|
const auth = useAuth()
|
||||||
const registry = useMasterRegistry()
|
const authed = auth.gate === 'ready'
|
||||||
|
const { containers, status } = useContainerStore(authed)
|
||||||
|
const registry = useMasterRegistry(authed)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [filter, setFilter] = useState<ServiceFilter>('all')
|
const [filter, setFilter] = useState<ServiceFilter>('all')
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const trust = useTrustStore(settingsOpen)
|
const trust = useTrustStore(settingsOpen, authed)
|
||||||
|
|
||||||
const services = useMemo(() => {
|
const services = useMemo(() => {
|
||||||
const items = registry.state.kind === 'ok' ? registry.state.items : []
|
const items = registry.state.kind === 'ok' ? registry.state.items : []
|
||||||
@@ -88,6 +92,22 @@ export function App() {
|
|||||||
|
|
||||||
const pendingTrust = trust.load.kind === 'ok' ? trust.load.state.pending.length : 0
|
const pendingTrust = trust.load.kind === 'ok' ? trust.load.state.pending.length : 0
|
||||||
|
|
||||||
|
if (auth.gate === 'loading') {
|
||||||
|
return (
|
||||||
|
<div data-slot="app" className="grid min-h-dvh place-items-center bg-background text-foreground antialiased">
|
||||||
|
<LoadingState />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.gate === 'setup' || auth.gate === 'login') {
|
||||||
|
return (
|
||||||
|
<div data-slot="app" className="bg-background text-foreground antialiased">
|
||||||
|
<AuthScreen mode={auth.gate} onAuthenticated={auth.markReady} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-slot="app" className="min-h-screen bg-background text-foreground antialiased">
|
<div data-slot="app" className="min-h-screen bg-background text-foreground antialiased">
|
||||||
<Header
|
<Header
|
||||||
@@ -137,6 +157,11 @@ export function App() {
|
|||||||
onOpenChange={setSettingsOpen}
|
onOpenChange={setSettingsOpen}
|
||||||
trust={trust.load}
|
trust={trust.load}
|
||||||
onRefreshTrust={trust.refresh}
|
onRefreshTrust={trust.refresh}
|
||||||
|
username={auth.username}
|
||||||
|
onSignOut={() => {
|
||||||
|
setSettingsOpen(false)
|
||||||
|
void auth.signOut()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Dcntrlized
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'preact/hooks'
|
||||||
|
import { BrandMark } from '@components/brand-mark'
|
||||||
|
import { Button } from '@components/ui/button'
|
||||||
|
import { Input } from '@components/ui/input'
|
||||||
|
import { loginOwner, setupOwner } from '@lib/api/auth'
|
||||||
|
|
||||||
|
const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]{2,63}$/
|
||||||
|
|
||||||
|
interface AuthScreenProps {
|
||||||
|
mode: 'setup' | 'login'
|
||||||
|
onAuthenticated: (username: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The instance-owner gate screen: first-run setup wizard and login. */
|
||||||
|
export function AuthScreen({ mode, onAuthenticated }: AuthScreenProps) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const submit = async (event: Event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (!USERNAME_PATTERN.test(username)) {
|
||||||
|
setError('Username must be 3-64 characters: lowercase letters, digits and . _ -')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.length < 10) {
|
||||||
|
setError('Password must be at least 10 characters.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mode === 'setup' && password !== confirm) {
|
||||||
|
setError('Passwords do not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const session =
|
||||||
|
mode === 'setup'
|
||||||
|
? await setupOwner(username, password)
|
||||||
|
: await loginOwner(username, password)
|
||||||
|
onAuthenticated(session.username)
|
||||||
|
} catch (err) {
|
||||||
|
const status = err instanceof Error && 'status' in err ? Number(err.status) : 0
|
||||||
|
if (status === 409) {
|
||||||
|
setError('This instance already has an owner. Log in instead.')
|
||||||
|
} else if (status === 429) {
|
||||||
|
setError('Too many attempts. Wait a moment and try again.')
|
||||||
|
} else {
|
||||||
|
setError(mode === 'setup' ? 'Could not create the account.' : 'Wrong username or password.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-dvh place-items-center bg-background px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="mb-8 flex flex-col items-center gap-3 text-center">
|
||||||
|
<BrandMark className="size-12" />
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight text-foreground">
|
||||||
|
{mode === 'setup' ? 'Set up your Dcntrlized node' : 'Sign in to Maestro'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{mode === 'setup'
|
||||||
|
? 'Create the owner account for this instance. It stays on your hardware.'
|
||||||
|
: 'Use the owner account you created for this instance.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
className="grid gap-4 rounded-xl border border-border bg-card p-6 shadow-sm"
|
||||||
|
aria-label={mode === 'setup' ? 'Create owner account' : 'Log in'}
|
||||||
|
>
|
||||||
|
<label className="grid gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Username</span>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onInput={(e) => setUsername((e.target as HTMLInputElement).value)}
|
||||||
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellcheck={false}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Password</span>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
|
||||||
|
autoComplete={mode === 'setup' ? 'new-password' : 'current-password'}
|
||||||
|
minLength={10}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{mode === 'setup' && (
|
||||||
|
<label className="grid gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Confirm password</span>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={10}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" isDisabled={busy} className="mt-1 w-full">
|
||||||
|
{busy ? 'Working…' : mode === 'setup' ? 'Create account' : 'Sign in'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Dcntrlized
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'preact/hooks'
|
||||||
|
import { KeyRound, LogOut } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@components/ui/button'
|
||||||
|
import { Input } from '@components/ui/input'
|
||||||
|
import { changePassword } from '@lib/api/auth'
|
||||||
|
|
||||||
|
interface AccountSectionProps {
|
||||||
|
username: string
|
||||||
|
onSignOut: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(error: unknown): string {
|
||||||
|
const status = error instanceof Error && 'status' in error ? Number(error.status) : 0
|
||||||
|
if (status === 429) return 'Too many attempts. Wait a moment and try again.'
|
||||||
|
if (status === 400 || status === 401) return 'Current password is wrong.'
|
||||||
|
if (status === 403) return 'Your session was rejected — sign in again.'
|
||||||
|
return 'Could not change the password.'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owner account controls inside the settings dialog: password + logout. */
|
||||||
|
export function AccountSection({ username, onSignOut }: AccountSectionProps) {
|
||||||
|
const [current, setCurrent] = useState('')
|
||||||
|
const [next, setNext] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const submit = async (event: Event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
if (next.length < 10) {
|
||||||
|
setError('New password must be at least 10 characters.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (next !== confirm) {
|
||||||
|
setError('Passwords do not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await changePassword(current, next)
|
||||||
|
toast.success('Password changed', {
|
||||||
|
description: 'All sessions were signed out — sign in with the new password.',
|
||||||
|
})
|
||||||
|
onSignOut()
|
||||||
|
} catch (err) {
|
||||||
|
setError(describeError(err))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section data-slot="settings-section-account" aria-labelledby="settings-account-title" className="min-w-0">
|
||||||
|
<h3
|
||||||
|
id="settings-account-title"
|
||||||
|
className="flex items-center gap-2 text-sm font-semibold tracking-tight text-foreground"
|
||||||
|
>
|
||||||
|
<KeyRound className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
Owner account
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
Signed in as{' '}
|
||||||
|
<span className="font-medium text-foreground">{username}</span>. Changing the password signs
|
||||||
|
out every session, including this one.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4">
|
||||||
|
<form onSubmit={submit} className="grid gap-3" aria-label="Change password">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Current password"
|
||||||
|
value={current}
|
||||||
|
onInput={(e) => setCurrent((e.target as HTMLInputElement).value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="New password"
|
||||||
|
value={next}
|
||||||
|
onInput={(e) => setNext((e.target as HTMLInputElement).value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={10}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
value={confirm}
|
||||||
|
onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={10}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Button type="submit" variant="secondary" size="sm" isDisabled={busy}>
|
||||||
|
{busy ? 'Changing…' : 'Change password'}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => void onSignOut()}>
|
||||||
|
<LogOut aria-hidden="true" data-icon="inline-start" />
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
import { Settings, ShieldCheck, X } from 'lucide-react'
|
import { Settings, ShieldCheck, X } from 'lucide-react'
|
||||||
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
|
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
|
||||||
import { Button } from '@components/ui/button'
|
import { Button } from '@components/ui/button'
|
||||||
|
import { AccountSection } from '@components/settings/account-section'
|
||||||
import { TrustedServicesSection } from '@components/service/trusted-services'
|
import { TrustedServicesSection } from '@components/service/trusted-services'
|
||||||
import type { TrustLoadState } from '@lib/hooks/useTrustStore'
|
import type { TrustLoadState } from '@lib/hooks/useTrustStore'
|
||||||
|
|
||||||
@@ -26,9 +27,18 @@ interface SettingsDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
trust: TrustLoadState
|
trust: TrustLoadState
|
||||||
onRefreshTrust: () => void
|
onRefreshTrust: () => void
|
||||||
|
username: string
|
||||||
|
onSignOut: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsDialog({ open, onOpenChange, trust, onRefreshTrust }: SettingsDialogProps) {
|
export function SettingsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
trust,
|
||||||
|
onRefreshTrust,
|
||||||
|
username,
|
||||||
|
onSignOut,
|
||||||
|
}: SettingsDialogProps) {
|
||||||
return (
|
return (
|
||||||
<ModalOverlay
|
<ModalOverlay
|
||||||
isOpen={open}
|
isOpen={open}
|
||||||
@@ -63,6 +73,8 @@ export function SettingsDialog({ open, onOpenChange, trust, onRefreshTrust }: Se
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid min-w-0 gap-6 px-5 py-5">
|
<div className="grid min-w-0 gap-6 px-5 py-5">
|
||||||
|
<AccountSection username={username} onSignOut={onSignOut} />
|
||||||
|
|
||||||
<section data-slot="settings-section-trusted" aria-labelledby="settings-trusted-title" className="min-w-0">
|
<section data-slot="settings-section-trusted" aria-labelledby="settings-trusted-title" className="min-w-0">
|
||||||
<h3
|
<h3
|
||||||
id="settings-trusted-title"
|
id="settings-trusted-title"
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Dcntrlized
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { get, post } from './client'
|
||||||
|
import type { AuthStatus, SessionResponse } from '../types'
|
||||||
|
|
||||||
|
/** Fetches the instance-owner gate state; the SPA bootstraps from it. */
|
||||||
|
export function fetchAuthStatus(): Promise<AuthStatus> {
|
||||||
|
return get<AuthStatus>('/api/auth/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the first owner account and starts its session. */
|
||||||
|
export function setupOwner(username: string, password: string): Promise<SessionResponse> {
|
||||||
|
return post<SessionResponse>('/api/auth/setup', { username, password })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logs in as the instance owner. */
|
||||||
|
export function loginOwner(username: string, password: string): Promise<SessionResponse> {
|
||||||
|
return post<SessionResponse>('/api/auth/login', { username, password })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rotates the access/refresh pair using the refresh cookie. */
|
||||||
|
export function rotateSession(): Promise<SessionResponse> {
|
||||||
|
return post<SessionResponse>('/api/auth/refresh')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revokes the current refresh token and clears both cookies. */
|
||||||
|
export function logoutOwner(): Promise<void> {
|
||||||
|
return post<void>('/api/auth/logout')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changes the owner's password; every existing session is revoked. */
|
||||||
|
export function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||||
|
return post<void>('/api/auth/change-password', {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -35,8 +35,37 @@ async function readJson<T>(res: Response): Promise<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
/** In-flight session rotation shared by concurrent 401s (no refresh storms). */
|
||||||
|
let refreshing: Promise<boolean> | null = null
|
||||||
|
|
||||||
|
function refreshSession(): Promise<boolean> {
|
||||||
|
refreshing ??= (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/refresh', { method: 'POST' })
|
||||||
|
return res.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
refreshing = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return refreshing
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit, retried = false): Promise<T> {
|
||||||
const res = await fetch(path, init)
|
const res = await fetch(path, init)
|
||||||
|
if (!path.startsWith('/api/auth/')) {
|
||||||
|
// Silent session recovery: rotate once and replay the original call.
|
||||||
|
if (res.status === 401 && !retried) {
|
||||||
|
if (await refreshSession()) {
|
||||||
|
return request<T>(path, init, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Surface gate loss to the app shell so it drops back to login/setup.
|
||||||
|
if (res.status === 401 || res.status === 428) {
|
||||||
|
window.dispatchEvent(new CustomEvent('maestro:gate-lost', { detail: res.status }))
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new ApiError(`Request failed (${res.status})`, res.status)
|
throw new ApiError(`Request failed (${res.status})`, res.status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (C) 2026 Dcntrlized
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'preact/hooks'
|
||||||
|
import { fetchAuthStatus, logoutOwner, rotateSession } from '@lib/api/auth'
|
||||||
|
|
||||||
|
/** Which screen the app shell should render. */
|
||||||
|
export type AuthGate = 'loading' | 'setup' | 'login' | 'ready'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the instance-owner gate: bootstraps from /api/auth/status, silently
|
||||||
|
* recovers an expired access token through refresh rotation, and listens for
|
||||||
|
* gate-loss events emitted by the API client (401/428 on gated routes).
|
||||||
|
*/
|
||||||
|
export function useAuth() {
|
||||||
|
const [gate, setGate] = useState<AuthGate>('loading')
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
|
||||||
|
const sync = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const status = await fetchAuthStatus()
|
||||||
|
if (status.setup_required) {
|
||||||
|
setGate('setup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status.authenticated && status.username) {
|
||||||
|
setUsername(status.username)
|
||||||
|
setGate('ready')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Access token expired but a valid refresh cookie may survive — try to
|
||||||
|
// resume the session before showing the login form.
|
||||||
|
try {
|
||||||
|
const session = await rotateSession()
|
||||||
|
setUsername(session.username)
|
||||||
|
setGate('ready')
|
||||||
|
} catch {
|
||||||
|
setGate('login')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setGate('login')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void sync()
|
||||||
|
|
||||||
|
const onGateLost = () => void sync()
|
||||||
|
window.addEventListener('maestro:gate-lost', onGateLost)
|
||||||
|
return () => window.removeEventListener('maestro:gate-lost', onGateLost)
|
||||||
|
}, [sync])
|
||||||
|
|
||||||
|
const markReady = useCallback((user: string) => {
|
||||||
|
setUsername(user)
|
||||||
|
setGate('ready')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const signOut = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await logoutOwner()
|
||||||
|
} finally {
|
||||||
|
setGate('login')
|
||||||
|
setUsername('')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { gate, username, markReady, signOut }
|
||||||
|
}
|
||||||
@@ -17,20 +17,25 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'preact/hooks'
|
import { useEffect, useState } from 'preact/hooks'
|
||||||
import { store, type ContainerStoreState } from '../ws/registry'
|
import { store, type ContainerStoreState } from '../ws/registry'
|
||||||
import { connectWebSocket } from '../ws/client'
|
import { connectWebSocket, disconnectWebSocket } from '../ws/client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribes the component to the live container store and owns the single
|
* Subscribes the component to the live container store and owns the single
|
||||||
* WebSocket connection for the app.
|
* WebSocket connection for the app. While `enabled` is false (owner not yet
|
||||||
|
* authenticated) no connection is attempted and any existing one is torn down.
|
||||||
*/
|
*/
|
||||||
export function useContainerStore(): ContainerStoreState {
|
export function useContainerStore(enabled = true): ContainerStoreState {
|
||||||
const [state, setState] = useState<ContainerStoreState>(() => store.getState())
|
const [state, setState] = useState<ContainerStoreState>(() => store.getState())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
disconnectWebSocket()
|
||||||
|
return
|
||||||
|
}
|
||||||
connectWebSocket()
|
connectWebSocket()
|
||||||
const unsubscribe = store.subscribe(() => setState(store.getState()))
|
const unsubscribe = store.subscribe(() => setState(store.getState()))
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [])
|
}, [enabled])
|
||||||
|
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export type RegistryLoadState =
|
|||||||
| { kind: 'ok'; items: RegistryItem[] }
|
| { kind: 'ok'; items: RegistryItem[] }
|
||||||
| { kind: 'error'; message: string }
|
| { kind: 'error'; message: string }
|
||||||
|
|
||||||
export function useMasterRegistry() {
|
export function useMasterRegistry(enabled = true) {
|
||||||
const [state, setState] = useState<RegistryLoadState>({ kind: 'loading' })
|
const [state, setState] = useState<RegistryLoadState>({ kind: 'loading' })
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -38,8 +38,9 @@ export function useMasterRegistry() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
void load()
|
void load()
|
||||||
}, [load])
|
}, [enabled, load])
|
||||||
|
|
||||||
return { state, retry: load }
|
return { state, retry: load }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function errorMessage(error: unknown): string {
|
|||||||
* mutation never flashes a loading spinner over live rows; mutations also
|
* mutation never flashes a loading spinner over live rows; mutations also
|
||||||
* trigger an immediate refresh through the same hook.
|
* trigger an immediate refresh through the same hook.
|
||||||
*/
|
*/
|
||||||
export function useTrustStore(open: boolean) {
|
export function useTrustStore(open: boolean, enabled = true) {
|
||||||
const [load, setLoad] = useState<TrustLoadState>({ kind: 'loading' })
|
const [load, setLoad] = useState<TrustLoadState>({ kind: 'loading' })
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
@@ -49,15 +49,17 @@ export function useTrustStore(open: boolean) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
void refresh()
|
void refresh()
|
||||||
}, [refresh])
|
}, [enabled, refresh])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
void refresh()
|
void refresh()
|
||||||
}, open ? 10_000 : 120_000)
|
}, open ? 10_000 : 120_000)
|
||||||
return () => window.clearInterval(interval)
|
return () => window.clearInterval(interval)
|
||||||
}, [open, refresh])
|
}, [open, enabled, refresh])
|
||||||
|
|
||||||
return { load, refresh }
|
return { load, refresh }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,3 +134,17 @@ export interface PeerAttestation {
|
|||||||
kid: string
|
kid: string
|
||||||
issued_at: string
|
issued_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The instance-owner gate state served by GET /api/auth/status (onboarding M1). */
|
||||||
|
export interface AuthStatus {
|
||||||
|
setup_required: boolean
|
||||||
|
authenticated: boolean
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login/setup/rotation result; tokens ride as HttpOnly cookies. */
|
||||||
|
export interface SessionResponse {
|
||||||
|
username: string
|
||||||
|
role: string
|
||||||
|
access_ttl_s: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,12 +18,17 @@
|
|||||||
import { store } from './registry'
|
import { store } from './registry'
|
||||||
import type { Container } from '../types'
|
import type { Container } from '../types'
|
||||||
|
|
||||||
const URL = 'ws://127.0.0.1:8080/api/ws'
|
|
||||||
const RECONNECT_DELAY = 3000
|
const RECONNECT_DELAY = 3000
|
||||||
|
|
||||||
let socket: WebSocket | null = null
|
let socket: WebSocket | null = null
|
||||||
let reconnectTimer: number | undefined
|
let reconnectTimer: number | undefined
|
||||||
|
|
||||||
|
/** Derives the same-origin WebSocket URL (wss:// behind a TLS proxy). */
|
||||||
|
function wsURL(): string {
|
||||||
|
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
return `${scheme}//${window.location.host}/api/ws`
|
||||||
|
}
|
||||||
|
|
||||||
export function connectWebSocket(): void {
|
export function connectWebSocket(): void {
|
||||||
if (
|
if (
|
||||||
socket &&
|
socket &&
|
||||||
@@ -32,7 +37,7 @@ export function connectWebSocket(): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const ws = new WebSocket(URL)
|
const ws = new WebSocket(wsURL())
|
||||||
socket = ws
|
socket = ws
|
||||||
store.setStatus('connecting')
|
store.setStatus('connecting')
|
||||||
|
|
||||||
@@ -57,6 +62,17 @@ export function connectWebSocket(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tears down the socket and stops the reconnect loop (auth gate lost). */
|
||||||
|
export function disconnectWebSocket(): void {
|
||||||
|
window.clearTimeout(reconnectTimer)
|
||||||
|
if (socket) {
|
||||||
|
socket.onclose = null
|
||||||
|
socket.close()
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
store.setStatus('closed')
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleReconnect(): void {
|
function scheduleReconnect(): void {
|
||||||
window.clearTimeout(reconnectTimer)
|
window.clearTimeout(reconnectTimer)
|
||||||
reconnectTimer = window.setTimeout(connectWebSocket, RECONNECT_DELAY)
|
reconnectTimer = window.setTimeout(connectWebSocket, RECONNECT_DELAY)
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
// Copyright (C) 2026 Dcntrlized
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// Package auth implements Maestro's instance-owner gate (onboarding theme M1):
|
||||||
|
// /api/auth/* endpoints plus the RequireSession middleware that guards every
|
||||||
|
// other /api/* route and the WebSocket handshake. Deliberately independent of
|
||||||
|
// the fleet — the owner store lives on the local data root.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
accessCookie = "maestro_access"
|
||||||
|
refreshCookie = "maestro_refresh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errorBody is the uniform API error shape.
|
||||||
|
type errorBody struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, code string) {
|
||||||
|
var body errorBody
|
||||||
|
body.Error.Code = code
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimited emits the 429 with a Retry-After hint from the throttler.
|
||||||
|
func (s *Service) rateLimited(w http.ResponseWriter, identifier string) {
|
||||||
|
if wait := s.throttle.RetryAfter(identifier); wait > 0 {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(int((wait+time.Second-1)/time.Second)))
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusTooManyRequests, "ERR_RATE_LIMITED")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service wires the owner gate together.
|
||||||
|
type Service struct {
|
||||||
|
owners owner.Store
|
||||||
|
tokens *owner.TokenService
|
||||||
|
sessions *owner.FileSessionStore
|
||||||
|
throttle *owner.Throttler
|
||||||
|
|
||||||
|
secureCookies bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the auth service from its stores.
|
||||||
|
func New(owners owner.Store, tokens *owner.TokenService, sessions *owner.FileSessionStore) *Service {
|
||||||
|
return &Service{
|
||||||
|
owners: owners,
|
||||||
|
tokens: tokens,
|
||||||
|
sessions: sessions,
|
||||||
|
throttle: owner.NewThrottler(),
|
||||||
|
secureCookies: os.Getenv("COOKIE_SECURE") == "true",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type credentials struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type passwordChange struct {
|
||||||
|
CurrentPassword string `json:"current_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupRequired reports whether the instance still needs its first owner.
|
||||||
|
func (s *Service) SetupRequired(ctx context.Context) bool {
|
||||||
|
count, err := s.owners.Count(ctx)
|
||||||
|
return err != nil || count == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status serves GET /api/auth/status — public; the SPA bootstraps from it.
|
||||||
|
func (s *Service) Status(w http.ResponseWriter, r *http.Request) {
|
||||||
|
setupRequired := s.SetupRequired(r.Context())
|
||||||
|
username := ""
|
||||||
|
if !setupRequired {
|
||||||
|
if c, err := r.Cookie(accessCookie); err == nil {
|
||||||
|
if sub, _, err := s.tokens.VerifyAccess(c.Value); err == nil {
|
||||||
|
username = sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"setup_required": setupRequired,
|
||||||
|
"authenticated": username != "",
|
||||||
|
"username": username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup serves POST /api/auth/setup — creates the first owner (admin). In
|
||||||
|
// setup mode only this and status respond anywhere in /api.
|
||||||
|
func (s *Service) Setup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var creds credentials
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "ERR_INVALID_REQUEST")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count, _ := s.owners.Count(r.Context()); count > 0 {
|
||||||
|
writeError(w, http.StatusConflict, "ERR_SETUP_COMPLETED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.throttle.Allow(creds.Username) {
|
||||||
|
s.rateLimited(w, creds.Username)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
o, err := s.owners.Create(r.Context(), creds.Username, creds.Password, true)
|
||||||
|
if err != nil {
|
||||||
|
s.throttle.Fail(creds.Username)
|
||||||
|
writeError(w, http.StatusBadRequest, "ERR_INVALID_CREDENTIALS")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Info("Owner account created: %s", o.Username)
|
||||||
|
s.issueSession(w, r, o.Username, o.Role())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login serves POST /api/auth/login.
|
||||||
|
func (s *Service) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var creds credentials
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "ERR_INVALID_REQUEST")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.throttle.Allow(creds.Username) {
|
||||||
|
s.rateLimited(w, creds.Username)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
o, err := s.owners.Verify(r.Context(), creds.Username, creds.Password)
|
||||||
|
if err != nil || o == nil {
|
||||||
|
s.throttle.Fail(creds.Username)
|
||||||
|
writeError(w, http.StatusUnauthorized, "ERR_INVALID_CREDENTIALS")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.throttle.Reset(creds.Username)
|
||||||
|
s.issueSession(w, r, o.Username, o.Role())
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueSession mints the pair, persists the refresh row and sets both cookies.
|
||||||
|
func (s *Service) issueSession(w http.ResponseWriter, r *http.Request, username, role string) {
|
||||||
|
pair, err := s.tokens.MintPair(username, role)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to mint session pair: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.sessions.Store(username, pair.RefreshJTI, time.Now().Add(pair.RefreshTTL)); err != nil {
|
||||||
|
logger.Error("Failed to persist session: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, s.cookie(accessCookie, pair.Access, pair.AccessTTL))
|
||||||
|
http.SetCookie(w, s.cookie(refreshCookie, pair.Refresh, pair.RefreshTTL))
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"username": pair.Username,
|
||||||
|
"role": pair.Role,
|
||||||
|
"access_ttl_s": int(pair.AccessTTL.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) cookie(name, value string, ttl time.Duration) *http.Cookie {
|
||||||
|
return &http.Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: value,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: s.secureCookies,
|
||||||
|
MaxAge: int(ttl.Seconds()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh serves POST /api/auth/refresh — rotates the pair. Reuse of an
|
||||||
|
// already-rotated (or unknown/revoked) jti revokes every session of the user.
|
||||||
|
func (s *Service) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := r.Cookie(refreshCookie)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub, jti, err := s.tokens.VerifyRefresh(c.Value)
|
||||||
|
if err != nil {
|
||||||
|
clearCookies(w)
|
||||||
|
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.sessions.Valid(jti) {
|
||||||
|
// Reuse of a rotated/revoked jti is a theft signal: burn everything.
|
||||||
|
_ = s.sessions.RevokeAllForUser(sub)
|
||||||
|
clearCookies(w)
|
||||||
|
writeError(w, http.StatusUnauthorized, "ERR_SESSION_REVOKED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner's role may have changed since login; re-read it.
|
||||||
|
role := "member"
|
||||||
|
if o, err := s.owners.Get(r.Context(), sub); err == nil && o != nil {
|
||||||
|
role = o.Role()
|
||||||
|
}
|
||||||
|
|
||||||
|
pair, err := s.tokens.MintPair(sub, role)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.sessions.Store(sub, pair.RefreshJTI, time.Now().Add(pair.RefreshTTL)); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.sessions.Revoke(jti); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, s.cookie(accessCookie, pair.Access, pair.AccessTTL))
|
||||||
|
http.SetCookie(w, s.cookie(refreshCookie, pair.Refresh, pair.RefreshTTL))
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"username": sub,
|
||||||
|
"role": role,
|
||||||
|
"access_ttl_s": int(pair.AccessTTL.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout serves POST /api/auth/logout — revokes the refresh jti, clears both.
|
||||||
|
func (s *Service) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c, err := r.Cookie(refreshCookie); err == nil && c.Value != "" {
|
||||||
|
if _, jti, err := s.tokens.VerifyRefresh(c.Value); err == nil {
|
||||||
|
if err := s.sessions.Revoke(jti); err != nil {
|
||||||
|
logger.Warn("Logout revoke failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearCookies(w)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "logged-out"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCookies(w http.ResponseWriter) {
|
||||||
|
for _, name := range []string{accessCookie, refreshCookie} {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: name, Value: "", Path: "/", HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode, MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword serves POST /api/auth/change-password — verifies the current
|
||||||
|
// password, rehashes, then rotates all of the user's sessions.
|
||||||
|
func (s *Service) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sub := r.Header.Get("X-Maestro-User")
|
||||||
|
if sub == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var change passwordChange
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&change); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "ERR_INVALID_REQUEST")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store, ok := s.owners.(*owner.FileOwnerStore)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusInternalServerError, "ERR_INTERNAL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.throttle.Allow(sub) {
|
||||||
|
s.rateLimited(w, sub)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := store.ChangePassword(r.Context(), sub, change.CurrentPassword, change.NewPassword); err != nil {
|
||||||
|
s.throttle.Fail(sub)
|
||||||
|
writeError(w, http.StatusBadRequest, "ERR_INVALID_CREDENTIALS")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Every existing session dies: the password changed under them.
|
||||||
|
if err := s.sessions.RevokeAllForUser(sub); err != nil {
|
||||||
|
logger.Warn("Session revoke after password change failed: %v", err)
|
||||||
|
}
|
||||||
|
clearCookies(w)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "password-changed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireSession gates every /api/* route except the /api/auth/* endpoints.
|
||||||
|
// Setup mode short-circuits everything else with 428; unauthenticated calls
|
||||||
|
// get the uniform 401; mutating requests pass the CSRF origin check.
|
||||||
|
func (s *Service) RequireSession(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.SetupRequired(r.Context()) {
|
||||||
|
writeError(w, http.StatusPreconditionRequired, "ERR_SETUP_REQUIRED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := r.Cookie(accessCookie)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub, _, err := s.tokens.VerifyAccess(c.Value)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Header.Set("X-Maestro-User", sub)
|
||||||
|
if mutating(r.Method) && !sameOriginOK(r) {
|
||||||
|
writeError(w, http.StatusForbidden, "ERR_CSRF")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GateWebSocket validates the access cookie before an upgrade is attempted;
|
||||||
|
// rejected handshakes close with 401 instead of switching protocols.
|
||||||
|
func (s *Service) GateWebSocket(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.SetupRequired(r.Context()) {
|
||||||
|
writeError(w, http.StatusPreconditionRequired, "ERR_SETUP_REQUIRED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := r.Cookie(accessCookie)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, _, err := s.tokens.VerifyAccess(c.Value); err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "ERR_UNAUTHENTICATED")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutating(method string) bool {
|
||||||
|
switch method {
|
||||||
|
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameOriginOK enforces D5's second line: on mutating requests, when Origin or
|
||||||
|
// Referer is present, its host must equal the request host. Non-browser
|
||||||
|
// clients omit both and pass (cookies are SameSite=Lax regardless).
|
||||||
|
func sameOriginOK(r *http.Request) bool {
|
||||||
|
header := r.Header.Get("Origin")
|
||||||
|
if header == "" {
|
||||||
|
header = r.Header.Get("Referer")
|
||||||
|
}
|
||||||
|
if header == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
u, err := url.Parse(header)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(u.Host, r.Host)
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
// Copyright (C) 2026 Dcntrlized
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statusBody mirrors GET /api/auth/status's snake_case response.
|
||||||
|
type statusBody struct {
|
||||||
|
SetupRequired bool `json:"setup_required"`
|
||||||
|
Authenticated bool `json:"authenticated"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T) (*Service, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
owners, err := owner.NewFileOwnerStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tokens, err := owner.NewTokenService(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sessions, err := owner.NewFileSessionStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := New(owners, tokens, sessions)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/auth/status", s.Status)
|
||||||
|
mux.HandleFunc("/api/auth/setup", s.Setup)
|
||||||
|
mux.HandleFunc("/api/auth/login", s.Login)
|
||||||
|
mux.HandleFunc("/api/auth/refresh", s.Refresh)
|
||||||
|
mux.HandleFunc("/api/auth/logout", s.Logout)
|
||||||
|
mux.Handle("/api/auth/change-password", s.RequireSession(http.HandlerFunc(s.ChangePassword)))
|
||||||
|
mux.Handle("/api/gated", s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})))
|
||||||
|
return s, mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func do(t *testing.T, mux http.Handler, method, target, body string, hdr map[string]string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var req *http.Request
|
||||||
|
if body == "" {
|
||||||
|
req = httptest.NewRequest(method, target, nil)
|
||||||
|
} else {
|
||||||
|
req = httptest.NewRequest(method, target, strings.NewReader(body))
|
||||||
|
}
|
||||||
|
for k, v := range hdr {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func cookieValue(t *testing.T, rec *httptest.ResponseRecorder, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if c.Name == name && c.Value != "" {
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("cookie %q not set", name)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusSetupModeAndAfterSetup(t *testing.T) {
|
||||||
|
s, h := newTestServer(t)
|
||||||
|
|
||||||
|
rec := do(t, h, http.MethodGet, "/api/auth/status", "", nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("status pre-setup: %d", rec.Code)
|
||||||
|
}
|
||||||
|
var st statusBody
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !st.SetupRequired || st.Authenticated {
|
||||||
|
t.Fatalf("pre-setup status wrong: %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gated route in setup mode → 428 ERR_SETUP_REQUIRED.
|
||||||
|
gate := s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/containers", nil)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
gate.ServeHTTP(rec2, req)
|
||||||
|
if rec2.Code != 428 {
|
||||||
|
t.Fatalf("gated pre-setup = %d, want 428", rec2.Code)
|
||||||
|
}
|
||||||
|
var e errorBody
|
||||||
|
if err := json.Unmarshal(rec2.Body.Bytes(), &e); err == nil && e.Error.Code != "ERR_SETUP_REQUIRED" {
|
||||||
|
t.Fatalf("code = %q", e.Error.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run setup through the API, then log in to obtain a session.
|
||||||
|
setupRec := do(t, h, http.MethodPost, "/api/auth/setup",
|
||||||
|
`{"username":"admin","password":"setup-password"}`, nil)
|
||||||
|
if setupRec.Code != 200 {
|
||||||
|
t.Fatalf("post-gate setup = %d", setupRec.Code)
|
||||||
|
}
|
||||||
|
loginRec := do(t, h, http.MethodPost, "/api/auth/login",
|
||||||
|
`{"username":"admin","password":"setup-password"}`, nil)
|
||||||
|
if loginRec.Code != 200 {
|
||||||
|
t.Fatalf("post-setup login = %d", loginRec.Code)
|
||||||
|
}
|
||||||
|
rec3 := do(t, h, http.MethodGet, "/api/auth/status", "", map[string]string{
|
||||||
|
"Cookie": "maestro_access=" + cookieValue(t, loginRec, accessCookie),
|
||||||
|
})
|
||||||
|
var st2 statusBody
|
||||||
|
if err := json.Unmarshal(rec3.Body.Bytes(), &st2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st2.SetupRequired || !st2.Authenticated || st2.Username != "admin" {
|
||||||
|
t.Fatalf("post-setup status: %+v", st2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupFlow(t *testing.T) {
|
||||||
|
_, h := newTestServer(t)
|
||||||
|
|
||||||
|
rec := do(t, h, http.MethodPost, "/api/auth/setup",
|
||||||
|
`{"username":"admin","password":"first-owner-pass"}`, nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("setup = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if c := cookieValue(t, rec, accessCookie); c == "" {
|
||||||
|
t.Fatal("no session after setup")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second setup attempt → 409.
|
||||||
|
rec2 := do(t, h, http.MethodPost, "/api/auth/setup",
|
||||||
|
`{"username":"evil","password":"hijack-attempt"}`, nil)
|
||||||
|
if rec2.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("second setup = %d, want 409", rec2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginLogoutAndGatedAccess(t *testing.T) {
|
||||||
|
s, h := newTestServer(t)
|
||||||
|
do(t, h, http.MethodPost, "/api/auth/setup",
|
||||||
|
`{"username":"admin","password":"first-owner-pass"}`, nil)
|
||||||
|
|
||||||
|
// Bad password.
|
||||||
|
rec := do(t, h, http.MethodPost, "/api/auth/login",
|
||||||
|
`{"username":"admin","password":"wrong-password"}`, nil)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("bad login = %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good login.
|
||||||
|
rec = do(t, h, http.MethodPost, "/api/auth/login",
|
||||||
|
`{"username":"admin","password":"first-owner-pass"}`, nil)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("login = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var lr struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &lr); err != nil || lr.Role != "admin" || lr.Username != "admin" {
|
||||||
|
t.Fatalf("login response: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
access := cookieValue(t, rec, accessCookie)
|
||||||
|
refresh := cookieValue(t, rec, refreshCookie)
|
||||||
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
if !c.HttpOnly || c.SameSite != http.SameSiteLaxMode {
|
||||||
|
t.Fatalf("cookie %s not HttpOnly/Lax", c.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gated access with the access token.
|
||||||
|
gate := s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(r.Header.Get("X-Maestro-User")))
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/containers", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: accessCookie, Value: access})
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
gate.ServeHTTP(rec2, req)
|
||||||
|
if rec2.Code != 200 || rec2.Body.String() != "admin" {
|
||||||
|
t.Fatalf("gated with token = %d %q", rec2.Code, rec2.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout revokes and clears both cookies.
|
||||||
|
rec3 := do(t, h, http.MethodPost, "/api/auth/logout",
|
||||||
|
"", map[string]string{"Cookie": "maestro_refresh=" + refresh})
|
||||||
|
if rec3.Code != 200 {
|
||||||
|
t.Fatalf("logout = %d", rec3.Code)
|
||||||
|
}
|
||||||
|
for _, c := range rec3.Result().Cookies() {
|
||||||
|
if c.MaxAge >= 0 {
|
||||||
|
t.Fatalf("cookie %s not cleared (max-age=%d)", c.Name, c.MaxAge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh token is dead post-logout.
|
||||||
|
rec4 := do(t, h, http.MethodPost, "/api/auth/refresh",
|
||||||
|
"", map[string]string{"Cookie": "maestro_refresh=" + refresh})
|
||||||
|
if rec4.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("refresh after logout = %d", rec4.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshRotationAndReuseDetection(t *testing.T) {
|
||||||
|
_, h := newTestServer(t)
|
||||||
|
setupRec := do(t, h, http.MethodPost, "/api/auth/setup",
|
||||||
|
`{"username":"admin","password":"first-owner-pass"}`, nil)
|
||||||
|
firstRefresh := cookieValue(t, setupRec, refreshCookie)
|
||||||
|
|
||||||
|
// Rotate.
|
||||||
|
rec := do(t, h, http.MethodPost, "/api/auth/refresh",
|
||||||
|
"", map[string]string{"Cookie": "maestro_refresh=" + firstRefresh})
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("refresh = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
secondRefresh := cookieValue(t, rec, refreshCookie)
|
||||||
|
if secondRefresh == firstRefresh {
|
||||||
|
t.Fatal("refresh token not rotated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay of the old token → 401 AND kill-switch revokes everything,
|
||||||
|
// including the session minted during rotation.
|
||||||
|
replay := do(t, h, http.MethodPost, "/api/auth/refresh",
|
||||||
|
"", map[string]string{"Cookie": "maestro_refresh=" + firstRefresh})
|
||||||
|
if replay.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("replay = %d, want 401", replay.Code)
|
||||||
|
}
|
||||||
|
rec2 := do(t, h, http.MethodPost, "/api/auth/refresh",
|
||||||
|
"", map[string]string{"Cookie": "maestro_refresh=" + secondRefresh})
|
||||||
|
if rec2.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("post-replay rotation = %d, want 401 (kill switch)", rec2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSRFOriginCheck(t *testing.T) {
|
||||||
|
s, h := newTestServer(t)
|
||||||
|
do(t, h, http.MethodPost, "/api/auth/setup",
|
||||||
|
`{"username":"admin","password":"first-owner-pass"}`, nil)
|
||||||
|
loginRec := do(t, h, http.MethodPost, "/api/auth/login",
|
||||||
|
`{"username":"admin","password":"first-owner-pass"}`, nil)
|
||||||
|
access := cookieValue(t, loginRec, accessCookie)
|
||||||
|
|
||||||
|
handler := s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// POST from a foreign origin without cookies → still 401 (auth first).
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/containers/install", nil)
|
||||||
|
req.Header.Set("Origin", "https://evil.example")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("unauthenticated foreign POST = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated but foreign origin → 403.
|
||||||
|
req = httptest.NewRequest(http.MethodDelete, "/api/containers/uninstall", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: accessCookie, Value: access})
|
||||||
|
req.Header.Set("Origin", "https://evil.example")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("foreign-origin POST = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same host, no Origin header (curl-style) → allowed.
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/x", nil)
|
||||||
|
req.Host = "localhost:8080"
|
||||||
|
req.AddCookie(&http.Cookie{Name: accessCookie, Value: access})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("same-host POST = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET is exempt.
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/api/x", nil)
|
||||||
|
req.Header.Set("Origin", "https://evil.example")
|
||||||
|
req.AddCookie(&http.Cookie{Name: accessCookie, Value: access})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("GET exempt = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThrottleLockout(t *testing.T) {
|
||||||
|
_, h := newTestServer(t)
|
||||||
|
do(t, h, http.MethodPost, "/api/auth/setup",
|
||||||
|
`{"username":"admin","password":"first-owner-pass"}`, nil)
|
||||||
|
|
||||||
|
for i := 0; i < 6; i++ { // 5 free + 1 that crosses the throttle threshold
|
||||||
|
rec := do(t, h, http.MethodPost, "/api/auth/login",
|
||||||
|
`{"username":"admin","password":"wrong-password"}`, nil)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("attempt %d = %d", i+1, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec := do(t, h, http.MethodPost, "/api/auth/login",
|
||||||
|
`{"username":"admin","password":"first-owner-pass"}`, nil)
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("locked-out login = %d, want 429", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Retry-After"); got == "" {
|
||||||
|
t.Fatal("no Retry-After header on 429")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangePasswordRevokesSessions(t *testing.T) {
|
||||||
|
_, h := newTestServer(t)
|
||||||
|
do(t, h, http.MethodPost, "/api/auth/setup",
|
||||||
|
`{"username":"admin","password":"first-owner-pass"}`, nil)
|
||||||
|
loginRec := do(t, h, http.MethodPost, "/api/auth/login",
|
||||||
|
`{"username":"admin","password":"first-owner-pass"}`, nil)
|
||||||
|
access := cookieValue(t, loginRec, accessCookie)
|
||||||
|
refresh := cookieValue(t, loginRec, refreshCookie)
|
||||||
|
|
||||||
|
// Missing current password → 401.
|
||||||
|
rec := do(t, h, http.MethodPost, "/api/auth/change-password",
|
||||||
|
`{"new_password":"rotated-password"}`,
|
||||||
|
map[string]string{"Cookie": "maestro_access=" + access})
|
||||||
|
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("missing currentPassword = %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = do(t, h, http.MethodPost, "/api/auth/change-password",
|
||||||
|
`{"current_password":"first-owner-pass","new_password":"rotated-password"}`,
|
||||||
|
map[string]string{"Cookie": "maestro_access=" + access})
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("change-password = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// All sessions revoked — including the caller's.
|
||||||
|
rec2 := do(t, h, http.MethodPost, "/api/auth/refresh",
|
||||||
|
"", map[string]string{"Cookie": "maestro_refresh=" + refresh})
|
||||||
|
if rec2.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("refresh after password change = %d", rec2.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old password dead, new one works.
|
||||||
|
rec3 := do(t, h, http.MethodPost, "/api/auth/login",
|
||||||
|
`{"username":"admin","password":"first-owner-pass"}`, nil)
|
||||||
|
if rec3.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("old password login = %d", rec3.Code)
|
||||||
|
}
|
||||||
|
rec4 := do(t, h, http.MethodPost, "/api/auth/login",
|
||||||
|
`{"username":"admin","password":"rotated-password"}`, nil)
|
||||||
|
if rec4.Code != 200 {
|
||||||
|
t.Fatalf("new password login = %d: %s", rec4.Code, rec4.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGateWebSocketRejectsAnonymousUpgrade(t *testing.T) {
|
||||||
|
s, h := newTestServer(t)
|
||||||
|
do(t, h, http.MethodPost, "/api/auth/setup",
|
||||||
|
`{"username":"admin","password":"first-owner-pass"}`, nil)
|
||||||
|
|
||||||
|
handler := s.GateWebSocket(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Error("upgrade handler reached without credentials")
|
||||||
|
}))
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/ws", nil)
|
||||||
|
req.Header.Set("Upgrade", "websocket")
|
||||||
|
req.Header.Set("Connection", "Upgrade")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("anonymous ws = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
// Copyright (C) 2026 Dcntrlized
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
// Package owner is Maestro's self-contained instance-owner identity store
|
||||||
|
// (onboarding theme M1, D1/D2): argon2id password hashes in a JSON file at the
|
||||||
|
// data root, deliberately independent of the fleet database and auth service —
|
||||||
|
// logging into the orchestrator must work exactly when everything else is
|
||||||
|
// down. The Store interface keeps the middleware swappable for a DB- or
|
||||||
|
// LDAP-backed implementation later.
|
||||||
|
package owner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Owner is one dashboard user. The setup wizard creates the first admin;
|
||||||
|
// further owners seed through the same store/API path (no management UI yet).
|
||||||
|
type Owner struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role renders the JWT role claim: admins gatekeep trust decisions.
|
||||||
|
func (o *Owner) Role() string {
|
||||||
|
if o.IsAdmin {
|
||||||
|
return "admin"
|
||||||
|
}
|
||||||
|
return "member"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store persists owners. FileOwnerStore is the on-disk implementation; a
|
||||||
|
// DB-backed store can slot in later without touching the middleware.
|
||||||
|
type Store interface {
|
||||||
|
Count(ctx context.Context) (int, error)
|
||||||
|
Verify(ctx context.Context, username, password string) (*Owner, error)
|
||||||
|
Create(ctx context.Context, username, password string, isAdmin bool) (*Owner, error)
|
||||||
|
// Get looks a user up without credentials (role refresh during token
|
||||||
|
// rotation).
|
||||||
|
Get(ctx context.Context, username string) (*Owner, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
usernameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{2,63}$`)
|
||||||
|
// Mirrors the dcntrlized-auth conventions.
|
||||||
|
MinPasswordLen = 10
|
||||||
|
|
||||||
|
ErrInvalidUsername = fmt.Errorf("username must match %s", usernameRe.String())
|
||||||
|
ErrWeakPassword = fmt.Errorf("password must be at least %d characters", MinPasswordLen)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateCredentials enforces the shared-auth conventions: lowercase
|
||||||
|
// usernames of 3–64 chars, passwords of at least MinPasswordLen chars.
|
||||||
|
func ValidateCredentials(username, password string) error {
|
||||||
|
if !usernameRe.MatchString(username) {
|
||||||
|
return ErrInvalidUsername
|
||||||
|
}
|
||||||
|
if len(password) < MinPasswordLen {
|
||||||
|
return ErrWeakPassword
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
argonTime = 3
|
||||||
|
argonMemory = 64 * 1024
|
||||||
|
argonThreads = 2
|
||||||
|
argonKeyLen = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashPassword derives an argon2id PHC-style hash string.
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
salt := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", fmt.Errorf("read salt: %w", err)
|
||||||
|
}
|
||||||
|
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||||
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||||
|
argon2.Version, argonMemory, argonTime, argonThreads,
|
||||||
|
base64.RawStdEncoding.EncodeToString(salt),
|
||||||
|
base64.RawStdEncoding.EncodeToString(key)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword compares a password against a PHC-style argon2id hash in
|
||||||
|
// constant time.
|
||||||
|
func VerifyPassword(hash, password string) bool {
|
||||||
|
parts := strings.Split(hash, "$")
|
||||||
|
// "", "argon2id", "v=19", "m=…,t=…,p=…", salt, key
|
||||||
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
got := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, uint32(len(want)))
|
||||||
|
return subtle.ConstantTimeCompare(got, want) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword rehashes the owner's password and bumps updated_at.
|
||||||
|
func (s *FileOwnerStore) ChangePassword(ctx context.Context, username, currentPassword, newPassword string) (*Owner, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
file, owners, err := s.read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range owners.Owners {
|
||||||
|
o := &owners.Owners[i]
|
||||||
|
if o.Username != username {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !VerifyPassword(o.PasswordHash, currentPassword) {
|
||||||
|
return nil, os.ErrPermission
|
||||||
|
}
|
||||||
|
hash, err := HashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
o.PasswordHash = hash
|
||||||
|
o.UpdatedAt = time.Now().UTC()
|
||||||
|
return o, s.write(file, owners)
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
type ownerFile struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Owners []Owner `json:"owners"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileOwnerStore persists owners to <dataRoot>/maestro/owners.json with
|
||||||
|
// atomic writes and chmod 600.
|
||||||
|
type FileOwnerStore struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileOwnerStore targets dir/owners.json (dir = <dataRoot>/maestro).
|
||||||
|
func NewFileOwnerStore(dir string) (*FileOwnerStore, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("create owner store directory: %w", err)
|
||||||
|
}
|
||||||
|
return &FileOwnerStore{path: filepath.Join(dir, "owners.json")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileOwnerStore) read() (string, *ownerFile, error) {
|
||||||
|
file := ownerFile{Version: 1}
|
||||||
|
data, err := os.ReadFile(s.path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return s.path, &file, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return s.path, nil, fmt.Errorf("read owners file: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &file); err != nil {
|
||||||
|
return s.path, nil, fmt.Errorf("parse owners file: %w", err)
|
||||||
|
}
|
||||||
|
return s.path, &file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// write atomically replaces the owners file; the mutex serializes writers and
|
||||||
|
// concurrent setups end up idempotent through Count's check in the caller.
|
||||||
|
func (s *FileOwnerStore) write(path string, owners *ownerFile) error {
|
||||||
|
data, err := json.MarshalIndent(owners, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode owners file: %w", err)
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write owners file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
return fmt.Errorf("replace owners file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileOwnerStore) Count(_ context.Context) (int, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
_, owners, err := s.read()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(owners.Owners), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileOwnerStore) Create(_ context.Context, username, password string, isAdmin bool) (*Owner, error) {
|
||||||
|
if err := ValidateCredentials(username, password); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
file, owners, err := s.read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, o := range owners.Owners {
|
||||||
|
if o.Username == username {
|
||||||
|
return nil, os.ErrExist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hash, err := HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
owner := Owner{Username: username, PasswordHash: hash, IsAdmin: isAdmin, CreatedAt: now, UpdatedAt: now}
|
||||||
|
owners.Owners = append(owners.Owners, owner)
|
||||||
|
if err := s.write(file, owners); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &owner, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileOwnerStore) Verify(_ context.Context, username, password string) (*Owner, error) {
|
||||||
|
o, err := s.Get(context.Background(), username)
|
||||||
|
if err != nil || o == nil {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
if VerifyPassword(o.PasswordHash, password) {
|
||||||
|
return o, nil
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileOwnerStore) Get(_ context.Context, username string) (*Owner, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
_, owners, err := s.read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, o := range owners.Owners {
|
||||||
|
if o.Username == username {
|
||||||
|
return &o, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Copyright (C) 2026 Dcntrlized
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package owner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestStore(t *testing.T) *FileOwnerStore {
|
||||||
|
t.Helper()
|
||||||
|
s, err := NewFileOwnerStore(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashAndVerifyPassword(t *testing.T) {
|
||||||
|
hash, err := HashPassword("correct horse battery")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(hash, "$argon2id$v=19$m=65536,t=3,p=2$") {
|
||||||
|
t.Fatalf("hash missing argon2id PHC prefix: %s", hash)
|
||||||
|
}
|
||||||
|
if !VerifyPassword(hash, "correct horse battery") {
|
||||||
|
t.Fatal("correct password rejected")
|
||||||
|
}
|
||||||
|
if VerifyPassword(hash, "wrong password!") {
|
||||||
|
t.Fatal("wrong password accepted")
|
||||||
|
}
|
||||||
|
if VerifyPassword("garbage", "x") {
|
||||||
|
t.Fatal("garbage hash accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCredentials(t *testing.T) {
|
||||||
|
good := [][2]string{{"admin", "longenough123"}, {"a.b-c_d1", "0123456789"}}
|
||||||
|
for _, c := range good {
|
||||||
|
if err := ValidateCredentials(c[0], c[1]); err != nil {
|
||||||
|
t.Errorf("%q/%q rejected: %v", c[0], c[1], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bad := [][2]string{
|
||||||
|
{"Admin", "0123456789"}, // uppercase
|
||||||
|
{"-lead", "0123456789"}, // bad first char
|
||||||
|
{"ab", "0123456789"}, // too short
|
||||||
|
{"okname", "short"}, // weak password
|
||||||
|
}
|
||||||
|
for _, c := range bad {
|
||||||
|
if err := ValidateCredentials(c[0], c[1]); err == nil {
|
||||||
|
t.Errorf("%q/%q accepted", c[0], c[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreRoundtripAndMultiUser(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
if n, _ := s.Count(ctx); n != 0 {
|
||||||
|
t.Fatalf("fresh store Count = %d", n)
|
||||||
|
}
|
||||||
|
first, err := s.Create(ctx, "admin", "first-owner-pass", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !first.IsAdmin || first.Role() != "admin" {
|
||||||
|
t.Fatalf("first owner should be admin, got %+v", first)
|
||||||
|
}
|
||||||
|
second, err := s.Create(ctx, "member", "second-owner-pass", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if second.Role() != "member" {
|
||||||
|
t.Fatalf("second owner role = %q", second.Role())
|
||||||
|
}
|
||||||
|
if n, _ := s.Count(ctx); n != 2 {
|
||||||
|
t.Fatalf("Count = %d, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, err := s.Verify(ctx, "admin", "first-owner-pass"); err != nil || got.Username != "admin" {
|
||||||
|
t.Fatalf("Verify admin: %v %+v", err, got)
|
||||||
|
}
|
||||||
|
if _, err := s.Verify(ctx, "admin", "wrong"); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("wrong password: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Create(ctx, "admin", "whatever-123", false); !errors.Is(err, os.ErrExist) {
|
||||||
|
t.Fatalf("duplicate create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
changed, err := s.ChangePassword(ctx, "admin", "first-owner-pass", "rotated-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if changed.UpdatedAt.Equal(changed.CreatedAt) {
|
||||||
|
t.Fatal("updated_at not bumped")
|
||||||
|
}
|
||||||
|
if _, err := s.Verify(ctx, "admin", "first-owner-pass"); err == nil {
|
||||||
|
t.Fatal("old password still valid")
|
||||||
|
}
|
||||||
|
if _, err := s.Verify(ctx, "admin", "rotated-password"); err != nil {
|
||||||
|
t.Fatalf("new password rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentCreateIsIdempotent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, 16)
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := s.Create(ctx, "wizard", "setup-password", true); err != nil && !errors.Is(err, os.ErrExist) {
|
||||||
|
errs <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if n, _ := s.Count(ctx); n != 1 {
|
||||||
|
t.Fatalf("Count after concurrent setup = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerFilePermissions(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
s, err := NewFileOwnerStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.Create(ctx, "admin", "perm-check-pass", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(dir, "owners.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||||
|
t.Fatalf("owners.json mode = %o, want 600", perm)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Copyright (C) 2026 Dcntrlized
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package owner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session is one persisted refresh-token record. Only the SHA-256 hash of the
|
||||||
|
// jti is stored — never the raw value, mirroring the auth service's
|
||||||
|
// token_hash convention.
|
||||||
|
type Session struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
JTIHash string `json:"jti_hash"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
RevokedAt *time.Time `json:"revoked_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionFile struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Sessions []Session `json:"sessions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashJTI renders the stored digest of a raw refresh jti.
|
||||||
|
func HashJTI(jti string) string {
|
||||||
|
sum := sha256.Sum256([]byte(jti))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileSessionStore persists refresh-token state to
|
||||||
|
// <dataRoot>/maestro/sessions.json so sessions survive restarts, can be
|
||||||
|
// revoked on logout, and support rotation + reuse detection.
|
||||||
|
type FileSessionStore struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileSessionStore targets dir/sessions.json.
|
||||||
|
func NewFileSessionStore(dir string) (*FileSessionStore, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("create session store directory: %w", err)
|
||||||
|
}
|
||||||
|
return &FileSessionStore{path: filepath.Join(dir, "sessions.json")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileSessionStore) load() (*sessionFile, error) {
|
||||||
|
file := sessionFile{Version: 1}
|
||||||
|
data, err := os.ReadFile(s.path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &file, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read sessions file: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &file); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse sessions file: %w", err)
|
||||||
|
}
|
||||||
|
return &file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// save atomically replaces the file after pruning expired/revoked rows.
|
||||||
|
func (s *FileSessionStore) save(file *sessionFile) error {
|
||||||
|
now := time.Now()
|
||||||
|
live := file.Sessions[:0]
|
||||||
|
for _, s := range file.Sessions {
|
||||||
|
if s.RevokedAt == nil && now.Before(s.ExpiresAt) {
|
||||||
|
live = append(live, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.Sessions = live
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(file, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode sessions file: %w", err)
|
||||||
|
}
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write sessions file: %w", err)
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, s.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store records a freshly minted refresh token.
|
||||||
|
func (s *FileSessionStore) Store(username, jti string, expiresAt time.Time) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
file, err := s.load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file.Sessions = append(file.Sessions, Session{
|
||||||
|
Username: username,
|
||||||
|
JTIHash: HashJTI(jti),
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
ExpiresAt: expiresAt.UTC(),
|
||||||
|
})
|
||||||
|
return s.save(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid reports whether the jti maps to a live (stored, unrevoked, unexpired)
|
||||||
|
// session row.
|
||||||
|
func (s *FileSessionStore) Valid(jti string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
file, err := s.load()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hash := HashJTI(jti)
|
||||||
|
now := time.Now()
|
||||||
|
for _, sess := range file.Sessions {
|
||||||
|
if sess.JTIHash == hash {
|
||||||
|
return sess.RevokedAt == nil && now.Before(sess.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke marks the jti's row revoked (logout / rotation).
|
||||||
|
func (s *FileSessionStore) Revoke(jti string) error {
|
||||||
|
return s.revokeWhere(func(sess Session) bool { return sess.JTIHash == HashJTI(jti) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeAllForUser kills every live session row of the user — the response to
|
||||||
|
// refresh-token reuse (theft signal).
|
||||||
|
func (s *FileSessionStore) RevokeAllForUser(username string) error {
|
||||||
|
return s.revokeWhere(func(sess Session) bool { return sess.Username == username })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileSessionStore) revokeWhere(match func(Session) bool) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
file, err := s.load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
changed := false
|
||||||
|
for i := range file.Sessions {
|
||||||
|
if match(file.Sessions[i]) && file.Sessions[i].RevokedAt == nil {
|
||||||
|
t := now
|
||||||
|
file.Sessions[i].RevokedAt = &t
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.save(file)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Copyright (C) 2026 Dcntrlized
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package owner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionsStoreValidRevoke(t *testing.T) {
|
||||||
|
s, err := NewFileSessionStore(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jti := "jti-1"
|
||||||
|
if err := s.Store("admin", jti, time.Now().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !s.Valid(jti) {
|
||||||
|
t.Fatal("fresh session invalid")
|
||||||
|
}
|
||||||
|
if s.Valid("missing") {
|
||||||
|
t.Fatal("unknown session valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Revoke(jti); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Valid(jti) {
|
||||||
|
t.Fatal("revoked session still valid")
|
||||||
|
}
|
||||||
|
// Re-revoke must not fail (logout idempotence).
|
||||||
|
if err := s.Revoke(jti); err != nil {
|
||||||
|
t.Fatalf("double revoke: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeAllForUser(t *testing.T) {
|
||||||
|
s, _ := NewFileSessionStore(t.TempDir())
|
||||||
|
|
||||||
|
for _, j := range []string{"a", "b", "c"} {
|
||||||
|
if err := s.Store("admin", j, time.Now().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.Store("other", "d", time.Now().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.RevokeAllForUser("admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, j := range []string{"a", "b", "c"} {
|
||||||
|
if s.Valid(j) {
|
||||||
|
t.Fatalf("session %q survived revoke-all", j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !s.Valid("d") {
|
||||||
|
t.Fatal("other user's session revoked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiredSessionsPrunedOnSave(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s, _ := NewFileSessionStore(dir)
|
||||||
|
|
||||||
|
// Expired session: stored but never valid.
|
||||||
|
expired := sha256.Sum256([]byte("expired"))
|
||||||
|
jti := hex.EncodeToString(expired[:])
|
||||||
|
if err := s.Store("admin", jti, time.Now().Add(-time.Minute)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Valid(jti) {
|
||||||
|
t.Fatal("expired session valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune happens on save: force a write and confirm the expired entry
|
||||||
|
// disappears from disk.
|
||||||
|
if err := s.Store("admin", "fresh", time.Now().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "sessions.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bytes.Contains(data, []byte(HashJTI(jti))) {
|
||||||
|
t.Fatal("expired session not pruned from disk")
|
||||||
|
}
|
||||||
|
if !bytes.Contains(data, []byte(HashJTI("fresh"))) {
|
||||||
|
t.Fatal("live session missing from disk")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Copyright (C) 2026 Dcntrlized
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package owner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// In-process throttling for login, setup and password change, keyed by
|
||||||
|
// username — mirrors dcntrlized-auth's handlers/auth/throttle.go: the first
|
||||||
|
// throttleMaxAttempts failures inside the window pass freely (humans mistype),
|
||||||
|
// beyond that each further failure multiplies the wait (5s×4^n, capped at
|
||||||
|
// 24h). Maestro is single-instance, so process-local state suffices.
|
||||||
|
|
||||||
|
const (
|
||||||
|
throttleMaxAttempts = 5
|
||||||
|
throttleWindow = 15 * time.Minute
|
||||||
|
throttleBackoffBase = 5 * time.Second
|
||||||
|
throttleMultiplier = 4
|
||||||
|
throttleMaxWait = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
type throttleAttempt struct {
|
||||||
|
count int
|
||||||
|
last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Throttler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
state map[string]*throttleAttempt
|
||||||
|
nowFn func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewThrottler builds an empty throttler.
|
||||||
|
func NewThrottler() *Throttler {
|
||||||
|
return &Throttler{state: map[string]*throttleAttempt{}, nowFn: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func backoffFor(count int) time.Duration {
|
||||||
|
delay := throttleBackoffBase
|
||||||
|
for i := 1; i < count-throttleMaxAttempts && delay < throttleMaxWait; i++ {
|
||||||
|
delay *= throttleMultiplier
|
||||||
|
}
|
||||||
|
if delay > throttleMaxWait {
|
||||||
|
delay = throttleMaxWait
|
||||||
|
}
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow reports whether an attempt for identifier may proceed right now.
|
||||||
|
func (t *Throttler) Allow(identifier string) bool {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
a, ok := t.state[identifier]
|
||||||
|
if !ok || a.count <= throttleMaxAttempts {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if t.nowFn().Sub(a.last) > throttleWindow {
|
||||||
|
delete(t.state, identifier)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return t.nowFn().Sub(a.last) >= backoffFor(a.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail records a failed attempt.
|
||||||
|
func (t *Throttler) Fail(identifier string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
a, ok := t.state[identifier]
|
||||||
|
now := t.nowFn()
|
||||||
|
if !ok || now.Sub(a.last) > throttleWindow {
|
||||||
|
t.state[identifier] = &throttleAttempt{count: 1, last: now}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.count++
|
||||||
|
a.last = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears the identifier after a success.
|
||||||
|
func (t *Throttler) Reset(identifier string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
delete(t.state, identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryAfter reports how long the identifier must wait before the next
|
||||||
|
// attempt is permitted; 0 when attempts are currently allowed. Used for the
|
||||||
|
// 429 Retry-After header.
|
||||||
|
func (t *Throttler) RetryAfter(identifier string) time.Duration {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
a, ok := t.state[identifier]
|
||||||
|
if !ok || a.count <= throttleMaxAttempts {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
now := t.nowFn()
|
||||||
|
if now.Sub(a.last) > throttleWindow {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if wait := backoffFor(a.count) - now.Sub(a.last); wait > 0 {
|
||||||
|
return wait
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// Copyright (C) 2026 Dcntrlized
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package owner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AccessTokenTTL is the stateless access-token lifetime.
|
||||||
|
AccessTokenTTL = 15 * time.Minute
|
||||||
|
// RefreshTokenTTL is the persisted refresh-token lifetime.
|
||||||
|
RefreshTokenTTL = 7 * 24 * time.Hour
|
||||||
|
// Issuer tags every minted token; verification rejects others.
|
||||||
|
Issuer = "maestro"
|
||||||
|
|
||||||
|
claimType = "type"
|
||||||
|
claimKid = "kid"
|
||||||
|
claimJti = "jti"
|
||||||
|
|
||||||
|
typeAccess = "access"
|
||||||
|
typeRefresh = "refresh"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidToken = errors.New("invalid token")
|
||||||
|
ErrWrongType = errors.New("token has the wrong type")
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenPair is one login/rotation result: the cookie payloads plus the refresh
|
||||||
|
// jti (its raw value lives only in the cookies and the caller's response).
|
||||||
|
type TokenPair struct {
|
||||||
|
Access string
|
||||||
|
Refresh string
|
||||||
|
RefreshJTI string
|
||||||
|
AccessTTL time.Duration
|
||||||
|
RefreshTTL time.Duration
|
||||||
|
Username string
|
||||||
|
Role string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenService mints and verifies the HS256 access/refresh session pair. The
|
||||||
|
// signing secret is a per-instance random key on disk (`session.key`, chmod
|
||||||
|
// 600); kid = sha256(secret)[:16] so a rotated secret keeps old access tokens
|
||||||
|
// valid through their remaining TTL (grace window).
|
||||||
|
type TokenService struct {
|
||||||
|
dir string
|
||||||
|
|
||||||
|
key []byte
|
||||||
|
kid string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTokenService loads or creates dir/session.key.
|
||||||
|
func NewTokenService(dir string) (*TokenService, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("create token directory: %w", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "session.key")
|
||||||
|
key, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
key = make([]byte, 32)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
return nil, fmt.Errorf("generate session key: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, key, 0o600); err != nil {
|
||||||
|
return nil, fmt.Errorf("persist session key: %w", err)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("read session key: %w", err)
|
||||||
|
} else if len(key) < 32 {
|
||||||
|
return nil, fmt.Errorf("session key at %s is too short", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256(key)
|
||||||
|
return &TokenService{dir: dir, key: key, kid: hex.EncodeToString(sum[:])[:16]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kid returns the active signing-key identifier.
|
||||||
|
func (t *TokenService) Kid() string { return t.kid }
|
||||||
|
|
||||||
|
func (t *TokenService) claims(sub, typ, role string, ttl time.Duration, jti string) jwt.MapClaims {
|
||||||
|
now := time.Now()
|
||||||
|
c := jwt.MapClaims{
|
||||||
|
"sub": sub,
|
||||||
|
"iss": Issuer,
|
||||||
|
"iat": now.Unix(),
|
||||||
|
"exp": now.Add(ttl).Unix(),
|
||||||
|
claimType: typ,
|
||||||
|
claimKid: t.kid,
|
||||||
|
}
|
||||||
|
if role != "" {
|
||||||
|
c["role"] = role
|
||||||
|
}
|
||||||
|
if jti != "" {
|
||||||
|
c[claimJti] = jti
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TokenService) sign(c jwt.MapClaims) (string, error) {
|
||||||
|
return jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(t.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MintPair issues a fresh access/refresh pair for the owner. The refresh jti
|
||||||
|
// must be recorded by the session store before the cookie is served.
|
||||||
|
func (t *TokenService) MintPair(username, role string) (*TokenPair, error) {
|
||||||
|
jti := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(jti); err != nil {
|
||||||
|
return nil, fmt.Errorf("generate jti: %w", err)
|
||||||
|
}
|
||||||
|
raw := hex.EncodeToString(jti)
|
||||||
|
|
||||||
|
access, err := t.sign(t.claims(username, typeAccess, role, AccessTokenTTL, ""))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sign access token: %w", err)
|
||||||
|
}
|
||||||
|
refresh, err := t.sign(t.claims(username, typeRefresh, "", RefreshTokenTTL, raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sign refresh token: %w", err)
|
||||||
|
}
|
||||||
|
return &TokenPair{
|
||||||
|
Access: access, Refresh: refresh, RefreshJTI: raw,
|
||||||
|
AccessTTL: AccessTokenTTL, RefreshTTL: RefreshTokenTTL,
|
||||||
|
Username: username, Role: role,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse validates signature (constant-time HMAC compare inside the library),
|
||||||
|
// issuer and expiry, returning the claims.
|
||||||
|
func (t *TokenService) parse(token string) (jwt.MapClaims, error) {
|
||||||
|
parsed, err := jwt.Parse(token, func(tk *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := tk.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return t.key, nil
|
||||||
|
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(Issuer), jwt.WithExpirationRequired())
|
||||||
|
if err != nil || !parsed.Valid {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
if kid, _ := claims[claimKid].(string); kid != "" && kid != t.kid && !hmac.Equal([]byte(kid), []byte(t.kid)) {
|
||||||
|
// A different kid means an unknown secret generation — refuse. The
|
||||||
|
// same-kid path is the normal case; empty kid predates kid tagging.
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAccess validates an access token and returns the username + role.
|
||||||
|
func (t *TokenService) VerifyAccess(token string) (username, role string, err error) {
|
||||||
|
claims, err := t.parse(token)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if typ, _ := claims[claimType].(string); typ != typeAccess {
|
||||||
|
return "", "", ErrWrongType
|
||||||
|
}
|
||||||
|
sub, _ := claims["sub"].(string)
|
||||||
|
role, _ = claims["role"].(string)
|
||||||
|
if sub == "" {
|
||||||
|
return "", "", ErrInvalidToken
|
||||||
|
}
|
||||||
|
return sub, role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyRefresh validates a refresh token and returns username + jti.
|
||||||
|
func (t *TokenService) VerifyRefresh(token string) (username, jti string, err error) {
|
||||||
|
claims, err := t.parse(token)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if typ, _ := claims[claimType].(string); typ != typeRefresh {
|
||||||
|
return "", "", ErrWrongType
|
||||||
|
}
|
||||||
|
sub, _ := claims["sub"].(string)
|
||||||
|
jti, _ = claims[claimJti].(string)
|
||||||
|
if sub == "" || jti == "" {
|
||||||
|
return "", "", ErrInvalidToken
|
||||||
|
}
|
||||||
|
return sub, jti, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// Copyright (C) 2026 Dcntrlized
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package owner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestTokenService(t *testing.T) *TokenService {
|
||||||
|
t.Helper()
|
||||||
|
s, err := NewTokenService(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMintAndVerifyPair(t *testing.T) {
|
||||||
|
s := newTestTokenService(t)
|
||||||
|
|
||||||
|
pair, err := s.MintPair("admin", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pair.Access == "" || pair.Refresh == "" || pair.RefreshJTI == "" {
|
||||||
|
t.Fatal("empty field in pair")
|
||||||
|
}
|
||||||
|
if pair.Username != "admin" || pair.Role != "admin" {
|
||||||
|
t.Fatalf("pair identity wrong: %+v", pair)
|
||||||
|
}
|
||||||
|
if pair.AccessTTL != AccessTokenTTL || pair.RefreshTTL != RefreshTokenTTL {
|
||||||
|
t.Fatalf("pair TTLs wrong: %+v", pair)
|
||||||
|
}
|
||||||
|
for _, tok := range []string{pair.Access, pair.Refresh} {
|
||||||
|
parts := strings.Split(tok, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Fatalf("token not JWS compact: %q", tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
username, role, err := s.VerifyAccess(pair.Access)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyAccess: %v", err)
|
||||||
|
}
|
||||||
|
if username != "admin" || role != "admin" {
|
||||||
|
t.Fatalf("access claims: %q/%q", username, role)
|
||||||
|
}
|
||||||
|
rUser, rJTI, err := s.VerifyRefresh(pair.Refresh)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyRefresh: %v", err)
|
||||||
|
}
|
||||||
|
if rUser != "admin" || rJTI != pair.RefreshJTI {
|
||||||
|
t.Fatalf("refresh claims: %q/%q", rUser, rJTI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenTypeIsEnforced(t *testing.T) {
|
||||||
|
s := newTestTokenService(t)
|
||||||
|
pair, _ := s.MintPair("admin", "admin")
|
||||||
|
|
||||||
|
if _, _, err := s.VerifyAccess(pair.Refresh); err == nil {
|
||||||
|
t.Fatal("refresh token accepted as access")
|
||||||
|
}
|
||||||
|
if _, _, err := s.VerifyRefresh(pair.Access); err == nil {
|
||||||
|
t.Fatal("access token accepted as refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func craftToken(t *testing.T, s *TokenService, claims jwt.MapClaims) string {
|
||||||
|
t.Helper()
|
||||||
|
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiredTokensRejected(t *testing.T) {
|
||||||
|
s := newTestTokenService(t)
|
||||||
|
|
||||||
|
mk := func(typ string, ttl time.Duration) string {
|
||||||
|
now := time.Now()
|
||||||
|
return craftToken(t, s, jwt.MapClaims{
|
||||||
|
"iss": Issuer, "sub": "admin", "type": typ, "kid": s.kid,
|
||||||
|
"iat": now.Add(-2 * ttl).Unix(), "exp": now.Add(-ttl).Unix(),
|
||||||
|
"jti": "expired-" + typ,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := s.VerifyAccess(mk(typeAccess, AccessTokenTTL)); err == nil {
|
||||||
|
t.Fatal("expired access accepted")
|
||||||
|
}
|
||||||
|
if _, _, err := s.VerifyRefresh(mk(typeRefresh, RefreshTokenTTL)); err == nil {
|
||||||
|
t.Fatal("expired refresh accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForgedTokensRejected(t *testing.T) {
|
||||||
|
s := newTestTokenService(t)
|
||||||
|
pair, _ := s.MintPair("admin", "admin")
|
||||||
|
|
||||||
|
// Signed by a different key.
|
||||||
|
other := newTestTokenService(t)
|
||||||
|
forged, _ := other.MintPair("admin", "admin")
|
||||||
|
if _, _, err := s.VerifyAccess(forged.Access); err == nil {
|
||||||
|
t.Fatal("foreign-signed access accepted")
|
||||||
|
}
|
||||||
|
if _, _, err := s.VerifyRefresh(forged.Refresh); err == nil {
|
||||||
|
t.Fatal("foreign-signed refresh accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tampered payload.
|
||||||
|
if _, _, err := s.VerifyAccess(strings.TrimSuffix(pair.Access, "x") + "y"); err == nil {
|
||||||
|
t.Fatal("tampered token accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrong issuer but right key.
|
||||||
|
now := time.Now()
|
||||||
|
badIss := craftToken(t, s, jwt.MapClaims{
|
||||||
|
"iss": "someone-else", "sub": "admin", "type": typeAccess, "kid": s.kid,
|
||||||
|
"iat": now.Unix(), "exp": now.Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
if _, _, err := s.VerifyAccess(badIss); err == nil {
|
||||||
|
t.Fatal("wrong-issuer token accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown kid (foreign key generation).
|
||||||
|
unknownKid := craftToken(t, s, jwt.MapClaims{
|
||||||
|
"iss": Issuer, "sub": "admin", "type": typeAccess, "kid": "deadbeefdeadbeef",
|
||||||
|
"iat": now.Unix(), "exp": now.Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
if _, _, err := s.VerifyAccess(unknownKid); err == nil {
|
||||||
|
t.Fatal("unknown-kid token accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyPersistsAcrossRestarts(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s1, err := NewTokenService(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pair, err := s1.MintPair("admin", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s2, err := NewTokenService(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s1.Kid() != s2.Kid() {
|
||||||
|
t.Fatalf("kid changed across restarts: %s vs %s", s1.Kid(), s2.Kid())
|
||||||
|
}
|
||||||
|
if _, _, err := s2.VerifyAccess(pair.Access); err != nil {
|
||||||
|
t.Fatalf("token invalid after restart: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThrottlerBackoffAndReset(t *testing.T) {
|
||||||
|
th := NewThrottler()
|
||||||
|
id := "admin"
|
||||||
|
|
||||||
|
if !th.Allow(id) {
|
||||||
|
t.Fatal("fresh identifier blocked")
|
||||||
|
}
|
||||||
|
if th.RetryAfter(id) != 0 {
|
||||||
|
t.Fatal("RetryAfter nonzero without failures")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first five failures stay within the free allowance.
|
||||||
|
for i := 0; i < throttleMaxAttempts; i++ {
|
||||||
|
th.Fail(id)
|
||||||
|
}
|
||||||
|
if !th.Allow(id) {
|
||||||
|
t.Fatal("attempt inside allowance blocked")
|
||||||
|
}
|
||||||
|
if th.RetryAfter(id) != 0 {
|
||||||
|
t.Fatal("RetryAfter nonzero inside allowance")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next failure crosses the threshold: backoff starts at base…
|
||||||
|
th.Fail(id)
|
||||||
|
if th.Allow(id) {
|
||||||
|
t.Fatal("locked-out identifier allowed immediately")
|
||||||
|
}
|
||||||
|
if wait := th.RetryAfter(id); wait <= 0 || wait > throttleBackoffBase {
|
||||||
|
t.Fatalf("RetryAfter = %v, want (0,%v]", wait, throttleBackoffBase)
|
||||||
|
}
|
||||||
|
// …and escalates multiplicatively with further failures.
|
||||||
|
th.Fail(id)
|
||||||
|
if wait := th.RetryAfter(id); wait <= throttleBackoffBase || wait > throttleBackoffBase*throttleMultiplier {
|
||||||
|
t.Fatalf("RetryAfter = %v, want escalated", wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success clears state.
|
||||||
|
th.Reset(id)
|
||||||
|
if !th.Allow(id) || th.RetryAfter(id) != 0 {
|
||||||
|
t.Fatal("reset did not clear throttler")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window expiry also clears state.
|
||||||
|
th.Fail(id)
|
||||||
|
th.nowFn = func() time.Time { return time.Now().Add(2 * throttleWindow) }
|
||||||
|
if !th.Allow(id) {
|
||||||
|
t.Fatal("identifier not forgiven after window")
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
-18
@@ -22,8 +22,10 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions"
|
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions"
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/auth"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers"
|
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers"
|
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers"
|
||||||
handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry"
|
handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry"
|
||||||
@@ -31,18 +33,62 @@ import (
|
|||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/devboot"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/devboot"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/owner"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:frontend/dist
|
//go:embed all:frontend/dist
|
||||||
var embeddedFrontend embed.FS
|
var embeddedFrontend embed.FS
|
||||||
|
|
||||||
|
// resetOwner implements --reset-owner: documented headless lockout recovery.
|
||||||
|
// It removes the owner store, signing key and sessions, then exits — the next
|
||||||
|
// boot runs the setup wizard again.
|
||||||
|
func resetOwner() {
|
||||||
|
dataRoot, err := libcontainers.DataRoot()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to resolve data root: %v", err)
|
||||||
|
}
|
||||||
|
dir := filepath.Join(dataRoot, "maestro")
|
||||||
|
for _, f := range []string{"owners.json", "session.key", "sessions.json"} {
|
||||||
|
if err := os.Remove(filepath.Join(dir, f)); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Fatalf("Failed to remove %s: %v", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("Owner identity reset (%s); restart Maestro to run the setup wizard.", dir)
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "--reset-owner" {
|
||||||
|
resetOwner()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cache := libcontainers.NewContainerCache()
|
cache := libcontainers.NewContainerCache()
|
||||||
|
|
||||||
|
// Instance-owner gate (onboarding theme M1): self-contained credential +
|
||||||
|
// session stores at the data root, independent of the fleet.
|
||||||
|
dataRoot, err := libcontainers.DataRoot()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to resolve data root: %v", err)
|
||||||
|
}
|
||||||
|
maestroDir := filepath.Join(dataRoot, "maestro")
|
||||||
|
ownerStore, err := owner.NewFileOwnerStore(maestroDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to open owner store: %v", err)
|
||||||
|
}
|
||||||
|
tokenService, err := owner.NewTokenService(maestroDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize session signing key: %v", err)
|
||||||
|
}
|
||||||
|
sessionStore, err := owner.NewFileSessionStore(maestroDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to open session store: %v", err)
|
||||||
|
}
|
||||||
|
authService := auth.New(ownerStore, tokenService, sessionStore)
|
||||||
|
|
||||||
if os.Getenv("DEV_MODE_STATIC_SERVICES") == "true" {
|
if os.Getenv("DEV_MODE_STATIC_SERVICES") == "true" {
|
||||||
// DEV MODE: Load from file, skip Docker client
|
// DEV MODE: Load from file, skip Docker client
|
||||||
cache.LoadDevServices("services.json")
|
cache.LoadDevServices("services.json")
|
||||||
@@ -59,10 +105,6 @@ func main() {
|
|||||||
// Provision the instance CA on first run (security theme M1). The CA is
|
// Provision the instance CA on first run (security theme M1). The CA is
|
||||||
// the trust anchor for every peer edge on dcntrlized_net; services get
|
// the trust anchor for every peer edge on dcntrlized_net; services get
|
||||||
// their identity certs from it at start/install.
|
// their identity certs from it at start/install.
|
||||||
dataRoot, err := libcontainers.DataRoot()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to resolve data root: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := pki.EnsureCA(dataRoot); err != nil {
|
if _, err := pki.EnsureCA(dataRoot); err != nil {
|
||||||
log.Fatalf("Failed to provision instance CA: %v", err)
|
log.Fatalf("Failed to provision instance CA: %v", err)
|
||||||
}
|
}
|
||||||
@@ -94,27 +136,39 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wsHub := containers.NewWebSocketHub(cache)
|
wsHub := containers.NewWebSocketHub(cache)
|
||||||
|
gate := authService.RequireSession
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/api/containers", containers.NewListContainersHandler(cache))
|
|
||||||
mux.HandleFunc("/api/ws", wsHub.HandleWS)
|
// Owner gate endpoints (onboarding theme M1). Status/setup/login/refresh/
|
||||||
mux.HandleFunc("/api/containers/install", containers.InstallContainerHandler(cache))
|
// logout stay public — they are how a session is obtained; change-password
|
||||||
mux.HandleFunc("/api/containers/uninstall", containers.UninstallContainerHandler(cache))
|
// rides behind the session gate like every other route.
|
||||||
mux.HandleFunc("/api/containers/start", containers.StartContainerHandler(cache))
|
mux.HandleFunc("/api/auth/status", authService.Status)
|
||||||
mux.HandleFunc("/api/containers/stop", containers.StopContainerHandler(cache))
|
mux.HandleFunc("/api/auth/setup", authService.Setup)
|
||||||
mux.HandleFunc("/api/actions/{containerId}/{actionName}", actions.RunAction)
|
mux.HandleFunc("/api/auth/login", authService.Login)
|
||||||
mux.HandleFunc("/api/registry", handlers.GetRegistry)
|
mux.HandleFunc("/api/auth/refresh", authService.Refresh)
|
||||||
|
mux.HandleFunc("/api/auth/logout", authService.Logout)
|
||||||
|
mux.Handle("/api/auth/change-password", gate(http.HandlerFunc(authService.ChangePassword)))
|
||||||
|
|
||||||
|
mux.Handle("/api/ws", authService.GateWebSocket(http.HandlerFunc(wsHub.HandleWS)))
|
||||||
|
mux.Handle("/api/containers", gate(containers.NewListContainersHandler(cache)))
|
||||||
|
mux.Handle("/api/containers/install", gate(containers.InstallContainerHandler(cache)))
|
||||||
|
mux.Handle("/api/containers/uninstall", gate(containers.UninstallContainerHandler(cache)))
|
||||||
|
mux.Handle("/api/containers/start", gate(containers.StartContainerHandler(cache)))
|
||||||
|
mux.Handle("/api/containers/stop", gate(containers.StopContainerHandler(cache)))
|
||||||
|
mux.Handle("/api/actions/{containerId}/{actionName}", gate(http.HandlerFunc(actions.RunAction)))
|
||||||
|
mux.Handle("/api/registry", gate(http.HandlerFunc(handlers.GetRegistry)))
|
||||||
|
|
||||||
// Trusted-services surface (security theme M2 §3): pending first-contact
|
// Trusted-services surface (security theme M2 §3): pending first-contact
|
||||||
// authorizations and the owner's approve/revoke actions, forwarded to the
|
// authorizations and the owner's approve/revoke actions, forwarded to the
|
||||||
// auth service's own trust store.
|
// auth service's own trust store.
|
||||||
peersHandler := peers.NewHandler()
|
peersHandler := peers.NewHandler()
|
||||||
mux.HandleFunc("/api/peers", peersHandler.List)
|
mux.Handle("/api/peers", gate(http.HandlerFunc(peersHandler.List)))
|
||||||
mux.HandleFunc("/api/peers/attestation", peersHandler.Attestation)
|
mux.Handle("/api/peers/attestation", gate(http.HandlerFunc(peersHandler.Attestation)))
|
||||||
mux.HandleFunc("/api/peers/approve", peersHandler.Approve)
|
mux.Handle("/api/peers/approve", gate(http.HandlerFunc(peersHandler.Approve)))
|
||||||
mux.HandleFunc("/api/peers/revoke", peersHandler.Revoke)
|
mux.Handle("/api/peers/revoke", gate(http.HandlerFunc(peersHandler.Revoke)))
|
||||||
mux.HandleFunc("/api/peers/reject", peersHandler.Reject)
|
mux.Handle("/api/peers/reject", gate(http.HandlerFunc(peersHandler.Reject)))
|
||||||
mux.HandleFunc("/api/peers/hide", peersHandler.Hide)
|
mux.Handle("/api/peers/hide", gate(http.HandlerFunc(peersHandler.Hide)))
|
||||||
|
|
||||||
frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist")
|
frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user