Files
ClusterCanvas/service/internal/api/users_handlers.go
T
Squid 463aa9a7a3 Add activity logs, sidebar badges, and logout with working alert colors.
Record auth audits and surface them in Activity, poll sidebar counts without resetting idle, and add a profile logout path plus theme red/green/blue tokens so failure badges render.
2026-07-18 21:43:44 +02:00

426 lines
12 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type userPublic struct {
ID string `json:"id"`
Username string `json:"username"`
Enabled bool `json:"enabled"`
TOTPConfirmed bool `json:"totp_confirmed"`
GroupNames []string `json:"group_names"`
CreatedAt time.Time `json:"created_at"`
PasswordChangedAt time.Time `json:"password_changed_at"`
IsAdmin bool `json:"is_admin"`
CanDelete bool `json:"can_delete"`
}
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()})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
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
}
appendAuthAudit(
app.ConfigDir,
"user_create",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryUser,
actorUsernameFromRequest(request),
username,
"",
)
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)
}
appendAuthAudit(
app.ConfigDir,
"user_update",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryUser,
actorUsernameFromRequest(request),
target.Username,
"",
)
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func (app *App) usersDeleteHandler(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
}
userID := strings.TrimSpace(request.URL.Query().Get("id"))
if userID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing user id"})
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()})
return
}
adminCount := countAdministrators(store.Users)
remaining := make([]settings.UserCredential, 0, len(store.Users))
found := false
deletedUsername := ""
for _, user := range store.Users {
if user.ID != userID {
remaining = append(remaining, user)
continue
}
found = true
deletedUsername = user.Username
if userIsAdministrator(user) && adminCount <= 1 {
writeJSON(writer, http.StatusConflict, apiErrorResponse{
Error: "cannot delete the last administrator",
})
return
}
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
return
}
store.Users = remaining
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if app.Sessions != nil {
_ = app.Sessions.InvalidateUserSessions(userID)
}
appendAuthAudit(
app.ConfigDir,
"user_delete",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryUser,
actorUsernameFromRequest(request),
deletedUsername,
"",
)
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
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 {
adminCount := countAdministrators(users)
result := make([]userPublic, 0, len(users))
for _, user := range users {
isAdmin := userIsAdministrator(user)
groupNames := user.GroupNames
if groupNames == nil {
groupNames = []string{}
}
result = append(result, userPublic{
ID: user.ID,
Username: user.Username,
Enabled: user.Enabled,
TOTPConfirmed: user.TOTPConfirmed,
GroupNames: groupNames,
CreatedAt: user.CreatedAt,
PasswordChangedAt: user.PasswordChangedAt,
IsAdmin: isAdmin,
CanDelete: !(isAdmin && adminCount <= 1),
})
}
return result
}
func userIsAdministrator(user settings.UserCredential) bool {
for _, groupName := range user.GroupNames {
if groupName == settings.AdministratorsGroupName {
return true
}
}
return false
}
func countAdministrators(users []settings.UserCredential) int {
count := 0
for _, user := range users {
if userIsAdministrator(user) {
count++
}
}
return count
}