diff --git a/chfs-ng/cmd/chfs/main.go b/chfs-ng/cmd/chfs/main.go index 98a2193..2b8a90a 100644 --- a/chfs-ng/cmd/chfs/main.go +++ b/chfs-ng/cmd/chfs/main.go @@ -64,6 +64,26 @@ func main() { if err != nil { log.Fatal(err) } + for _, ac := range cfg.Accounts { + a := &auth.Account{Username: ac.Username, Password: ac.Password, Mask: auth.ParseMask(ac.Default)} + for _, d := range ac.None { + a.Rules = append(a.Rules, auth.PathRule{Dir: d, Deny: true}) + } + for _, d := range ac.Read { + a.Rules = append(a.Rules, auth.PathRule{Dir: d, Mask: auth.Read, ChildrenOnly: true}) + } + for _, d := range ac.Write { + a.Rules = append(a.Rules, auth.PathRule{Dir: d, Mask: auth.Write, ChildrenOnly: true}) + } + for _, d := range ac.Delete { + a.Rules = append(a.Rules, auth.PathRule{Dir: d, Mask: auth.Delete, ChildrenOnly: true}) + } + if ac.Username == "guest" { + mgr.Guest = a + } else { + mgr.Accounts[ac.Username] = a + } + } multi := storage.NewMulti(cfg.Path) store := storage.Storage(multi) if len(cfg.Path) == 1 { diff --git a/chfs-ng/go.mod b/chfs-ng/go.mod index e734f68..03b3c93 100644 --- a/chfs-ng/go.mod +++ b/chfs-ng/go.mod @@ -5,6 +5,7 @@ go 1.22 require ( github.com/Bios-Marcel/wastebasket v0.0.3 github.com/emersion/go-webdav v0.7.0 + github.com/golang-jwt/jwt/v4 v4.5.2 ) require golang.org/x/sys v0.3.0 // indirect diff --git a/chfs-ng/go.sum b/chfs-ng/go.sum index 7dec236..e92bb1f 100644 --- a/chfs-ng/go.sum +++ b/chfs-ng/go.sum @@ -4,6 +4,8 @@ github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegN github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA= github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/chfs-ng/internal/api/server.go b/chfs-ng/internal/api/server.go index 0f43f43..a662119 100644 --- a/chfs-ng/internal/api/server.go +++ b/chfs-ng/internal/api/server.go @@ -69,20 +69,16 @@ func (s *Server) Handler() http.Handler { return s.ipFilter(mux) } func account(r *http.Request, s *Server) *auth.Account { - if c, e := r.Cookie("chfs_token"); e == nil { - s.SessionMu.RLock() - a := s.Sessions[c.Value] - exp := s.SessionExpiry[c.Value] - s.SessionMu.RUnlock() - if a != nil && (exp.IsZero() || time.Now().Before(exp)) { - return a - } - if a != nil { - s.SessionMu.Lock() - delete(s.Sessions, c.Value) - delete(s.SessionExpiry, c.Value) - s.SessionMu.Unlock() - } + c, err := r.Cookie("JWT") + if err != nil { + return s.Auth.Guest + } + username, err := auth.GetAccFromToken(c.Value) + if err != nil { + return s.Auth.Guest + } + if a, ok := s.Auth.Accounts[username]; ok { + return a } return s.Auth.Guest } @@ -256,15 +252,20 @@ func (s *Server) exist(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"exist": e == nil}) } func (s *Server) session(w http.ResponseWriter, r *http.Request) { - if r.Method == "DELETE" { - if c, e := r.Cookie("chfs_token"); e == nil { - s.SessionMu.Lock() - delete(s.Sessions, c.Value) - delete(s.SessionExpiry, c.Value) - s.SessionMu.Unlock() + if r.Method == http.MethodDelete { + jwtValue, _ := r.Cookie("JWT") + userValue, _ := r.Cookie("user") + jwt := "" + user := "" + if jwtValue != nil { + jwt = jwtValue.Value } - http.SetCookie(w, &http.Cookie{Name: "chfs_token", Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) - writeJSON(w, map[string]any{"ok": true}) + if userValue != nil { + user = userValue.Value + } + http.SetCookie(w, &http.Cookie{Name: "JWT", Value: jwt, Path: "/", MaxAge: -1}) + http.SetCookie(w, &http.Cookie{Name: "user", Value: user, Path: "/", MaxAge: -1}) + w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodPost { @@ -281,25 +282,15 @@ func (s *Server) session(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusNotFound, "user or password is wrong") return } - token := auth.NewToken() - exp := time.Time{} - maxAge := 0 - if s.SessionTimeout > 0 { - exp = time.Now().Add(time.Duration(s.SessionTimeout) * time.Minute) - maxAge = s.SessionTimeout * 60 + token, err := auth.NewToken(a.Username) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to create token") + return } - s.SessionMu.Lock() - if s.Sessions == nil { - s.Sessions = make(map[string]*auth.Account) - } - if s.SessionExpiry == nil { - s.SessionExpiry = make(map[string]time.Time) - } - s.Sessions[token] = a - s.SessionExpiry[token] = exp - s.SessionMu.Unlock() - http.SetCookie(w, &http.Cookie{Name: "chfs_token", Value: token, Path: "/", MaxAge: maxAge, HttpOnly: true, SameSite: http.SameSiteLaxMode}) - writeJSON(w, map[string]any{"ok": true, "user": a.Username}) + expires := time.Now().Add(365 * 24 * time.Hour) + http.SetCookie(w, &http.Cookie{Name: "JWT", Value: token, Path: "/", Expires: expires, HttpOnly: true}) + http.SetCookie(w, &http.Cookie{Name: "user", Value: a.Username, Path: "/", Expires: expires}) + w.WriteHeader(http.StatusCreated) } func (s *Server) newtext(w http.ResponseWriter, r *http.Request) { diff --git a/chfs-ng/internal/auth/auth.go b/chfs-ng/internal/auth/auth.go index f7beadc..1542262 100644 --- a/chfs-ng/internal/auth/auth.go +++ b/chfs-ng/internal/auth/auth.go @@ -1,10 +1,11 @@ package auth import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" + "fmt" "strings" + "time" + + jwt "github.com/golang-jwt/jwt/v4" ) type PermMask uint8 @@ -35,8 +36,10 @@ func ParseMask(s string) PermMask { } type PathRule struct { - Dir string - Mask PermMask + Dir string + Mask PermMask + Deny bool + ChildrenOnly bool } type Account struct { Username, Password string @@ -91,9 +94,19 @@ func (a *Account) MaskFor(path string) PermMask { mask := a.Mask for _, r := range a.Rules { d := strings.Trim(r.Dir, "/") - if matchPath(d, path) && len(d) > best { + if !matchPath(d, path) { + continue + } + if r.ChildrenOnly && matchPathExact(d, path) { + continue + } + if len(d) > best { best = len(d) - mask = r.Mask + if r.Deny { + mask = None + } else { + mask = r.Mask + } } } return mask @@ -143,13 +156,49 @@ func matchSegment(pattern, value string) bool { return dp[len(p)][len(v)] } +func matchPathExact(pattern, path string) bool { + pp := strings.Split(strings.Trim(pattern, "/"), "/") + ps := strings.Split(strings.Trim(path, "/"), "/") + if len(pp) != len(ps) { + return false + } + return matchPath(pattern, path) +} + func MatchPath(pattern, path string) bool { return matchPath(pattern, path) } -func NewToken() string { - b := make([]byte, 32) - if _, e := rand.Read(b); e != nil { - h := sha256.Sum256([]byte("fallback")) - return hex.EncodeToString(h[:]) +const jwtSecret = "_pengyichen2015_" + +type jwtClaims struct { + Acc string `json:"acc"` + jwt.StandardClaims +} + +func NewToken(username string) (string, error) { + claims := jwtClaims{Acc: username, StandardClaims: jwt.StandardClaims{ExpiresAt: time.Now().Add(365 * 24 * time.Hour).Unix()}} + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(jwtSecret)) +} + +func GetAccFromToken(raw string) (string, error) { + if raw == "" { + return "", fmt.Errorf("empty token") } - return hex.EncodeToString(b) + var claims jwtClaims + token, err := jwt.ParseWithClaims(raw, &claims, func(t *jwt.Token) (interface{}, error) { + if t.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method: %s", t.Method.Alg()) + } + return []byte(jwtSecret), nil + }) + if err != nil || token == nil || !token.Valid { + if err == nil { + err = fmt.Errorf("invalid token") + } + return "", err + } + if claims.Acc == "" { + return "", fmt.Errorf("token missing account") + } + return claims.Acc, nil } diff --git a/chfs-ng/internal/config/config.go b/chfs-ng/internal/config/config.go index e88ab30..5868be8 100644 --- a/chfs-ng/internal/config/config.go +++ b/chfs-ng/internal/config/config.go @@ -8,6 +8,16 @@ import ( "strings" ) +type AccountConfig struct { + Username string + Password string + Default string + None []string + Read []string + Write []string + Delete []string +} + type Config struct { Path []string Port int @@ -22,6 +32,7 @@ type Config struct { HTMLNotice string SSLCert string SSLKey string + Accounts []AccountConfig } func Default() Config { @@ -36,17 +47,45 @@ func Load(path string) (Config, error) { } defer f.Close() s := bufio.NewScanner(f) + var section *AccountConfig for s.Scan() { line := strings.TrimSpace(s.Text()) if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") { continue } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + name := strings.TrimSpace(line[1 : len(line)-1]) + if name != "" { + c.Accounts = append(c.Accounts, AccountConfig{Username: name}) + section = &c.Accounts[len(c.Accounts)-1] + } else { + section = nil + } + continue + } i := strings.IndexByte(line, '=') if i < 0 { continue } key, val := strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]) val = strings.Trim(val, "\\\"") + if section != nil { + switch strings.ToLower(key) { + case "password": + section.Password = val + case "rule.default": + section.Default = val + case "rule.none": + section.None = appendNonEmpty(section.None, val) + case "rule.r": + section.Read = appendNonEmpty(section.Read, val) + case "rule.w": + section.Write = appendNonEmpty(section.Write, val) + case "rule.d": + section.Delete = appendNonEmpty(section.Delete, val) + } + continue + } switch strings.ToLower(key) { case "path": c.Path = splitPath(val) @@ -126,3 +165,13 @@ func (c Config) Validate() error { } return nil } + +func appendNonEmpty(dst []string, value string) []string { + for _, item := range strings.Split(value, "|") { + item = strings.TrimSpace(item) + if item != "" { + dst = append(dst, item) + } + } + return dst +}