feat(auth): Self-Service-Admin-Password-Reset via CLI-Token

Operator hat Admin-Passwort vergessen aber SSH-Zugang zur Box →
schneller Reset ohne SMTP/Email-Setup.

Flow:
  1. `sudo edgeguard-ctl reset-password` auf der Box → 32-hex-Token
     + ISO-Expiry werden nach /var/lib/edgeguard/.reset-token (mode
     0600 edgeguard:edgeguard) geschrieben, Token kommt auf stdout.
     TTL: 30 min.
  2. Login-Seite hat „Passwort vergessen?"-Link → /reset-password.
  3. Reset-Page: Token + neues Passwort (min. 12). POST /auth/reset-
     password validiert Token (constant-time compare), prüft Expiry,
     löscht das File (single-use), hash't das Passwort + speichert
     in setup.json.

internal/services/setup/:
  - SetAdminPassword() — bcrypt-hash + save, fehler wenn setup nicht
    completed
  - GenerateResetToken() / ConsumeResetToken() — File-basiert,
    Format: "<token>|<RFC3339-expiry>"

internal/handlers/auth.go: POST /api/v1/auth/reset-password.
cmd/edgeguard-ctl/main.go: `reset-password` command.

UI: /reset-password Page mit Info-Alert für CLI-Snippet
(„sudo edgeguard-ctl reset-password" im dunklen Code-Block); Login-
Seite bekommt den „Passwort vergessen?"-Link.

Verifiziert auf 1.0.76: CLI druckt Token + schreibt File mit 0600
edgeguard:edgeguard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-13 19:04:25 +02:00
parent 27ac7b53fc
commit c79bfe84ec
13 changed files with 323 additions and 8 deletions

View File

@@ -1 +1 @@
1.0.75 1.0.76

View File

@@ -54,7 +54,7 @@ import (
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
) )
var version = "1.0.75" var version = "1.0.76"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -7,9 +7,11 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
) )
var version = "1.0.75" var version = "1.0.76"
const usage = `edgeguard-ctl — EdgeGuard CLI const usage = `edgeguard-ctl — EdgeGuard CLI
@@ -25,6 +27,7 @@ Commands:
initdb Create PostgreSQL role + database (idempotent) initdb Create PostgreSQL role + database (idempotent)
render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=) render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=)
wg-import [--path <dir>] Import existing /etc/wireguard/*.conf files into the DB wg-import [--path <dir>] Import existing /etc/wireguard/*.conf files into the DB
reset-password Generate a one-time token for the /reset-password UI flow
cluster-join Join an existing cluster (Phase 3, not yet implemented) cluster-join Join an existing cluster (Phase 3, not yet implemented)
promote Promote this node's PG to primary (Phase 3, not yet implemented) promote Promote this node's PG to primary (Phase 3, not yet implemented)
dump-config Print effective config (Phase 3, not yet implemented) dump-config Print effective config (Phase 3, not yet implemented)
@@ -48,6 +51,8 @@ func main() {
os.Exit(cmdRenderConfig(os.Args[2:])) os.Exit(cmdRenderConfig(os.Args[2:]))
case "wg-import": case "wg-import":
os.Exit(cmdWGImport(os.Args[2:])) os.Exit(cmdWGImport(os.Args[2:]))
case "reset-password":
os.Exit(cmdResetPassword())
case "cluster-join", "cluster-leave", "promote", "dump-config": case "cluster-join", "cluster-leave", "promote", "dump-config":
fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1]) fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
os.Exit(1) os.Exit(1)
@@ -57,3 +62,25 @@ func main() {
os.Exit(2) os.Exit(2)
} }
} }
// cmdResetPassword generates a single-use reset token and prints it to
// stdout. Operator pastes it into the /reset-password UI within 30 min.
// File mode 0600, owned by the edgeguard user — the CLI muss als sudo
// edgeguard ausgeführt werden (oder als root); fremde User sehen das
// Token nicht.
func cmdResetPassword() int {
store := setup.NewStore(setup.DefaultDir)
token, err := store.GenerateResetToken()
if err != nil {
fmt.Fprintf(os.Stderr, "edgeguard-ctl reset-password: %v\n", err)
return 1
}
fmt.Println("Admin-Password-Reset-Token (gültig 30 Minuten):")
fmt.Println()
fmt.Println(" " + token)
fmt.Println()
fmt.Println("→ UI öffnen: https://<box-fqdn>/reset-password")
fmt.Println("→ Token eingeben, neues Passwort setzen (min. 12 Zeichen)")
fmt.Println("→ Token ist single-use und wird beim erfolgreichen Reset gelöscht.")
return 0
}

View File

@@ -32,7 +32,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
) )
var version = "1.0.75" var version = "1.0.76"
const ( const (
// renewTickInterval — how often we re-evaluate expiring certs. // renewTickInterval — how often we re-evaluate expiring certs.

View File

@@ -32,6 +32,7 @@ func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc)
g.POST("/login", h.Login) g.POST("/login", h.Login)
g.POST("/logout", h.Logout) g.POST("/logout", h.Logout)
g.GET("/me", requireAuth, h.Me) g.GET("/me", requireAuth, h.Me)
g.POST("/reset-password", h.ResetPassword)
} }
type loginRequest struct { type loginRequest struct {
@@ -99,6 +100,31 @@ func (h *AuthHandler) Me(c *gin.Context) {
}) })
} }
type resetPasswordRequest struct {
Token string `json:"token" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=12"`
}
// ResetPassword verifies the operator-generated token from
// /var/lib/edgeguard/.reset-token and sets a new admin password. The
// token is single-use — ConsumeResetToken löscht das File bei Erfolg.
func (h *AuthHandler) ResetPassword(c *gin.Context) {
var req resetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.Setup.ConsumeResetToken(req.Token); err != nil {
response.Err(c, http.StatusUnauthorized, err)
return
}
if err := h.Setup.SetAdminPassword(req.NewPassword); err != nil {
response.BadRequest(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
func setSessionCookie(c *gin.Context, raw string, expUnix int64) { func setSessionCookie(c *gin.Context, raw string, expUnix int64) {
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds()) maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
if maxAge < 0 { if maxAge < 0 {

View File

@@ -0,0 +1,114 @@
package setup
// Admin-Password-Reset-Flow: Operator hat Login-Zugang vergessen,
// aber SSH-Zugang zur Box. Auf der Box ruft er
//
// sudo edgeguard-ctl reset-password
//
// auf — der CLI schreibt ein Token-File nach
// /var/lib/edgeguard/.reset-token (mode 0600 edgeguard:edgeguard) und
// druckt das Token. Der Operator gibt es auf der /reset-password-Seite
// ein, setzt ein neues Passwort, fertig.
//
// Token-Eigenschaften:
// - 32 zufällige hex-chars (128 bit entropy)
// - File-mode 0600 — nur edgeguard liest, nur root + edgeguard schreibt
// - Expiry 30 min (im File als ISO-timestamp gespeichert)
// - Single-use: bei jedem erfolgreichen Reset wird das File gelöscht
//
// Sicherheit gegen Brute-Force: nicht nötig — Token hat 128 bit
// Entropy, das File darf existieren nur kurzzeitig nach einem expliziten
// CLI-Aufruf. Login-Seite hat kein Rate-Limit, aber jeder Versuch
// braucht das exakte File-Inhalt — bei 32 hex zu raten dauert 2^127
// Versuche.
import (
"crypto/rand"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
"time"
)
const (
resetTokenFile = ".reset-token"
resetTokenTTL = 30 * time.Minute
)
// GenerateResetToken erzeugt ein neues Token, schreibt das File und
// gibt das Token zurück. Existiert bereits ein Token → wird
// überschrieben (Operator hat das alte verworfen). Aufrufer: CLI.
func (s *Store) GenerateResetToken() (string, error) {
prev, err := s.Load()
if err != nil {
return "", err
}
if prev == nil || !prev.Completed {
return "", errors.New("setup not completed — nothing to reset")
}
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
token := hex.EncodeToString(raw[:])
expiry := time.Now().Add(resetTokenTTL).UTC().Format(time.RFC3339)
content := token + "|" + expiry + "\n"
path := filepath.Join(s.Dir, resetTokenFile)
if err := os.MkdirAll(s.Dir, 0o755); err != nil {
return "", err
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
return "", err
}
return token, nil
}
// ConsumeResetToken prüft Token-Match + Expiry und löscht das File bei
// Erfolg. Aufrufer: API-Handler /auth/reset-password.
func (s *Store) ConsumeResetToken(token string) error {
path := filepath.Join(s.Dir, resetTokenFile)
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return errors.New("no reset-token available — run `sudo edgeguard-ctl reset-password` on the box first")
}
return err
}
parts := strings.SplitN(strings.TrimSpace(string(b)), "|", 2)
if len(parts) != 2 {
return errors.New("reset-token file malformed")
}
stored := strings.TrimSpace(parts[0])
expiryStr := strings.TrimSpace(parts[1])
if !constantTimeEqual(stored, strings.TrimSpace(token)) {
return errors.New("reset-token mismatch")
}
exp, err := time.Parse(time.RFC3339, expiryStr)
if err != nil {
return errors.New("reset-token expiry malformed")
}
if time.Now().After(exp) {
_ = os.Remove(path)
return errors.New("reset-token expired — generate a new one")
}
// success → delete file (single-use)
_ = os.Remove(path)
return nil
}
// constantTimeEqual prevents timing-attacks on the token compare.
// crypto/subtle hätte's auch getan, aber für hex-strings reicht das
// hier — und vermeidet einen weiteren Import.
func constantTimeEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := range a {
diff |= a[i] ^ b[i]
}
return diff == 0
}

View File

@@ -136,6 +136,27 @@ func (st *State) VerifyAdminPassword(plaintext string) bool {
return bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(plaintext)) == nil return bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(plaintext)) == nil
} }
// SetAdminPassword hash't ein neues Plaintext-Passwort und persistiert
// es. Verwendet vom Self-Service-Reset (CLI-Token-Flow).
func (s *Store) SetAdminPassword(plaintext string) error {
if len(plaintext) < 12 {
return errors.New("admin_password must be at least 12 characters")
}
prev, err := s.Load()
if err != nil {
return err
}
if prev == nil || !prev.Completed {
return errors.New("setup not completed — cannot reset password before initial setup")
}
hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), adminPwCost)
if err != nil {
return fmt.Errorf("hash admin password: %w", err)
}
prev.AdminPasswordHash = string(hash)
return s.Save(prev)
}
func validate(req Request) error { func validate(req Request) error {
if _, err := mail.ParseAddress(req.AdminEmail); err != nil { if _, err := mail.ParseAddress(req.AdminEmail); err != nil {
return fmt.Errorf("invalid admin_email: %w", err) return fmt.Errorf("invalid admin_email: %w", err)

View File

@@ -11,6 +11,7 @@ import apiClient, { isEnvelope } from './api/client'
import { useAuthStore, type SessionUser } from './stores/auth' import { useAuthStore, type SessionUser } from './stores/auth'
const LoginPage = lazy(() => import('./pages/Login')) const LoginPage = lazy(() => import('./pages/Login'))
const ResetPasswordPage = lazy(() => import('./pages/ResetPassword'))
const SetupPage = lazy(() => import('./pages/Setup')) const SetupPage = lazy(() => import('./pages/Setup'))
const DashboardPage = lazy(() => import('./pages/Dashboard')) const DashboardPage = lazy(() => import('./pages/Dashboard'))
const DomainsPage = lazy(() => import('./pages/Domains')) const DomainsPage = lazy(() => import('./pages/Domains'))
@@ -98,6 +99,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/setup" element={<SetupPage onComplete={(u: SessionUser) => useAuthStore.getState().set(u)} />} /> <Route path="/setup" element={<SetupPage onComplete={(u: SessionUser) => useAuthStore.getState().set(u)} />} />
<Route path="/login" element={<LoginPage onLogin={(u: SessionUser) => useAuthStore.getState().set(u)} />} /> <Route path="/login" element={<LoginPage onLogin={(u: SessionUser) => useAuthStore.getState().set(u)} />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route element={<RequireAuth><AppLayout /></RequireAuth>}> <Route element={<RequireAuth><AppLayout /></RequireAuth>}>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />

View File

@@ -85,7 +85,7 @@ const NAV: NavSection[] = [
}, },
] ]
const VERSION = '1.0.75' const VERSION = '1.0.76'
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen: // Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent // - <nav> als root, dunkler Gradient + Teal/Blue-Accent

View File

@@ -172,7 +172,22 @@
"login": "Anmelden", "login": "Anmelden",
"logout": "Abmelden", "logout": "Abmelden",
"loginFailed": "Anmeldung fehlgeschlagen", "loginFailed": "Anmeldung fehlgeschlagen",
"loggedInAs": "Angemeldet als" "loggedInAs": "Angemeldet als",
"forgotPassword": "Passwort vergessen?"
},
"reset": {
"title": "Admin-Passwort zurücksetzen",
"intro": "Self-Service-Reset über CLI-Token. Du brauchst SSH-Zugang zur Box.",
"cliTitle": "1. Auf der Box ausführen:",
"token": "Reset-Token",
"tokenExtra": "32 hex-Zeichen aus der CLI-Ausgabe. Token ist 30 Minuten gültig.",
"newPassword": "Neues Passwort (min. 12 Zeichen)",
"confirmPassword": "Passwort bestätigen",
"submit": "Passwort setzen",
"backToLogin": "← Zurück zum Login",
"mismatch": "Passwörter stimmen nicht überein",
"success": "Passwort erfolgreich gesetzt. Du wirst weitergeleitet …",
"failed": "Reset fehlgeschlagen"
}, },
"setup": { "setup": {
"title": "Erst-Einrichtung", "title": "Erst-Einrichtung",

View File

@@ -172,7 +172,22 @@
"login": "Sign in", "login": "Sign in",
"logout": "Sign out", "logout": "Sign out",
"loginFailed": "Sign-in failed", "loginFailed": "Sign-in failed",
"loggedInAs": "Signed in as" "loggedInAs": "Signed in as",
"forgotPassword": "Forgot your password?"
},
"reset": {
"title": "Reset admin password",
"intro": "Self-service reset via CLI token. You need SSH access to the box.",
"cliTitle": "1. Run on the box:",
"token": "Reset token",
"tokenExtra": "32 hex chars from the CLI output. Token is valid for 30 minutes.",
"newPassword": "New password (min. 12 chars)",
"confirmPassword": "Confirm password",
"submit": "Set password",
"backToLogin": "← Back to sign-in",
"mismatch": "Passwords don't match",
"success": "Password set. Redirecting…",
"failed": "Reset failed"
}, },
"setup": { "setup": {
"title": "First-time setup", "title": "First-time setup",

View File

@@ -1,5 +1,5 @@
import { Button, Card, Form, Input, message, Typography } from 'antd' import { Button, Card, Form, Input, message, Typography } from 'antd'
import { useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client' import apiClient, { isEnvelope } from '../../api/client'
@@ -65,6 +65,9 @@ export default function LoginPage({ onLogin }: Props) {
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>
<div style={{ textAlign: 'center', fontSize: 12 }}>
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
</div>
</Card> </Card>
</div> </div>
) )

View File

@@ -0,0 +1,92 @@
import { useState } from 'react'
import { Alert, Button, Card, Form, Input, message, Typography } from 'antd'
import { Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import apiClient from '../../api/client'
interface FormValues {
token: string
new_password: string
new_password2: string
}
export default function ResetPasswordPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [form] = Form.useForm<FormValues>()
const onFinish = async (v: FormValues) => {
if (v.new_password !== v.new_password2) {
setError(t('reset.mismatch'))
return
}
setSubmitting(true); setError(null)
try {
await apiClient.post('/auth/reset-password', {
token: v.token.trim(),
new_password: v.new_password,
})
message.success(t('reset.success'))
setTimeout(() => navigate('/login', { replace: true }), 1500)
} catch (e: unknown) {
const err = e as { message?: string }
setError(err.message ?? t('reset.failed'))
} finally {
setSubmitting(false)
}
}
return (
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
<Card style={{ width: 480 }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 8 }}>
{t('reset.title')}
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ textAlign: 'center', fontSize: 12 }}>
{t('reset.intro')}
</Typography.Paragraph>
<Alert
type="info"
showIcon
className="mb-16"
message={t('reset.cliTitle')}
description={
<code style={{ display: 'block', padding: 8, marginTop: 8, background: '#0F172A', color: '#CBD5E1', borderRadius: 4 }}>
sudo edgeguard-ctl reset-password
</code>
}
/>
{error && <Alert type="error" showIcon className="mb-16" message={error} closable
onClose={() => setError(null)} />}
<Form form={form} layout="vertical" onFinish={onFinish}>
<Form.Item label={t('reset.token')} name="token" rules={[{ required: true, len: 32 }]}
extra={t('reset.tokenExtra')}>
<Input autoComplete="off" autoFocus placeholder="32 hex chars" />
</Form.Item>
<Form.Item label={t('reset.newPassword')} name="new_password"
rules={[{ required: true, min: 12 }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item label={t('reset.confirmPassword')} name="new_password2"
rules={[{ required: true, min: 12 }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={submitting}>
{t('reset.submit')}
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', fontSize: 12 }}>
<Link to="/login">{t('reset.backToLogin')}</Link>
</div>
</Card>
</div>
)
}