Files
ferrum/internal/api/settings_security.go
Anand 61ee806af6 Add realtime polling defaults, Gotify/SMTP notifications, UI-configurable OIDC, and expanded admin settings
- 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).
2026-09-03 22:00:41 +05:30

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))
}