From 71757ec0fad7688d6bfd51f85c7f9de1f6c5aeca Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Tue, 22 Sep 2026 01:30:30 +0800 Subject: [PATCH] feat: add user status and path permissions --- chfs-ng/internal/api/server.go | 42 +++++++++-- chfs-ng/internal/api/user_management_test.go | 49 ++++++++++++ chfs-ng/internal/api/web/index.html | 7 +- chfs-ng/internal/auth/auth.go | 3 +- chfs-ng/internal/auth/users.go | 78 ++++++++++++++++++-- 5 files changed, 163 insertions(+), 16 deletions(-) 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='

用户管理

';for(const u of d.users)html+='

'+esc(u.username)+' · '+esc(u.home||"")+(u.managed&&!u.admin?' ':"")+"

";html+='
';show(html)}catch(e){alert(e.message)}} +async function showUsers(){try{const d=await api("/chfs/users");let html='

用户管理

';for(const u of d.users)html+='
'+esc(u.username)+' · '+(u.enabled?"启用":"已禁用")+' · '+esc(u.home||"")+(u.managed&&!u.admin?' ':"")+"
";html+='
';show(html)}catch(e){alert(e.message)}} async function createUser(){try{await api("/chfs/users",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:$("nu").value,password:$("np").value})});showUsers()}catch(e){alert(e.message)}} async function resetUser(u){const p=prompt("为 "+u+" 设置新密码");if(!p)return;try{await api("/chfs/users",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,password:p})});alert("密码已更新")}catch(e){alert(e.message)}} +async function editUser(name){const d=await api("/chfs/users");const u=d.users.find(x=>x.username===name);if(!u)return;let rows=(u.rules||[]).map((r,i)=>'
'+esc(r.dir)+' · '+esc(r.mask||"禁止")+'
').join("");show('

'+esc(u.username)+'

主目录:'+esc(u.home)+'
权限:R 读取 · W 写入 · D 删除
'+rows+'
');window._editUser=u;window._editRules=[...(u.rules||[])]} +function addRule(){const dir=$("rp").value.trim().replace(/^\/+|\/+$/g,"");if(!dir)return;window._editRules.push({dir,mask:$("rm").value});renderEditRules()} +function removeRule(i){window._editRules.splice(i,1);renderEditRules()} +function renderEditRules(){$("rules").innerHTML=window._editRules.map((r,i)=>'
'+esc(r.dir)+' · '+esc(r.mask||"禁止")+'
').join("")} +async function saveUser(u){try{await api("/chfs/users",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,enabled:$("ue").checked,rules:window._editRules})});showUsers()}catch(e){alert(e.message)}} async function deleteUser(u){if(!confirm("删除用户 "+u+"?用户目录不会自动删除。"))return;try{await api("/chfs/users?username="+encodeURIComponent(u),{method:"DELETE"});showUsers()}catch(e){alert(e.message)}} init(); diff --git a/chfs-ng/internal/auth/auth.go b/chfs-ng/internal/auth/auth.go index 913632a..1f23e27 100644 --- a/chfs-ng/internal/auth/auth.go +++ b/chfs-ng/internal/auth/auth.go @@ -50,6 +50,7 @@ type Account struct { Rules []PathRule Home string Admin bool + Enabled bool } type Manager struct { Accounts map[string]*Account @@ -85,7 +86,7 @@ func ParseRules(values []string) (*Manager, error) { } func (m *Manager) Authenticate(user, pwd string) *Account { - if a, ok := m.Accounts[user]; ok && (a.Password == pwd || a.PasswordHash == PasswordHash(pwd)) { + if a, ok := m.Accounts[user]; ok && (a.Enabled || a.PasswordHash == "") && (a.Password == pwd || a.PasswordHash == PasswordHash(pwd)) { return a } if user == "" && pwd == "" { diff --git a/chfs-ng/internal/auth/users.go b/chfs-ng/internal/auth/users.go index e535350..29c06f1 100644 --- a/chfs-ng/internal/auth/users.go +++ b/chfs-ng/internal/auth/users.go @@ -16,9 +16,18 @@ type UserStore struct { } type userRecord struct { - Username string `json:"username"` - Password string `json:"passwordHash"` - Home string `json:"home"` + Username string `json:"username"` + Password string `json:"passwordHash"` + Home string `json:"home"` + Enabled *bool `json:"enabled,omitempty"` + Rules []userRule `json:"rules,omitempty"` +} + +type userRule struct { + Dir string `json:"dir"` + Mask string `json:"mask"` + Deny bool `json:"deny,omitempty"` + ChildrenOnly bool `json:"childrenOnly,omitempty"` } var validUsername = regexp.MustCompile("^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$") @@ -30,6 +39,11 @@ func ValidateUsername(username string) error { return nil } +func unsafeUserPath(p string) bool { + p = strings.ReplaceAll(p, "\\", "/") + return p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "/../") || strings.HasPrefix(p, "/") +} + func (u *UserStore) Load(m *Manager, homeRoot string) error { if u.Path == "" { return nil @@ -56,8 +70,22 @@ func (u *UserStore) Load(m *Manager, homeRoot string) error { if home == "" { home = filepath.ToSlash(filepath.Join(homeRoot, rec.Username)) } - a := &Account{Username: rec.Username, PasswordHash: rec.Password, Mask: None, Home: home} - a.Rules = append(a.Rules, PathRule{Dir: home, Mask: Read | Write | Delete}) + enabled := true + if rec.Enabled != nil { + enabled = *rec.Enabled + } + a := &Account{Username: rec.Username, PasswordHash: rec.Password, Mask: None, Home: home, Enabled: enabled} + if len(rec.Rules) == 0 { + a.Rules = append(a.Rules, PathRule{Dir: home, Mask: Read | Write | Delete}) + } else { + for _, rr := range rec.Rules { + dir := strings.Trim(strings.ReplaceAll(rr.Dir, "\\", "/"), "/") + if dir == "" || unsafeUserPath(dir) { + continue + } + a.Rules = append(a.Rules, PathRule{Dir: dir, Mask: ParseMask(rr.Mask), Deny: rr.Deny, ChildrenOnly: rr.ChildrenOnly}) + } + } m.Accounts[rec.Username] = a } return nil @@ -74,7 +102,11 @@ func (u *UserStore) Save(m *Manager) error { if a.PasswordHash == "" || a.Home == "" || a.Admin { continue } - records = append(records, userRecord{Username: a.Username, Password: a.PasswordHash, Home: a.Home}) + rec := userRecord{Username: a.Username, Password: a.PasswordHash, Home: a.Home, Enabled: &a.Enabled} + for _, rr := range a.Rules { + rec.Rules = append(rec.Rules, userRule{Dir: rr.Dir, Mask: maskString(rr.Mask), Deny: rr.Deny, ChildrenOnly: rr.ChildrenOnly}) + } + records = append(records, rec) } data, err := json.MarshalIndent(records, "", " ") if err != nil { @@ -103,12 +135,44 @@ func (u *UserStore) Add(m *Manager, username, password, home string) (*Account, return nil, fmt.Errorf("user already exists") } home = strings.Trim(home, "/") - a := &Account{Username: username, PasswordHash: PasswordHash(password), Mask: None, Home: home} + a := &Account{Username: username, PasswordHash: PasswordHash(password), Mask: None, Home: home, Enabled: true} a.Rules = append(a.Rules, PathRule{Dir: home, Mask: Read | Write | Delete}) m.Accounts[username] = a return a, u.Save(m) } +func maskString(p PermMask) string { + var b strings.Builder + if p.HasRead() { + b.WriteByte('r') + } + if p.HasWrite() { + b.WriteByte('w') + } + if p.HasDelete() { + b.WriteByte('d') + } + return b.String() +} + +func (u *UserStore) Update(m *Manager, username, password string, enabled *bool, rules []PathRule) error { + a, ok := m.Accounts[username] + if !ok || a.PasswordHash == "" || a.Admin { + return fmt.Errorf("managed user not found") + } + if strings.TrimSpace(password) != "" { + a.PasswordHash = PasswordHash(password) + a.Password = "" + } + if enabled != nil { + a.Enabled = *enabled + } + if rules != nil { + a.Rules = rules + } + return u.Save(m) +} + func (u *UserStore) UpdatePassword(m *Manager, username, password string) error { if strings.TrimSpace(password) == "" { return fmt.Errorf("password required")