Login handler hatte h.Audit injiziert (mit Kommentar "login-success/fail ins audit_log fließen") aber nie aufgerufen. Jetzt werden geloggt: - auth.login.failed (reason: invalid_credentials | account_disabled) - auth.login.success (mit role + remote IP) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
294 lines
8.9 KiB
Go
294 lines
8.9 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/audit"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
|
)
|
|
|
|
// AuthHandler exposes login / me / logout.
|
|
// Login checks the DB users table first; falls back to the setup-store
|
|
// admin for backwards compatibility. On a successful setup-store login
|
|
// the account is auto-migrated into the DB (Upsert) so it shows up in
|
|
// user management from that point on.
|
|
type AuthHandler struct {
|
|
Setup *setup.Store
|
|
Signer *session.Signer
|
|
Audit *audit.Repo
|
|
NodeID string
|
|
Users *usersvc.Repo // optional — nil on first boot before DB is ready
|
|
}
|
|
|
|
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
|
|
return &AuthHandler{Setup: s, Signer: sig}
|
|
}
|
|
|
|
// WithAudit: Audit-Repo + NodeID damit Password-Operationen (change,
|
|
// reset, login-success/fail) ins audit_log fließen.
|
|
func (h *AuthHandler) WithAudit(a *audit.Repo, nodeID string) *AuthHandler {
|
|
h.Audit = a
|
|
h.NodeID = nodeID
|
|
return h
|
|
}
|
|
|
|
// WithUsers injects the users repo so Login can verify against the DB.
|
|
func (h *AuthHandler) WithUsers(u *usersvc.Repo) *AuthHandler {
|
|
h.Users = u
|
|
return h
|
|
}
|
|
|
|
// 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)
|
|
g.POST("/change-password", requireAuth, h.ChangePassword)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
email := strings.TrimSpace(req.Email)
|
|
actor, role := "", "admin"
|
|
remote := c.ClientIP()
|
|
|
|
// 1. Try DB users table first.
|
|
if h.Users != nil {
|
|
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), email)
|
|
if dbErr == nil {
|
|
if !u.Active {
|
|
if h.Audit != nil {
|
|
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
|
email, gin.H{"reason": "account_disabled", "remote": remote}, h.NodeID)
|
|
}
|
|
response.Unauthorized(c, errors.New("account_disabled"))
|
|
return
|
|
}
|
|
if !usersvc.VerifyPassword(hash, req.Password) {
|
|
if h.Audit != nil {
|
|
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
|
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
|
|
}
|
|
response.Unauthorized(c, errors.New("invalid_credentials"))
|
|
return
|
|
}
|
|
actor = u.Email
|
|
role = u.Role
|
|
h.Users.RecordLogin(c.Request.Context(), u.ID)
|
|
}
|
|
}
|
|
|
|
// 2. Fallback: setup-store admin (backwards compat for pre-DB installs).
|
|
if actor == "" {
|
|
if !strings.EqualFold(st.AdminEmail, email) || !st.VerifyAdminPassword(req.Password) {
|
|
if h.Audit != nil {
|
|
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
|
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
|
|
}
|
|
response.Unauthorized(c, errors.New("invalid_credentials"))
|
|
return
|
|
}
|
|
actor = st.AdminEmail
|
|
role = "admin"
|
|
// Auto-migrate: insert the setup-store admin into the DB so it
|
|
// shows up in user management from this point on.
|
|
if h.Users != nil {
|
|
_, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true)
|
|
}
|
|
}
|
|
|
|
raw, tok, err := h.Signer.IssueWithRole(actor, role)
|
|
if err != nil {
|
|
response.Internal(c, err)
|
|
return
|
|
}
|
|
setSessionCookie(c, raw, tok.Exp)
|
|
|
|
if h.Audit != nil {
|
|
_ = h.Audit.Log(c.Request.Context(), actor, "auth.login.success",
|
|
actor, gin.H{"role": role, "remote": remote}, h.NodeID)
|
|
}
|
|
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
|
|
}
|
|
if h.Audit != nil {
|
|
// ResetPassword: keine Session, deshalb "self-reset" als Actor
|
|
// damit der Audit-Trail zeigt dass es kein admin-mediated Reset war.
|
|
_ = h.Audit.Log(c.Request.Context(), "self-reset", "auth.password.reset",
|
|
"", gin.H{"remote": c.ClientIP()}, h.NodeID)
|
|
}
|
|
response.OK(c, gin.H{"ok": true})
|
|
}
|
|
|
|
type changePasswordRequest struct {
|
|
CurrentPassword string `json:"current_password" binding:"required"`
|
|
NewPassword string `json:"new_password" binding:"required,min=12"`
|
|
}
|
|
|
|
// ChangePassword: authenticated User wechselt sein eigenes Passwort.
|
|
// Anders als ResetPassword (CLI-Token-Flow für vergessenes Passwort)
|
|
// braucht das hier das current_password als Confirmation — verhindert
|
|
// dass eine kompromittierte Session den Account übernimmt ohne dass
|
|
// das alte Passwort bekannt ist.
|
|
//
|
|
// Lookup-Reihenfolge: 1) DB users-Tabelle (alle multi-user-Accounts),
|
|
// 2) setup-store Admin-Fallback (Legacy / pre-DB). Beim Setup-Admin
|
|
// werden beide Stores synchron gehalten.
|
|
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
|
var req changePasswordRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.BadRequest(c, err)
|
|
return
|
|
}
|
|
|
|
tok := CurrentToken(c)
|
|
if tok == nil {
|
|
response.Unauthorized(c, nil)
|
|
return
|
|
}
|
|
|
|
// 1. DB-backed user (alle via User-Management erstellten Accounts).
|
|
if h.Users != nil {
|
|
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
|
|
if dbErr == nil {
|
|
if !usersvc.VerifyPassword(hash, req.CurrentPassword) {
|
|
response.Unauthorized(c, errors.New("invalid_current_password"))
|
|
return
|
|
}
|
|
if err := h.Users.SetPassword(c.Request.Context(), u.ID, req.NewPassword); err != nil {
|
|
response.Internal(c, err)
|
|
return
|
|
}
|
|
// Setup-Store-Admin synchron halten, falls gleiche E-Mail.
|
|
if st, _ := h.Setup.Load(); st != nil && strings.EqualFold(st.AdminEmail, tok.Actor) {
|
|
_ = h.Setup.SetAdminPassword(req.NewPassword)
|
|
}
|
|
if h.Audit != nil {
|
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
|
|
tok.Actor, gin.H{"actor": actorOf(c)}, h.NodeID)
|
|
}
|
|
response.OK(c, gin.H{"ok": true})
|
|
return
|
|
}
|
|
}
|
|
|
|
// 2. Fallback: setup-store Admin (vor DB-Migration oder nicht migriert).
|
|
st, err := h.Setup.Load()
|
|
if err != nil {
|
|
response.Internal(c, err)
|
|
return
|
|
}
|
|
if st == nil || !st.Completed {
|
|
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
|
|
return
|
|
}
|
|
if !st.VerifyAdminPassword(req.CurrentPassword) {
|
|
response.Unauthorized(c, errors.New("invalid_current_password"))
|
|
return
|
|
}
|
|
if err := h.Setup.SetAdminPassword(req.NewPassword); err != nil {
|
|
response.BadRequest(c, err)
|
|
return
|
|
}
|
|
if h.Audit != nil {
|
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
|
|
st.AdminEmail, gin.H{"actor": actorOf(c)}, h.NodeID)
|
|
}
|
|
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)
|
|
}
|