diff --git a/chfs-ng/internal/api/server.go b/chfs-ng/internal/api/server.go index 0ba47df..4a90428 100644 --- a/chfs-ng/internal/api/server.go +++ b/chfs-ng/internal/api/server.go @@ -110,7 +110,7 @@ func account(r *http.Request, s *Server) *auth.Account { if err != nil { return s.Auth.Guest } - if a, ok := s.Auth.Accounts[username]; ok { + if a, ok := s.Auth.Accounts[username]; ok && (a.Enabled || a.PasswordHash == "") { return a } return s.Auth.Guest @@ -465,7 +465,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusUnauthorized, "not authenticated") return } - writeJSON(w, map[string]any{"username": a.Username, "home": a.Home, "admin": a.Admin, "mask": permString(a.MaskFor(a.Home)), "managed": a.PasswordHash != ""}) + writeJSON(w, map[string]any{"username": a.Username, "home": a.Home, "admin": a.Admin, "mask": permString(a.MaskFor(a.Home)), "managed": a.PasswordHash != "", "enabled": a.Enabled}) } func (s *Server) password(w http.ResponseWriter, r *http.Request) { @@ -502,6 +502,21 @@ func (s *Server) password(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": true}) } +type userRuleJSON struct { + Dir string `json:"dir"` + Mask string `json:"mask"` + Deny bool `json:"deny,omitempty"` + ChildrenOnly bool `json:"childrenOnly,omitempty"` +} + +func userRulesJSON(rules []auth.PathRule) []userRuleJSON { + out := make([]userRuleJSON, 0, len(rules)) + for _, r := range rules { + out = append(out, userRuleJSON{Dir: r.Dir, Mask: permString(r.Mask), Deny: r.Deny, ChildrenOnly: r.ChildrenOnly}) + } + return out +} + func (s *Server) users(w http.ResponseWriter, r *http.Request) { a := account(r, s) if a == nil || !a.Admin { @@ -519,7 +534,7 @@ func (s *Server) users(w http.ResponseWriter, r *http.Request) { if u.Username == "" || u == s.Auth.Guest { continue } - out = append(out, map[string]any{"username": u.Username, "home": u.Home, "admin": u.Admin, "managed": u.PasswordHash != ""}) + out = append(out, map[string]any{"username": u.Username, "home": u.Home, "admin": u.Admin, "managed": u.PasswordHash != "", "enabled": u.Enabled, "rules": userRulesJSON(u.Rules)}) } writeJSON(w, map[string]any{"users": out}) case http.MethodPost: @@ -550,8 +565,10 @@ func (s *Server) users(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"username": u.Username, "home": u.Home}) case http.MethodPut: var req struct { - Username string `json:"username"` - Password string `json:"password"` + Username string `json:"username"` + Password string `json:"password"` + Enabled *bool `json:"enabled"` + Rules []userRuleJSON `json:"rules"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid json") @@ -561,11 +578,22 @@ func (s *Server) users(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusBadRequest, "invalid username") return } - if err := s.UserStore.UpdatePassword(s.Auth, req.Username, req.Password); err != nil { + var rules []auth.PathRule + if req.Rules != nil { + for _, rr := range req.Rules { + dir := strings.Trim(strings.ReplaceAll(rr.Dir, "\\", "/"), "/") + if dir == "" || unsafePath(dir) { + writeJSONError(w, http.StatusBadRequest, "invalid rule path") + return + } + rules = append(rules, auth.PathRule{Dir: dir, Mask: auth.ParseMask(rr.Mask), Deny: rr.Deny, ChildrenOnly: rr.ChildrenOnly}) + } + } + if err := s.UserStore.Update(s.Auth, req.Username, req.Password, req.Enabled, rules); err != nil { writeJSONError(w, http.StatusBadRequest, err.Error()) return } - s.logOp(r, fmt.Sprintf("reset password for '%s'", req.Username)) + s.logOp(r, fmt.Sprintf("updated user '%s'", req.Username)) writeJSON(w, map[string]any{"ok": true}) case http.MethodDelete: username := r.URL.Query().Get("username") diff --git a/chfs-ng/internal/api/user_management_test.go b/chfs-ng/internal/api/user_management_test.go index 9565535..9ed474a 100644 --- a/chfs-ng/internal/api/user_management_test.go +++ b/chfs-ng/internal/api/user_management_test.go @@ -76,6 +76,55 @@ func TestUserManagementAndPrivateHome(t *testing.T) { t.Fatalf("private home access: %d %s", rr.Code, rr.Body.String()) } + // Admin can disable a managed user, which immediately invalidates token-based access. + updateBody, _ := json.Marshal(map[string]any{"username": "alice", "enabled": false}) + req = httptest.NewRequest(http.MethodPut, "/chfs/users", bytes.NewReader(updateBody)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(cookie) + rr = httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("disable user: %d %s", rr.Code, rr.Body.String()) + } + req = httptest.NewRequest(http.MethodPost, "/chfs/session", strings.NewReader("user=alice&pwd=alice-pass")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code == http.StatusCreated { + t.Fatal("disabled user was allowed to login") + } + + // Re-enable and grant read-only access to a shared directory. + rules := []map[string]any{ + {"dir": "users/alice", "mask": "rwd"}, + {"dir": "public", "mask": "r"}, + } + updateBody, _ = json.Marshal(map[string]any{"username": "alice", "enabled": true, "rules": rules}) + req = httptest.NewRequest(http.MethodPut, "/chfs/users", bytes.NewReader(updateBody)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(cookie) + rr = httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("update rules: %d %s", rr.Code, rr.Body.String()) + } + if err := os.MkdirAll(filepath.Join(root, "public"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "public", "readme.txt"), []byte("ok"), 0644); err != nil { + t.Fatal(err) + } + token, err = auth.NewToken("alice", 30) + if err != nil { + t.Fatal(err) + } + req = httptest.NewRequest(http.MethodGet, "/chfs/files?filepath=public", nil) + req.AddCookie(&http.Cookie{Name: "JWT", Value: token}) + rr = httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "\"currentMask\":\"R\"") { + t.Fatalf("shared read rule: %d %s", rr.Code, rr.Body.String()) + } var saved []map[string]any data, err := os.ReadFile(usersFile) if err != nil { diff --git a/chfs-ng/internal/api/web/index.html b/chfs-ng/internal/api/web/index.html index a0bfea0..b6aa0f7 100644 --- a/chfs-ng/internal/api/web/index.html +++ b/chfs-ng/internal/api/web/index.html @@ -60,9 +60,14 @@ async function login(){try{await fetch("/chfs/session",{method:"POST",headers:{" async function logout(){await fetch("/chfs/session",{method:"DELETE"});location.reload()} function show(html){$("dialog").innerHTML=html;$("modal").classList.add("show")}function closeModal(){$("modal").classList.remove("show")} $("modal").addEventListener("click",e=>{if(e.target.id==="modal")closeModal()}) -async function showUsers(){try{const d=await api("/chfs/users");let html='
'+esc(u.username)+' · '+esc(u.home||"")+(u.managed&&!u.admin?' ':"")+"
";html+='