Added user profile page, prepped for TOTP 2fa
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user