Add SSH-managed node registry with connection testing and reauth.
Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
@@ -24,6 +27,23 @@ type usersResponse struct {
|
||||
Users []userPublic `json:"users"`
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
GroupNames []string `json:"group_names"`
|
||||
}
|
||||
|
||||
type patchUserRequest struct {
|
||||
GroupNames *[]string `json:"group_names"`
|
||||
Password *string `json:"password"`
|
||||
DisableTOTP *bool `json:"disable_totp"`
|
||||
}
|
||||
|
||||
type deleteUserRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
|
||||
func (app *App) usersGetHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.requireKey(); err != nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
||||
@@ -39,8 +59,206 @@ func (app *App) usersGetHandler(writer http.ResponseWriter, request *http.Reques
|
||||
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
|
||||
}
|
||||
|
||||
func (app *App) usersCreateHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeUsersWrite(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := app.requireKey(); err != nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var payload createUserRequest
|
||||
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 == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "username is required"})
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(payload.Password, username); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
groupNames, err := validateGroupNames(payload.GroupNames, settingsPayload.Groups)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for _, existing := range store.Users {
|
||||
if strings.EqualFold(existing.Username, username) {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "username already exists"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(payload.Password)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
userID, err := newUserID()
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
store.Users = append(store.Users, settings.UserCredential{
|
||||
ID: userID,
|
||||
Username: username,
|
||||
PasswordHash: hash,
|
||||
Enabled: true,
|
||||
GroupNames: groupNames,
|
||||
CreatedAt: now,
|
||||
PasswordChangedAt: now,
|
||||
})
|
||||
|
||||
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
|
||||
}
|
||||
|
||||
func (app *App) usersPatchHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeUsersWrite(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := app.requireKey(); err != nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.requireGraceReauth(request); err != nil {
|
||||
if err.Error() == reauthRequiredMessage {
|
||||
writeReauthRequired(writer)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(request.URL.Query().Get("id"))
|
||||
if userID == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing user id"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload patchUserRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
if payload.GroupNames == nil && payload.Password == nil && payload.DisableTOTP == nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
index := -1
|
||||
for i := range store.Users {
|
||||
if store.Users[i].ID == userID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
target := &store.Users[index]
|
||||
passwordChanged := false
|
||||
|
||||
if payload.GroupNames != nil {
|
||||
groupNames, err := validateGroupNames(*payload.GroupNames, settingsPayload.Groups)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
wasAdmin := userIsAdministrator(*target)
|
||||
wouldBeAdmin := false
|
||||
for _, name := range groupNames {
|
||||
if name == settings.AdministratorsGroupName {
|
||||
wouldBeAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if wasAdmin && !wouldBeAdmin && countAdministrators(store.Users) <= 1 {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{
|
||||
Error: "cannot remove the last administrator from the Administrators group",
|
||||
})
|
||||
return
|
||||
}
|
||||
target.GroupNames = groupNames
|
||||
}
|
||||
|
||||
if payload.Password != nil {
|
||||
if err := auth.ValidatePassword(*payload.Password, target.Username); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(*payload.Password)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
target.PasswordHash = hash
|
||||
target.PasswordChangedAt = time.Now().UTC()
|
||||
passwordChanged = true
|
||||
}
|
||||
|
||||
if payload.DisableTOTP != nil && *payload.DisableTOTP {
|
||||
target.TOTPSecret = ""
|
||||
target.TOTPConfirmed = false
|
||||
}
|
||||
|
||||
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if passwordChanged && app.Sessions != nil {
|
||||
_ = app.Sessions.InvalidateUserSessions(userID)
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
|
||||
}
|
||||
|
||||
func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeUsersWrite(request); err != nil {
|
||||
if err := app.authorizeUsersWrite(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -55,6 +273,17 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
var payload deleteUserRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
@@ -96,9 +325,31 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
|
||||
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
|
||||
}
|
||||
|
||||
func authorizeUsersWrite(_ *http.Request) error {
|
||||
// TODO: integrate authz middleware.
|
||||
return nil
|
||||
func validateGroupNames(names []string, groups []settings.Group) ([]string, error) {
|
||||
if names == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
known := make(map[string]struct{}, len(groups))
|
||||
for _, group := range groups {
|
||||
known[group.Name] = struct{}{}
|
||||
}
|
||||
result := make([]string, 0, len(names))
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := known[trimmed]; !ok {
|
||||
return nil, fmt.Errorf("unknown group: %s", trimmed)
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func toPublicUsers(users []settings.UserCredential) []userPublic {
|
||||
|
||||
Reference in New Issue
Block a user