fix: harden login session lifetime

This commit is contained in:
OpenClaw Agent
2026-09-22 04:39:10 +08:00
parent 2c8531d9b9
commit c37161db31
6 changed files with 73 additions and 28 deletions
+11 -1
View File
@@ -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 != "" {
+2 -1
View File
@@ -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
+13 -17
View File
@@ -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)
}
+16 -8
View File
@@ -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 {
+28 -1
View File
@@ -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 {
+3
View File
@@ -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":