diff --git a/cmd/chfs/main.go b/cmd/chfs/main.go index 52a7b03..f42cca8 100644 --- a/cmd/chfs/main.go +++ b/cmd/chfs/main.go @@ -1,6 +1,8 @@ package main import ( + "crypto/rand" + "encoding/hex" "flag" "log" "net/http" @@ -124,6 +126,14 @@ func main() { if err := users.Load(mgr, cfg.UserHome); err != nil { log.Fatal(err) } + jwtSecret := cfg.JWTSecret + if jwtSecret == "" { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + log.Fatal(err) + } + jwtSecret = hex.EncodeToString(buf) + } for _, a := range mgr.Accounts { if a.Home != "" { if err := store.MkdirAll(a.Home); err != nil { @@ -131,7 +141,7 @@ func main() { } } } - srv := &api.Server{Store: store, Auth: mgr, Sessions: map[string]*auth.Account{}, SessionExpiry: map[string]time.Time{}, SessionTimeout: cfg.SessionTimeout, WebDAVRoot: root, Allow: cfg.Allow, MultiRoot: len(cfg.Path) > 1, RemoveMode: cfg.FileRemove, ImagePreview: cfg.ImagePreview, FolderDownload: cfg.FolderDownload, HTMLTitle: cfg.HTMLTitle, HTMLNotice: cfg.HTMLNotice, Log: opslog.New(cfg.Log), UserStore: users, UserHome: cfg.UserHome} + srv := &api.Server{Store: store, Auth: mgr, Sessions: map[string]*auth.Account{}, SessionExpiry: map[string]time.Time{}, SessionTimeout: cfg.SessionTimeout, JWTSecret: jwtSecret, WebDAVRoot: root, Allow: cfg.Allow, MultiRoot: len(cfg.Path) > 1, RemoveMode: cfg.FileRemove, ImagePreview: cfg.ImagePreview, FolderDownload: cfg.FolderDownload, HTMLTitle: cfg.HTMLTitle, HTMLNotice: cfg.HTMLNotice, Log: opslog.New(cfg.Log), UserStore: users, UserHome: cfg.UserHome} addr := ":" + itoa(cfg.Port) log.Printf("chfs-ng listening on %s root=%s removeMode=%d", addr, filepath.Clean(root), cfg.FileRemove) if cfg.SSLCert != "" && cfg.SSLKey != "" { diff --git a/docs/research/compatibility/config-v1.md b/docs/research/compatibility/config-v1.md index 50d836a..3c81b9a 100644 --- a/docs/research/compatibility/config-v1.md +++ b/docs/research/compatibility/config-v1.md @@ -5,7 +5,8 @@ image.preview=true file.remove=3 Known additional keys from binary/config ecosystem: -session.timeout +session.timeout # login session lifetime in minutes; default 30 +jwt.secret # optional signing secret; if omitted a random secret is generated per process folder.download folder.leaf.download rule.default diff --git a/internal/api/server.go b/internal/api/server.go index 7c71fa7..fb653f9 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -42,6 +42,7 @@ type Server struct { SessionExpiry map[string]time.Time SessionMu sync.RWMutex SessionTimeout int + JWTSecret string WebDAVRoot string WebDAVFS webdav.FileSystem Allow string @@ -113,7 +114,7 @@ func account(r *http.Request, s *Server) *auth.Account { if err != nil { return s.Auth.Guest } - username, err := auth.GetAccFromToken(c.Value) + username, err := auth.GetAccFromToken(c.Value, s.JWTSecret) if err != nil { return s.Auth.Guest } @@ -511,18 +512,8 @@ func (s *Server) exist(w http.ResponseWriter, r *http.Request) { func (s *Server) session(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodDelete { - jwtValue, _ := r.Cookie("JWT") - userValue, _ := r.Cookie("user") - jwt := "" - user := "" - if jwtValue != nil { - jwt = jwtValue.Value - } - 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}) + http.SetCookie(w, &http.Cookie{Name: "JWT", Value: "", Path: "/", MaxAge: -1, Expires: time.Unix(1, 0), HttpOnly: true, SameSite: http.SameSiteLaxMode}) + http.SetCookie(w, &http.Cookie{Name: "user", Value: "", Path: "/", MaxAge: -1, Expires: time.Unix(1, 0), SameSite: http.SameSiteLaxMode}) w.WriteHeader(http.StatusNoContent) return } @@ -540,14 +531,19 @@ func (s *Server) session(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusNotFound, "user or password is wrong") return } - token, err := auth.NewToken(a.Username, s.SessionTimeout) + timeout := s.SessionTimeout + if timeout <= 0 { + timeout = 30 + } + token, err := auth.NewToken(a.Username, timeout, s.JWTSecret) if err != nil { writeJSONError(w, http.StatusInternalServerError, "failed to create token") return } - 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}) + expires := time.Now().Add(time.Duration(timeout) * time.Minute) + secure := r.TLS != nil + http.SetCookie(w, &http.Cookie{Name: "JWT", Value: token, Path: "/", MaxAge: timeout * 60, Expires: expires, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode}) + http.SetCookie(w, &http.Cookie{Name: "user", Value: a.Username, Path: "/", MaxAge: timeout * 60, Expires: expires, Secure: secure, SameSite: http.SameSiteLaxMode}) w.WriteHeader(http.StatusCreated) } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index ff9f54a..b2b0edf 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -197,16 +197,24 @@ type jwtClaims struct { jwt.StandardClaims } -func NewToken(username string, _ int) (string, error) { - // CHFS v4 keeps the JWT itself valid for one year. session.timeout is - // retained as a compatibility configuration field, but it does not alter - // the JWT exp claim in the original implementation. - claims := jwtClaims{Acc: username, StandardClaims: jwt.StandardClaims{ExpiresAt: time.Now().Add(365 * 24 * time.Hour).Unix()}} +func NewToken(username string, timeoutMinutes int, secrets ...string) (string, error) { + if timeoutMinutes <= 0 { + timeoutMinutes = 30 + } + secret := jwtSecret + if len(secrets) > 0 && secrets[0] != "" { + secret = secrets[0] + } + claims := jwtClaims{Acc: username, StandardClaims: jwt.StandardClaims{ExpiresAt: time.Now().Add(time.Duration(timeoutMinutes) * time.Minute).Unix()}} token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString([]byte(jwtSecret)) + return token.SignedString([]byte(secret)) } -func GetAccFromToken(raw string) (string, error) { +func GetAccFromToken(raw string, secrets ...string) (string, error) { + secret := jwtSecret + if len(secrets) > 0 && secrets[0] != "" { + secret = secrets[0] + } if raw == "" { return "", fmt.Errorf("empty token") } @@ -215,7 +223,7 @@ func GetAccFromToken(raw string) (string, error) { if t.Method != jwt.SigningMethodHS256 { return nil, fmt.Errorf("unexpected signing method: %s", t.Method.Alg()) } - return []byte(jwtSecret), nil + return []byte(secret), nil }) if err != nil || token == nil || !token.Valid { if err == nil { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index d3d19ff..e4183e3 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -1,6 +1,33 @@ package auth -import "testing" +import ( + "testing" + "time" + + jwt "github.com/golang-jwt/jwt/v4" +) + +func TestTokenUsesConfiguredSecretAndTimeout(t *testing.T) { + const secret = "test-secret" + before := time.Now().Add(2 * time.Minute).Unix() + raw, err := NewToken("alice", 2, secret) + if err != nil { + t.Fatal(err) + } + var claims jwtClaims + token, err := jwt.ParseWithClaims(raw, &claims, func(t *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + if err != nil || !token.Valid { + t.Fatalf("token should validate with configured secret: %v", err) + } + if claims.ExpiresAt < before-1 || claims.ExpiresAt > before+1 { + t.Fatalf("unexpected expiration: %v", claims.ExpiresAt) + } + if _, err := GetAccFromToken(raw, "wrong-secret"); err == nil { + t.Fatal("token validated with wrong secret") + } +} func TestMatchPath(t *testing.T) { cases := []struct { diff --git a/internal/config/config.go b/internal/config/config.go index 85bce64..d082d89 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ type Config struct { ImagePreview bool FolderDownload string SessionTimeout int + JWTSecret string HTMLTitle string HTMLNotice string UserFile string @@ -122,6 +123,8 @@ func Load(path string) (Config, error) { if n, e := strconv.Atoi(val); e == nil && n > 0 { c.SessionTimeout = n } + case "jwt.secret": + c.JWTSecret = val case "html.title": c.HTMLTitle = val case "html.notice":