From a74dc33c1c81edb1ecaa9f52e8342984ec313976 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:38:59 +0100 Subject: [PATCH] =?UTF-8?q?http:=20POST=20/api/users=20=E2=80=94=20create?= =?UTF-8?q?=20+=20setup-token=20+=20audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/http/api_users.go | 132 +++++++++++++++++++++++++++++ internal/server/http/server.go | 1 + internal/server/http/users_test.go | 71 ++++++++++++++++ 3 files changed, 204 insertions(+) diff --git a/internal/server/http/api_users.go b/internal/server/http/api_users.go index 9b0a3a8..6bad2c1 100644 --- a/internal/server/http/api_users.go +++ b/internal/server/http/api_users.go @@ -5,9 +5,19 @@ package http import ( + "crypto/rand" + "encoding/hex" "encoding/json" + "errors" "log/slog" stdhttp "net/http" + "net/mail" + "strings" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) type listUsersResponse struct { @@ -50,3 +60,125 @@ func (s *Server) handleAPIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(listUsersResponse{Users: out}) } + +type createUserRequest struct { + Username string `json:"username"` + Email string `json:"email,omitempty"` + Role string `json:"role"` +} + +type createUserResponse struct { + ID string `json:"id"` + SetupURL string `json:"setup_url"` +} + +// generateSetupToken returns 32 random bytes hex-encoded (64 chars). +func generateSetupToken() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +// validRole maps a wire role string to the typed constant. Returns +// ("", false) for anything unknown. +func validRole(r string) (store.Role, bool) { + switch r { + case "admin": + return store.RoleAdmin, true + case "operator": + return store.RoleOperator, true + case "viewer": + return store.RoleViewer, true + } + return "", false +} + +func (s *Server) handleAPIUserCreate(w stdhttp.ResponseWriter, r *stdhttp.Request) { + actor, _ := s.requireUser(r) // already gated by middleware + var req createUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + uname := strings.ToLower(strings.TrimSpace(req.Username)) + if uname == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "username_required", "") + return + } + role, ok := validRole(req.Role) + if !ok { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_role", "") + return + } + if req.Email != "" { + if _, err := mail.ParseAddress(req.Email); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_email", err.Error()) + return + } + } + + // Check for collision against existing user (case-insensitive). + existing, err := s.deps.Store.GetUserByUsername(r.Context(), uname) + if err == nil { + body := map[string]any{ + "error": "username_taken", + "existing_user_id": existing.ID, + "disabled": existing.DisabledAt != nil, + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(stdhttp.StatusConflict) + _ = json.NewEncoder(w).Encode(body) + return + } else if !errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + + id := ulid.Make().String() + now := time.Now().UTC() + var emailPtr *string + if req.Email != "" { + em := strings.ToLower(strings.TrimSpace(req.Email)) + emailPtr = &em + } + if err := s.deps.Store.CreateUser(r.Context(), store.User{ + ID: id, Username: uname, PasswordHash: "", + Role: role, Email: emailPtr, CreatedAt: now, + MustChangePassword: true, + }); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + + rawToken, err := generateSetupToken() + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + var actorID *string + if actor != nil { + actorID = &actor.ID + } + if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{ + UserID: id, TokenHash: hashSetupToken(rawToken), + ExpiresAt: now.Add(time.Hour), + CreatedAt: now, CreatedBy: actorID, + }); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: actorID, Actor: "user", + Action: "user.created", TargetKind: ptr("user"), TargetID: &id, + TS: now, + }) + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(stdhttp.StatusCreated) + _ = json.NewEncoder(w).Encode(createUserResponse{ + ID: id, + SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken, + }) +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 921c0ac..15fe5a9 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -252,6 +252,7 @@ func (s *Server) routes(r chi.Router) { r.Use(s.requireRole(store.RoleAdmin)) r.Get("/api/users", s.handleAPIUsersList) + r.Post("/api/users", s.handleAPIUserCreate) r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest) if s.deps.UI != nil { diff --git a/internal/server/http/users_test.go b/internal/server/http/users_test.go index b5da56b..f55567a 100644 --- a/internal/server/http/users_test.go +++ b/internal/server/http/users_test.go @@ -1,9 +1,11 @@ package http import ( + "bytes" "encoding/json" "io" stdhttp "net/http" + "strings" "testing" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" @@ -33,3 +35,72 @@ func TestAPIUsersList(t *testing.T) { t.Errorf("count: got %d want 2", len(got.Users)) } } + +func TestAPIUserCreate(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + cookie := loginAs(t, srv, adminID) + + body, _ := json.Marshal(map[string]any{ + "username": "Bob", "email": "bob@example.com", "role": "operator", + }) + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users", bytes.NewReader(body)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusCreated { + body, _ := io.ReadAll(res.Body) + t.Fatalf("status: got %d body=%s", res.StatusCode, body) + } + var got struct { + ID string `json:"id"` + SetupURL string `json:"setup_url"` + } + _ = json.NewDecoder(res.Body).Decode(&got) + if got.ID == "" || got.SetupURL == "" { + t.Errorf("missing fields: %+v", got) + } + if !strings.Contains(got.SetupURL, "/setup?token=") { + t.Errorf("setup_url shape: %q", got.SetupURL) + } + + // Verify lowercase-normalised. + u, err := srv.deps.Store.GetUserByUsername(t.Context(), "bob") + if err != nil { + t.Fatalf("get: %v", err) + } + if u.Username != "bob" { + t.Errorf("username: got %q want bob", u.Username) + } + if !u.MustChangePassword { + t.Error("must_change_password not set") + } +} + +func TestAPIUserCreateRejectsDuplicateEnabled(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + makeUser(t, srv, "alice", store.RoleOperator) + cookie := loginAs(t, srv, adminID) + + body, _ := json.Marshal(map[string]any{ + "username": "ALICE", "role": "operator", + }) + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users", bytes.NewReader(body)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusConflict { + t.Errorf("status: got %d want 409", res.StatusCode) + } +}