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

@@ -32,6 +32,7 @@ func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc)
g.POST("/login", h.Login)
g.POST("/logout", h.Logout)
g.GET("/me", requireAuth, h.Me)
g.POST("/reset-password", h.ResetPassword)
}
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) {
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
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
}
// 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 {
if _, err := mail.ParseAddress(req.AdminEmail); err != nil {
return fmt.Errorf("invalid admin_email: %w", err)