Add users and network settings tabs with admin protections.

List and delete accounts with last-admin and Administrators-group guards, and expose editable network settings via the configuration UI.
This commit is contained in:
2026-07-18 15:18:01 +02:00
parent f049f766d9
commit 2605c3b346
49 changed files with 5440 additions and 209 deletions
+464
View File
@@ -0,0 +1,464 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"sync"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type contextKey string
const (
contextUserKey contextKey = "user"
contextSessionKey contextKey = "session"
)
type setupStatusResponse struct {
Completed bool `json:"completed"`
ClientIP string `json:"client_ip"`
}
type setupTOTPBeginRequest struct {
Username string `json:"username"`
}
type setupTOTPBeginResponse struct {
Secret string `json:"secret"`
OTPAuthURL string `json:"otpauth_url"`
}
type setupTOTPVerifyRequest struct {
Secret string `json:"secret"`
Code string `json:"code"`
}
type setupCompleteRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPSecret string `json:"totp_secret"`
TOTPConfirmed bool `json:"totp_confirmed"`
Network settings.NetworkSettings `json:"network"`
Security settings.SecuritySettings `json:"security"`
}
type setupPreviewRequest struct {
Network settings.NetworkSettings `json:"network"`
}
type setupPreviewResponse struct {
WouldLockOut bool `json:"would_lock_out"`
Reason string `json:"reason,omitempty"`
ClientIP string `json:"client_ip"`
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
type meResponse struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Groups []string `json:"groups"`
}
// App holds shared API dependencies.
type App struct {
ConfigDir string
Key []byte
Sessions *auth.SessionManager
pendingTOTPMu sync.Mutex
pendingTOTP map[string]string // username -> secret (setup only)
}
func newApp(configDir string) (*App, error) {
key, err := settings.KeyFromEnv()
if err != nil {
// Key is required for setup complete / auth; status still works without it.
return &App{
ConfigDir: configDir,
pendingTOTP: map[string]string{},
}, nil
}
return &App{
ConfigDir: configDir,
Key: key,
Sessions: auth.NewSessionManager(configDir, key),
pendingTOTP: map[string]string{},
}, nil
}
func (app *App) requireKey() error {
if len(app.Key) == 0 {
return fmtErrorfKeyMissing()
}
return nil
}
func fmtErrorfKeyMissing() error {
return errors.New(settings.ConfigKeyEnvVar + " is not set")
}
func (app *App) setupStatusHandler(writer http.ResponseWriter, request *http.Request) {
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, setupStatusResponse{
Completed: completed,
ClientIP: auth.ClientIP(request),
})
}
func (app *App) setupTOTPBeginHandler(writer http.ResponseWriter, request *http.Request) {
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
return
}
var payload setupTOTPBeginRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
username := strings.TrimSpace(payload.Username)
if username == "" {
username = "Admin"
}
key, err := auth.GenerateTOTPSecret(username)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
app.pendingTOTPMu.Lock()
app.pendingTOTP[strings.ToLower(username)] = key.Secret()
app.pendingTOTPMu.Unlock()
writeJSON(writer, http.StatusOK, setupTOTPBeginResponse{
Secret: key.Secret(),
OTPAuthURL: key.URL(),
})
}
func (app *App) setupTOTPVerifyHandler(writer http.ResponseWriter, request *http.Request) {
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
return
}
var payload setupTOTPVerifyRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if !auth.VerifyTOTPCode(payload.Secret, payload.Code) {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid TOTP code"})
return
}
writeJSON(writer, http.StatusOK, map[string]bool{"verified": true})
}
func (app *App) setupPreviewHandler(writer http.ResponseWriter, request *http.Request) {
var payload setupPreviewRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
clientIP := auth.ClientIP(request)
wouldLockOut, reason := auth.WouldLockOut(clientIP, payload.Network)
writeJSON(writer, http.StatusOK, setupPreviewResponse{
WouldLockOut: wouldLockOut,
Reason: reason,
ClientIP: clientIP,
})
}
func (app *App) setupCompleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
return
}
var payload setupCompleteRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
username := strings.TrimSpace(payload.Username)
if username == "" {
username = "Admin"
}
if err := auth.ValidatePassword(payload.Password, username); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidateListenAddress(payload.Network.ListenAddress); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidateHostname(payload.Network.PublicHostname); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidateAccessRules(payload.Network.Rules); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
switch payload.Network.AccessMode {
case settings.AccessModeOpen, settings.AccessModeWhitelist, settings.AccessModeBlacklist:
default:
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "network.access_mode must be open, whitelist, or blacklist"})
return
}
if err := validateSecuritySettings(payload.Security); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if payload.TOTPConfirmed {
if strings.TrimSpace(payload.TOTPSecret) == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "totp_secret is required when totp_confirmed is true"})
return
}
payload.Security.TotpEnabled = true
}
passwordHash, err := auth.HashPassword(payload.Password)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
now := time.Now().UTC()
userID, err := newUserID()
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
user := settings.UserCredential{
ID: userID,
Username: username,
PasswordHash: passwordHash,
TOTPSecret: payload.TOTPSecret,
TOTPConfirmed: payload.TOTPConfirmed,
Enabled: true,
GroupNames: []string{settings.AdministratorsGroupName},
CreatedAt: now,
PasswordChangedAt: now,
}
passwordStore := settings.PasswordStore{Users: []settings.UserCredential{user}}
if err := settings.SavePasswords(app.ConfigDir, passwordStore, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if err := settings.SaveSessions(app.ConfigDir, settings.SessionStore{Sessions: []settings.SessionRecord{}}, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
adminGroup := settings.Group{
Name: settings.AdministratorsGroupName,
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Admin Group",
Permissions: allPermissionList(),
}
network := settings.EffectiveNetwork(payload.Network)
settingsPayload := settings.Settings{
SetupCompleted: true,
Groups: []settings.Group{adminGroup},
Security: payload.Security,
Network: network,
}
if err := settings.SaveSettings(app.ConfigDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, map[string]any{
"completed": true,
"username": username,
})
}
func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
if app.Sessions == nil {
app.Sessions = auth.NewSessionManager(app.ConfigDir, app.Key)
}
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if !completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup is not completed"})
return
}
var payload loginRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var matched *settings.UserCredential
for index := range store.Users {
user := &store.Users[index]
if strings.EqualFold(user.Username, strings.TrimSpace(payload.Username)) {
matched = user
break
}
}
if matched == nil || !matched.Enabled {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return
}
ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash)
if err != nil || !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
security := effectiveSecurity(settingsPayload.Security)
requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != ""
if requiresTOTP {
if !auth.VerifyTOTPCode(matched.TOTPSecret, payload.TOTPCode) {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"})
return
}
}
if _, err := app.Sessions.CreateSession(writer, matched.ID, security); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, meResponse{
UserID: matched.ID,
Username: matched.Username,
Groups: matched.GroupNames,
})
}
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
if app.Sessions != nil {
_ = app.Sessions.DestroySession(writer, request)
}
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
func (app *App) meHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
writeJSON(writer, http.StatusOK, meResponse{
UserID: user.ID,
Username: user.Username,
Groups: user.GroupNames,
})
}
func UserFromContext(ctx context.Context) (settings.UserCredential, bool) {
user, ok := ctx.Value(contextUserKey).(settings.UserCredential)
return user, ok
}
func allPermissionList() []string {
list := make([]string, 0, len(allowedPermissions))
for permission := range allowedPermissions {
list = append(list, permission)
}
// Stable-ish order for tests: sort manually via known catalog order.
ordered := []string{
"nodes.read",
"nodes.exec",
"nodes.update",
"jobs.read",
"jobs.run",
"users.manage",
"secrets.manage",
"roles.manage",
}
_ = list
return ordered
}
func newUserID() (string, error) {
return auth.NewRandomID()
}
func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
payload, err := settings.LoadSettings(configDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return settings.Settings{
Groups: []settings.Group{},
Security: settings.DefaultSecuritySettings(),
Network: settings.DefaultNetworkSettings(),
}, nil
}
return settings.Settings{}, err
}
if payload.Groups == nil {
payload.Groups = []settings.Group{}
}
payload.Network = settings.EffectiveNetwork(payload.Network)
return payload, nil
}