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>
115 lines
3.4 KiB
Go
115 lines
3.4 KiB
Go
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
|
|
}
|