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>
141 lines
3.7 KiB
Go
141 lines
3.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
|
)
|
|
|
|
// AuthHandler exposes login / me / logout. v1 verifies against the
|
|
// setup-store (single admin); admin_users-table support comes when the
|
|
// users repo lands.
|
|
type AuthHandler struct {
|
|
Setup *setup.Store
|
|
Signer *session.Signer
|
|
}
|
|
|
|
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
|
|
return &AuthHandler{Setup: s, Signer: sig}
|
|
}
|
|
|
|
// Register mounts /auth/login + /logout (public) and /auth/me
|
|
// (gated by requireAuth, passed in as a per-route middleware).
|
|
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
|
g := rg.Group("/auth")
|
|
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 {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required"`
|
|
}
|
|
|
|
type loginResponse struct {
|
|
Actor string `json:"actor"`
|
|
Role string `json:"role"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
func (h *AuthHandler) Login(c *gin.Context) {
|
|
var req loginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.BadRequest(c, err)
|
|
return
|
|
}
|
|
st, err := h.Setup.Load()
|
|
if err != nil {
|
|
response.Internal(c, err)
|
|
return
|
|
}
|
|
if !st.Completed {
|
|
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
|
|
return
|
|
}
|
|
if !strings.EqualFold(st.AdminEmail, strings.TrimSpace(req.Email)) ||
|
|
!st.VerifyAdminPassword(req.Password) {
|
|
response.Unauthorized(c, errors.New("invalid_credentials"))
|
|
return
|
|
}
|
|
|
|
raw, tok, err := h.Signer.IssueWithRole(st.AdminEmail, "admin")
|
|
if err != nil {
|
|
response.Internal(c, err)
|
|
return
|
|
}
|
|
setSessionCookie(c, raw, tok.Exp)
|
|
|
|
response.OK(c, loginResponse{
|
|
Actor: tok.Actor,
|
|
Role: tok.Role,
|
|
ExpiresAt: time.Unix(tok.Exp, 0).UTC(),
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
|
clearSessionCookie(c)
|
|
response.OK(c, gin.H{"logged_out": true})
|
|
}
|
|
|
|
// Me returns the current actor + role (or 401 if no/invalid token).
|
|
func (h *AuthHandler) Me(c *gin.Context) {
|
|
tok := CurrentToken(c)
|
|
if tok == nil {
|
|
response.Unauthorized(c, nil)
|
|
return
|
|
}
|
|
response.OK(c, gin.H{
|
|
"actor": tok.Actor,
|
|
"role": tok.Role,
|
|
"expires_at": time.Unix(tok.Exp, 0).UTC(),
|
|
})
|
|
}
|
|
|
|
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 {
|
|
maxAge = 0
|
|
}
|
|
c.SetSameSite(http.SameSiteStrictMode)
|
|
c.SetCookie(cookieName, raw, maxAge, "/", "", true, true)
|
|
}
|
|
|
|
func clearSessionCookie(c *gin.Context) {
|
|
c.SetSameSite(http.SameSiteStrictMode)
|
|
c.SetCookie(cookieName, "", -1, "/", "", true, true)
|
|
}
|