feat: add user status and path permissions

This commit is contained in:
OpenClaw Agent
2026-09-22 01:30:30 +08:00
parent eafc6c6499
commit 71757ec0fa
5 changed files with 163 additions and 16 deletions
+35 -7
View File
@@ -110,7 +110,7 @@ func account(r *http.Request, s *Server) *auth.Account {
if err != nil { if err != nil {
return s.Auth.Guest 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 a
} }
return s.Auth.Guest return s.Auth.Guest
@@ -465,7 +465,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, http.StatusUnauthorized, "not authenticated") writeJSONError(w, http.StatusUnauthorized, "not authenticated")
return 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) { 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}) 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) { func (s *Server) users(w http.ResponseWriter, r *http.Request) {
a := account(r, s) a := account(r, s)
if a == nil || !a.Admin { 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 { if u.Username == "" || u == s.Auth.Guest {
continue 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}) writeJSON(w, map[string]any{"users": out})
case http.MethodPost: 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}) writeJSON(w, map[string]any{"username": u.Username, "home": u.Home})
case http.MethodPut: case http.MethodPut:
var req struct { var req struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Enabled *bool `json:"enabled"`
Rules []userRuleJSON `json:"rules"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid json") 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") writeJSONError(w, http.StatusBadRequest, "invalid username")
return 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()) writeJSONError(w, http.StatusBadRequest, err.Error())
return 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}) writeJSON(w, map[string]any{"ok": true})
case http.MethodDelete: case http.MethodDelete:
username := r.URL.Query().Get("username") username := r.URL.Query().Get("username")
@@ -76,6 +76,55 @@ func TestUserManagementAndPrivateHome(t *testing.T) {
t.Fatalf("private home access: %d %s", rr.Code, rr.Body.String()) 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 var saved []map[string]any
data, err := os.ReadFile(usersFile) data, err := os.ReadFile(usersFile)
if err != nil { if err != nil {
+6 -1
View File
@@ -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()} 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")} 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()}) $("modal").addEventListener("click",e=>{if(e.target.id==="modal")closeModal()})
async function showUsers(){try{const d=await api("/chfs/users");let html='<h2>用户管理</h2><div class="field"><input id="nu" placeholder="用户名"><input id="np" type="password" placeholder="初始密码"><button onclick="createUser()">创建用户</button></div><div>';for(const u of d.users)html+='<p><b>'+esc(u.username)+'</b> · '+esc(u.home||"")+(u.managed&&!u.admin?' <button class="ghost" onclick="resetUser('+JSON.stringify(u.username)+')">重置密码</button> <button class="danger" onclick="deleteUser('+JSON.stringify(u.username)+')">删除</button>':"")+"</p>";html+='</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>';show(html)}catch(e){alert(e.message)}} async function showUsers(){try{const d=await api("/chfs/users");let html='<h2>用户管理</h2><div class="field"><input id="nu" placeholder="用户名"><input id="np" type="password" placeholder="初始密码"><button onclick="createUser()">创建用户</button></div><div>';for(const u of d.users)html+='<div style="padding:10px 0;border-bottom:1px solid #edf0f5"><b>'+esc(u.username)+'</b> · '+(u.enabled?"启用":"已禁用")+' · '+esc(u.home||"")+(u.managed&&!u.admin?' <button class="ghost" onclick="editUser('+JSON.stringify(u.username)+')">权限/状态</button> <button class="ghost" onclick="resetUser('+JSON.stringify(u.username)+')">重置密码</button> <button class="danger" onclick="deleteUser('+JSON.stringify(u.username)+')">删除</button>':"")+"</div>";html+='</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>';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 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 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)=>'<div class="row" style="justify-content:space-between;border-top:1px solid #edf0f5;padding:8px 0"><span><b>'+esc(r.dir)+'</b> · '+esc(r.mask||"禁止")+'</span><button class="danger" onclick="removeRule('+i+')">移除</button></div>').join("");show('<h2>'+esc(u.username)+'</h2><div class="notice">主目录:'+esc(u.home)+'<br>权限:R 读取 · W 写入 · D 删除</div><label><input id="ue" type="checkbox" '+(u.enabled?"checked":"")+'> 账号启用</label><div class="field"><input id="rp" placeholder="目录,例如 public 或 shared/team"><select id="rm"><option value="r">只读</option><option value="rw">读写</option><option value="rwd">读写删</option><option value="">禁止</option></select><button onclick="addRule()">添加目录权限</button></div><div id="rules">'+rows+'</div><div class="row"><button class="ghost" onclick="showUsers()">取消</button><button onclick="saveUser('+JSON.stringify(u.username)+')">保存</button></div>');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)=>'<div class="row" style="justify-content:space-between;border-top:1px solid #edf0f5;padding:8px 0"><span><b>'+esc(r.dir)+'</b> · '+esc(r.mask||"禁止")+'</span><button class="danger" onclick="removeRule('+i+')">移除</button></div>').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)}} 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(); init();
</script> </script>
+2 -1
View File
@@ -50,6 +50,7 @@ type Account struct {
Rules []PathRule Rules []PathRule
Home string Home string
Admin bool Admin bool
Enabled bool
} }
type Manager struct { type Manager struct {
Accounts map[string]*Account Accounts map[string]*Account
@@ -85,7 +86,7 @@ func ParseRules(values []string) (*Manager, error) {
} }
func (m *Manager) Authenticate(user, pwd string) *Account { 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 return a
} }
if user == "" && pwd == "" { if user == "" && pwd == "" {
+71 -7
View File
@@ -16,9 +16,18 @@ type UserStore struct {
} }
type userRecord struct { type userRecord struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"passwordHash"` Password string `json:"passwordHash"`
Home string `json:"home"` 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}$") 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 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 { func (u *UserStore) Load(m *Manager, homeRoot string) error {
if u.Path == "" { if u.Path == "" {
return nil return nil
@@ -56,8 +70,22 @@ func (u *UserStore) Load(m *Manager, homeRoot string) error {
if home == "" { if home == "" {
home = filepath.ToSlash(filepath.Join(homeRoot, rec.Username)) home = filepath.ToSlash(filepath.Join(homeRoot, rec.Username))
} }
a := &Account{Username: rec.Username, PasswordHash: rec.Password, Mask: None, Home: home} enabled := true
a.Rules = append(a.Rules, PathRule{Dir: home, Mask: Read | Write | Delete}) 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 m.Accounts[rec.Username] = a
} }
return nil return nil
@@ -74,7 +102,11 @@ func (u *UserStore) Save(m *Manager) error {
if a.PasswordHash == "" || a.Home == "" || a.Admin { if a.PasswordHash == "" || a.Home == "" || a.Admin {
continue 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, "", " ") data, err := json.MarshalIndent(records, "", " ")
if err != nil { 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") return nil, fmt.Errorf("user already exists")
} }
home = strings.Trim(home, "/") 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}) a.Rules = append(a.Rules, PathRule{Dir: home, Mask: Read | Write | Delete})
m.Accounts[username] = a m.Accounts[username] = a
return a, u.Save(m) 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 { func (u *UserStore) UpdatePassword(m *Manager, username, password string) error {
if strings.TrimSpace(password) == "" { if strings.TrimSpace(password) == "" {
return fmt.Errorf("password required") return fmt.Errorf("password required")