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" // 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 { response.Unauthorized(c, errors.New("account_disabled")) return } if !usersvc.VerifyPassword(hash, req.Password) { 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) { 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) 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. func (h *AuthHandler) ChangePassword(c *gin.Context) { var req changePasswordRequest 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 == nil || !st.Completed { response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required")) return } // Authorisierte Session ist nicht automatisch der Admin (Phase 4 // admin_users-Tabelle könnte mehrere Rollen haben). v1: aktuell // nur der eine Admin-User; trotzdem prüfen wir das current_password // gegen die persistierte Hash. 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) } // Neue Session ausstellen — alte Cookie zeigt auf ein Token das // noch gültig ist; das ist OK für UX (kein erzwungener Logout), // sicherheitsbewusster: clearSession + force re-login. Wir // halten's hier ruhig. 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) }