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.
426 lines
12 KiB
Go
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
|
|
}
|