mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-12 05:48:58 +00:00
61ee806af6
- Default all queries to a 20s poll + refetch-on-focus (main.tsx) instead of a per-page opt-in, so every page/widget stays live without manual tuning. - New internal/notify package: Gotify and SMTP (stdlib net/smtp, STARTTLS and implicit-TLS-on-465) notifications, each independently optional. Fires from the alert evaluator on new alert triggers; admin-configurable from Settings with a send-test-notification action per channel. - OIDC/SSO moved from config.yaml-only to a DB-backed, admin-editable Settings card — swaps the live client with no restart. config.yaml is used to seed the database once on first boot after upgrading. - New Security settings: session TTL, login lockout policy, and a real "require 2FA for admins" enforcement (requireTOTPEnrolled middleware) that blocks non-enrolled admins from everything but /profile and logout. - New org-wide default preferences (theme/accent/look/landing page) for brand-new accounts, plus a personal landing-page picker and an email-me-alerts opt-in on Profile. - Storage page: separate Local vs Shared/External storage tables and capacity donuts, fixing shared-storage totals that were being summed once per node that mounts them (e.g. a 2TB NFS share on 4 nodes read as 8TB). - RankedBarChart: stop the longest bar's value label wrapping onto two lines (recharts auto-wraps LabelList when space is tight).
138 lines
4.8 KiB
Go
138 lines
4.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// securityRow is the single (id=1) security_settings row — session TTL and
|
|
// login-lockout policy, admin-editable from Settings, applied to the running
|
|
// server (auth.Service, loginLimiter) immediately on save.
|
|
type securityRow struct {
|
|
sessionTTLHours int
|
|
loginMaxFailures int
|
|
loginLockoutMinutes int
|
|
require2FAAdmins bool
|
|
}
|
|
|
|
func defaultSecurityRow() securityRow {
|
|
return securityRow{sessionTTLHours: 720, loginMaxFailures: 5, loginLockoutMinutes: 15}
|
|
}
|
|
|
|
func (s *Server) loadSecurityRow(ctx context.Context) (securityRow, error) {
|
|
row := defaultSecurityRow()
|
|
var require2FA int
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT session_ttl_hours, login_max_failures, login_lockout_minutes, require_2fa_admins FROM security_settings WHERE id = 1`).
|
|
Scan(&row.sessionTTLHours, &row.loginMaxFailures, &row.loginLockoutMinutes, &require2FA)
|
|
if err == sql.ErrNoRows {
|
|
return defaultSecurityRow(), nil
|
|
}
|
|
if err != nil {
|
|
return securityRow{}, err
|
|
}
|
|
row.require2FAAdmins = require2FA == 1
|
|
return row, nil
|
|
}
|
|
|
|
func (s *Server) saveSecurityRow(ctx context.Context, row securityRow) error {
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO security_settings (id, session_ttl_hours, login_max_failures, login_lockout_minutes, require_2fa_admins, updated_at)
|
|
VALUES (1, ?, ?, ?, ?, ?)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
session_ttl_hours = excluded.session_ttl_hours, login_max_failures = excluded.login_max_failures,
|
|
login_lockout_minutes = excluded.login_lockout_minutes, require_2fa_admins = excluded.require_2fa_admins,
|
|
updated_at = excluded.updated_at`,
|
|
row.sessionTTLHours, row.loginMaxFailures, row.loginLockoutMinutes, boolToInt(row.require2FAAdmins),
|
|
time.Now().UTC().Format(time.RFC3339))
|
|
return err
|
|
}
|
|
|
|
// applySecurityRow pushes row into the live auth.Service, loginLimiter, and
|
|
// the requireTOTPEnrolled gate — called at boot and on every save.
|
|
func (s *Server) applySecurityRow(row securityRow) {
|
|
s.auth.SetSessionTTL(time.Duration(row.sessionTTLHours) * time.Hour)
|
|
s.logins.SetPolicy(row.loginMaxFailures, time.Duration(row.loginLockoutMinutes)*time.Minute)
|
|
s.SetRequire2FAAdmins(row.require2FAAdmins)
|
|
}
|
|
|
|
type securitySettingsResponse struct {
|
|
SessionTTLHours int `json:"sessionTtlHours"`
|
|
LoginMaxFailures int `json:"loginMaxFailures"`
|
|
LoginLockoutMinutes int `json:"loginLockoutMinutes"`
|
|
Require2FAAdmins bool `json:"require2faAdmins"`
|
|
}
|
|
|
|
func toSecuritySettingsResponse(row securityRow) securitySettingsResponse {
|
|
return securitySettingsResponse{
|
|
SessionTTLHours: row.sessionTTLHours, LoginMaxFailures: row.loginMaxFailures,
|
|
LoginLockoutMinutes: row.loginLockoutMinutes, Require2FAAdmins: row.require2FAAdmins,
|
|
}
|
|
}
|
|
|
|
func (s *Server) getSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
|
row, err := s.loadSecurityRow(r.Context())
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, toSecuritySettingsResponse(row))
|
|
}
|
|
|
|
type securitySettingsPatch struct {
|
|
SessionTTLHours *int `json:"sessionTtlHours"`
|
|
LoginMaxFailures *int `json:"loginMaxFailures"`
|
|
LoginLockoutMinutes *int `json:"loginLockoutMinutes"`
|
|
Require2FAAdmins *bool `json:"require2faAdmins"`
|
|
}
|
|
|
|
func (s *Server) putSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
|
var patch securitySettingsPatch
|
|
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
|
writeErrorMsg(w, http.StatusBadRequest, "expected a JSON object")
|
|
return
|
|
}
|
|
|
|
row, err := s.loadSecurityRow(r.Context())
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
if patch.SessionTTLHours != nil {
|
|
if *patch.SessionTTLHours < 1 || *patch.SessionTTLHours > 8760 {
|
|
writeErrorMsg(w, http.StatusBadRequest, "session TTL must be between 1 and 8760 hours (1 year)")
|
|
return
|
|
}
|
|
row.sessionTTLHours = *patch.SessionTTLHours
|
|
}
|
|
if patch.LoginMaxFailures != nil {
|
|
if *patch.LoginMaxFailures < 1 || *patch.LoginMaxFailures > 100 {
|
|
writeErrorMsg(w, http.StatusBadRequest, "login max failures must be between 1 and 100")
|
|
return
|
|
}
|
|
row.loginMaxFailures = *patch.LoginMaxFailures
|
|
}
|
|
if patch.LoginLockoutMinutes != nil {
|
|
if *patch.LoginLockoutMinutes < 1 || *patch.LoginLockoutMinutes > 1440 {
|
|
writeErrorMsg(w, http.StatusBadRequest, "login lockout window must be between 1 and 1440 minutes (24h)")
|
|
return
|
|
}
|
|
row.loginLockoutMinutes = *patch.LoginLockoutMinutes
|
|
}
|
|
if patch.Require2FAAdmins != nil {
|
|
row.require2FAAdmins = *patch.Require2FAAdmins
|
|
}
|
|
|
|
if err := s.saveSecurityRow(r.Context(), row); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
s.applySecurityRow(row)
|
|
s.audit(r, "settings.security", "settings", "updated")
|
|
writeJSON(w, http.StatusOK, toSecuritySettingsResponse(row))
|
|
}
|