Added user profile page, prepped for TOTP 2fa

This commit is contained in:
2026-07-16 22:13:54 +02:00
parent 63deb02d39
commit c8f47768a3
11 changed files with 850 additions and 15 deletions
+3 -1
View File
@@ -9,7 +9,7 @@ const defaultAllowedOrigin = "http://localhost:5173"
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Access-Control-Allow-Origin", defaultAllowedOrigin)
writer.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if request.Method == http.MethodOptions {
@@ -28,5 +28,7 @@ func NewRouter(configDir string) http.Handler {
mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir))
mux.HandleFunc("GET /api/v1/security", securityGetHandler(configDir))
mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir))
return withCORS(mux)
}
+98
View File
@@ -0,0 +1,98 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const (
minIdleTimeoutMinutes = 1
maxIdleTimeoutMinutes = 240 // 4 hours
minSessionLifetimeHours = 1
minReauthGraceMinutes = 0
)
type securityResponse struct {
Security settings.SecuritySettings `json:"security"`
}
type putSecurityRequest struct {
Security settings.SecuritySettings `json:"security"`
}
func securityGetHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, securityResponse{
Security: effectiveSecurity(settingsPayload.Security),
})
}
}
func securityPutHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeSecurityWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
var payload putSecurityRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := validateSecuritySettings(payload.Security); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload.Security = payload.Security
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, securityResponse{Security: settingsPayload.Security})
}
}
func authorizeSecurityWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return nil
}
func effectiveSecurity(security settings.SecuritySettings) settings.SecuritySettings {
if err := validateSecuritySettings(security); err != nil {
return settings.DefaultSecuritySettings()
}
return security
}
func validateSecuritySettings(security settings.SecuritySettings) error {
if security.IdleTimeoutMinutes < minIdleTimeoutMinutes ||
security.IdleTimeoutMinutes > maxIdleTimeoutMinutes {
return errors.New("idle_timeout_minutes must be between 1 and 240")
}
if security.SessionLifetimeHours < minSessionLifetimeHours {
return errors.New("session_lifetime_hours must be at least 1")
}
if security.ReauthGraceMinutes < minReauthGraceMinutes {
return errors.New("reauth_grace_minutes must be at least 0")
}
return nil
}
@@ -0,0 +1,157 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type securityResponseTest struct {
Security settings.SecuritySettings `json:"security"`
}
func TestSecurityGetReturnsDefaultsWhenMissing(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
router := NewRouter(configDir)
request := httptest.NewRequest(http.MethodGet, "/api/v1/security", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
var payload securityResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
want := settings.DefaultSecuritySettings()
if payload.Security != want {
t.Fatalf("got %#v, want %#v", payload.Security, want)
}
}
func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
router := NewRouter(configDir)
body := []byte(`{
"security": {
"idle_timeout_minutes": 0,
"session_lifetime_hours": 24,
"reauth_sensitive_actions": false,
"reauth_grace_minutes": 15
}
}`)
request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
router := NewRouter(configDir)
body := []byte(`{
"security": {
"idle_timeout_minutes": 241,
"session_lifetime_hours": 24,
"reauth_sensitive_actions": false,
"reauth_grace_minutes": 15
}
}`)
request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
router := NewRouter(configDir)
body := []byte(`{
"security": {
"idle_timeout_minutes": 30,
"session_lifetime_hours": 0,
"reauth_sensitive_actions": false,
"reauth_grace_minutes": 15
}
}`)
request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestSecurityPutRoundTrip(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
router := NewRouter(configDir)
body := []byte(`{
"security": {
"idle_timeout_minutes": 45,
"session_lifetime_hours": 12,
"reauth_sensitive_actions": true,
"reauth_grace_minutes": 0,
"totp_enabled": true
}
}`)
putReq := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body))
putRec := httptest.NewRecorder()
router.ServeHTTP(putRec, putReq)
if putRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, putRec.Code)
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/security", nil)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, getRec.Code)
}
var payload securityResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
want := settings.SecuritySettings{
IdleTimeoutMinutes: 45,
SessionLifetimeHours: 12,
ReauthSensitiveActions: true,
ReauthGraceMinutes: 0,
TotpEnabled: true,
}
if payload.Security != want {
t.Fatalf("got %#v, want %#v", payload.Security, want)
}
}
+23 -2
View File
@@ -18,10 +18,31 @@ type Group struct {
Permissions []string `json:"permissions"`
}
// SecuritySettings holds session and re-auth policy for the admin UI / auth middleware.
type SecuritySettings struct {
IdleTimeoutMinutes int `json:"idle_timeout_minutes"`
SessionLifetimeHours int `json:"session_lifetime_hours"`
ReauthSensitiveActions bool `json:"reauth_sensitive_actions"`
ReauthGraceMinutes int `json:"reauth_grace_minutes"`
TotpEnabled bool `json:"totp_enabled"`
}
// DefaultSecuritySettings returns the built-in security defaults.
func DefaultSecuritySettings() SecuritySettings {
return SecuritySettings{
IdleTimeoutMinutes: 30,
SessionLifetimeHours: 24,
ReauthSensitiveActions: false,
ReauthGraceMinutes: 15,
TotpEnabled: false,
}
}
// Settings holds plain (non-secret) base/CLI configuration.
type Settings struct {
LogLevel string `json:"log_level"`
Groups []Group `json:"groups"`
LogLevel string `json:"log_level"`
Groups []Group `json:"groups"`
Security SecuritySettings `json:"security"`
}
// Secrets holds sensitive configuration encrypted at rest.