208 lines
5.4 KiB
Go
208 lines
5.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
type UserStore struct {
|
|
Path string
|
|
Mu sync.Mutex
|
|
}
|
|
|
|
type userRecord struct {
|
|
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}$")
|
|
|
|
func ValidateUsername(username string) error {
|
|
if !validUsername.MatchString(username) {
|
|
return fmt.Errorf("invalid username")
|
|
}
|
|
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
|
|
}
|
|
data, err := os.ReadFile(u.Path)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var records []userRecord
|
|
if err := json.Unmarshal(data, &records); err != nil {
|
|
return fmt.Errorf("invalid user store: %w", err)
|
|
}
|
|
for _, rec := range records {
|
|
if err := ValidateUsername(rec.Username); err != nil || rec.Password == "" {
|
|
continue
|
|
}
|
|
if _, exists := m.Accounts[rec.Username]; exists {
|
|
continue
|
|
}
|
|
home := filepath.ToSlash(strings.Trim(strings.TrimSpace(rec.Home), "/"))
|
|
if home == "" {
|
|
home = filepath.ToSlash(filepath.Join(homeRoot, rec.Username))
|
|
}
|
|
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
|
|
}
|
|
|
|
func (u *UserStore) Save(m *Manager) error {
|
|
if u.Path == "" {
|
|
return nil
|
|
}
|
|
u.Mu.Lock()
|
|
defer u.Mu.Unlock()
|
|
records := make([]userRecord, 0)
|
|
for _, a := range m.Accounts {
|
|
if a.PasswordHash == "" || a.Home == "" || a.Admin {
|
|
continue
|
|
}
|
|
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 {
|
|
return err
|
|
}
|
|
if dir := filepath.Dir(u.Path); dir != "." {
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
tmp := u.Path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, u.Path)
|
|
}
|
|
|
|
func (u *UserStore) Add(m *Manager, username, password, home string) (*Account, error) {
|
|
if err := ValidateUsername(username); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(password) == "" {
|
|
return nil, fmt.Errorf("password required")
|
|
}
|
|
if _, ok := m.Accounts[username]; ok {
|
|
return nil, fmt.Errorf("user already exists")
|
|
}
|
|
home = strings.Trim(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 managed user's home is always their private working area. Keep the
|
|
// base RWD rule even when an administrator replaces the extra rules.
|
|
// Additional rules can grant or deny access elsewhere.
|
|
next := make([]PathRule, 0, len(rules)+1)
|
|
next = append(next, PathRule{Dir: a.Home, Mask: Read | Write | Delete})
|
|
for _, rule := range rules {
|
|
if strings.Trim(rule.Dir, "/") == strings.Trim(a.Home, "/") {
|
|
continue
|
|
}
|
|
next = append(next, rule)
|
|
}
|
|
a.Rules = next
|
|
}
|
|
return u.Save(m)
|
|
}
|
|
|
|
func (u *UserStore) UpdatePassword(m *Manager, username, password string) error {
|
|
if strings.TrimSpace(password) == "" {
|
|
return fmt.Errorf("password required")
|
|
}
|
|
a, ok := m.Accounts[username]
|
|
if !ok || a.PasswordHash == "" {
|
|
return fmt.Errorf("managed user not found")
|
|
}
|
|
a.PasswordHash = PasswordHash(password)
|
|
a.Password = ""
|
|
return u.Save(m)
|
|
}
|
|
|
|
func (u *UserStore) Delete(m *Manager, username string) error {
|
|
a, ok := m.Accounts[username]
|
|
if !ok || a.Admin || a.PasswordHash == "" {
|
|
return fmt.Errorf("managed user not found")
|
|
}
|
|
delete(m.Accounts, username)
|
|
return u.Save(m)
|
|
}
|