Browse Source

allow to use a persistent signing key for JWT and CSRF tokens

Fixes #466
Nicola Murino 4 years ago
parent
commit
ff19879ffd
8 changed files with 86 additions and 24 deletions
  1. 3 0
      config/config.go
  2. 1 0
      docs/full-configuration.md
  3. 3 1
      docs/rest-api.md
  4. 6 3
      httpd/auth_utils.go
  5. 23 4
      httpd/httpd.go
  6. 34 3
      httpd/internal_test.go
  7. 14 12
      httpd/server.go
  8. 2 1
      sftpgo.json

+ 3 - 0
config/config.go

@@ -248,6 +248,7 @@ func Init() {
 			CertificateKeyFile: "",
 			CertificateKeyFile: "",
 			CACertificates:     nil,
 			CACertificates:     nil,
 			CARevocationLists:  nil,
 			CARevocationLists:  nil,
+			SigningPassphrase:  "",
 		},
 		},
 		HTTPConfig: httpclient.Config{
 		HTTPConfig: httpclient.Config{
 			Timeout:        20,
 			Timeout:        20,
@@ -391,6 +392,7 @@ func getRedactedGlobalConf() globalConfig {
 	conf.Common.StartupHook = utils.GetRedactedURL(conf.Common.StartupHook)
 	conf.Common.StartupHook = utils.GetRedactedURL(conf.Common.StartupHook)
 	conf.Common.PostConnectHook = utils.GetRedactedURL(conf.Common.PostConnectHook)
 	conf.Common.PostConnectHook = utils.GetRedactedURL(conf.Common.PostConnectHook)
 	conf.SFTPD.KeyboardInteractiveHook = utils.GetRedactedURL(conf.SFTPD.KeyboardInteractiveHook)
 	conf.SFTPD.KeyboardInteractiveHook = utils.GetRedactedURL(conf.SFTPD.KeyboardInteractiveHook)
+	conf.HTTPDConfig.SigningPassphrase = "[redacted]"
 	conf.ProviderConf.Password = "[redacted]"
 	conf.ProviderConf.Password = "[redacted]"
 	conf.ProviderConf.Actions.Hook = utils.GetRedactedURL(conf.ProviderConf.Actions.Hook)
 	conf.ProviderConf.Actions.Hook = utils.GetRedactedURL(conf.ProviderConf.Actions.Hook)
 	conf.ProviderConf.ExternalAuthHook = utils.GetRedactedURL(conf.ProviderConf.ExternalAuthHook)
 	conf.ProviderConf.ExternalAuthHook = utils.GetRedactedURL(conf.ProviderConf.ExternalAuthHook)
@@ -939,6 +941,7 @@ func setViperDefaults() {
 	viper.SetDefault("httpd.certificate_key_file", globalConf.HTTPDConfig.CertificateKeyFile)
 	viper.SetDefault("httpd.certificate_key_file", globalConf.HTTPDConfig.CertificateKeyFile)
 	viper.SetDefault("httpd.ca_certificates", globalConf.HTTPDConfig.CACertificates)
 	viper.SetDefault("httpd.ca_certificates", globalConf.HTTPDConfig.CACertificates)
 	viper.SetDefault("httpd.ca_revocation_lists", globalConf.HTTPDConfig.CARevocationLists)
 	viper.SetDefault("httpd.ca_revocation_lists", globalConf.HTTPDConfig.CARevocationLists)
+	viper.SetDefault("httpd.signing_passphrase", globalConf.HTTPDConfig.SigningPassphrase)
 	viper.SetDefault("http.timeout", globalConf.HTTPConfig.Timeout)
 	viper.SetDefault("http.timeout", globalConf.HTTPConfig.Timeout)
 	viper.SetDefault("http.retry_wait_min", globalConf.HTTPConfig.RetryWaitMin)
 	viper.SetDefault("http.retry_wait_min", globalConf.HTTPConfig.RetryWaitMin)
 	viper.SetDefault("http.retry_wait_max", globalConf.HTTPConfig.RetryWaitMax)
 	viper.SetDefault("http.retry_wait_max", globalConf.HTTPConfig.RetryWaitMax)

+ 1 - 0
docs/full-configuration.md

@@ -211,6 +211,7 @@ The configuration file contains the following sections:
   - `certificate_key_file`, string. Private key matching the above certificate. This can be an absolute path or a path relative to the config dir. If both the certificate and the private key are provided, the server will expect HTTPS connections. Certificate and key files can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows.
   - `certificate_key_file`, string. Private key matching the above certificate. This can be an absolute path or a path relative to the config dir. If both the certificate and the private key are provided, the server will expect HTTPS connections. Certificate and key files can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows.
   - `ca_certificates`, list of strings. Set of root certificate authorities to be used to verify client certificates.
   - `ca_certificates`, list of strings. Set of root certificate authorities to be used to verify client certificates.
   - `ca_revocation_lists`, list of strings. Set a revocation lists, one for each root CA, to be used to check if a client certificate has been revoked. The revocation lists can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows.
   - `ca_revocation_lists`, list of strings. Set a revocation lists, one for each root CA, to be used to check if a client certificate has been revoked. The revocation lists can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows.
+  - `signing_passphrase`, string. Passphrase to use to derive the signing key for JWT and CSRF tokens. If empty a random signing key will be generated each time SFTPGo starts. If you set a signing passphrase you should consider rotating it periodically for added security.
 - **"telemetry"**, the configuration for the telemetry server, more details [below](#telemetry-server)
 - **"telemetry"**, the configuration for the telemetry server, more details [below](#telemetry-server)
   - `bind_port`, integer. The port used for serving HTTP requests. Set to 0 to disable HTTP server. Default: 10000
   - `bind_port`, integer. The port used for serving HTTP requests. Set to 0 to disable HTTP server. Default: 10000
   - `bind_address`, string. Leave blank to listen on all available network interfaces. On \*NIX you can specify an absolute path to listen on a Unix-domain socket. Default: "127.0.0.1"
   - `bind_address`, string. Leave blank to listen on all available network interfaces. On \*NIX you can specify an absolute path to listen on a Unix-domain socket. Default: "127.0.0.1"

+ 3 - 1
docs/rest-api.md

@@ -19,10 +19,12 @@ You can get a JWT token using the `/api/v2/token` endpoint, you need to authenti
 
 
 once the access token has expired, you need to get a new one.
 once the access token has expired, you need to get a new one.
 
 
-JWT tokens are not stored and we use a randomly generated secret to sign them so if you restart SFTPGo all the previous tokens will be invalidated and you will get a 401 HTTP response code.
+By default, JWT tokens are not stored and we use a randomly generated secret to sign them so if you restart SFTPGo all the previous tokens will be invalidated and you will get a 401 HTTP response code.
 
 
 If you define multiple bindings, each binding will sign JWT tokens with a different secret so the token generated for a binding is not valid for the other ones.
 If you define multiple bindings, each binding will sign JWT tokens with a different secret so the token generated for a binding is not valid for the other ones.
 
 
+If, instead, you want to use a persistent signing key for JWT tokens, you can define a signing passphrase via configuration file or environment variable.
+
 You can create other administrator and assign them the following permissions:
 You can create other administrator and assign them the following permissions:
 
 
 - add users
 - add users

+ 6 - 3
httpd/auth_utils.go

@@ -32,8 +32,11 @@ const (
 )
 )
 
 
 var (
 var (
-	tokenDuration   = 15 * time.Minute
-	tokenRefreshMin = 10 * time.Minute
+	tokenDuration = 20 * time.Minute
+	// csrf token duration is greater than normal token duration to reduce issues
+	// with the login form
+	csrfTokenDuration = 6 * time.Hour
+	tokenRefreshMin   = 10 * time.Minute
 )
 )
 
 
 type jwtTokenClaims struct {
 type jwtTokenClaims struct {
@@ -232,7 +235,7 @@ func createCSRFToken() string {
 
 
 	claims[jwt.JwtIDKey] = xid.New().String()
 	claims[jwt.JwtIDKey] = xid.New().String()
 	claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
 	claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second)
-	claims[jwt.ExpirationKey] = now.Add(tokenDuration)
+	claims[jwt.ExpirationKey] = now.Add(csrfTokenDuration)
 	claims[jwt.AudienceKey] = tokenAudienceCSRF
 	claims[jwt.AudienceKey] = tokenAudienceCSRF
 
 
 	_, tokenString, err := csrfTokenAuth.Encode(claims)
 	_, tokenString, err := csrfTokenAuth.Encode(claims)

+ 23 - 4
httpd/httpd.go

@@ -4,6 +4,7 @@
 package httpd
 package httpd
 
 
 import (
 import (
+	"crypto/sha256"
 	"fmt"
 	"fmt"
 	"net"
 	"net"
 	"net/http"
 	"net/http"
@@ -245,6 +246,10 @@ type Conf struct {
 	// CARevocationLists defines a set a revocation lists, one for each root CA, to be used to check
 	// CARevocationLists defines a set a revocation lists, one for each root CA, to be used to check
 	// if a client certificate has been revoked
 	// if a client certificate has been revoked
 	CARevocationLists []string `json:"ca_revocation_lists" mapstructure:"ca_revocation_lists"`
 	CARevocationLists []string `json:"ca_revocation_lists" mapstructure:"ca_revocation_lists"`
+	// SigningPassphrase defines the passphrase to use to derive the signing key for JWT and CSRF tokens.
+	// If empty a random signing key will be generated each time SFTPGo starts. If you set a
+	// signing passphrase you should consider rotating it periodically for added security
+	SigningPassphrase string `json:"signing_passphrase" mapstructure:"signing_passphrase"`
 }
 }
 
 
 type apiResponse struct {
 type apiResponse struct {
@@ -289,9 +294,15 @@ func (c *Conf) checkRequiredDirs(staticFilesPath, templatesPath string) error {
 	return nil
 	return nil
 }
 }
 
 
+func (c *Conf) getRedacted() Conf {
+	conf := *c
+	conf.SigningPassphrase = "[redacted]"
+	return conf
+}
+
 // Initialize configures and starts the HTTP server
 // Initialize configures and starts the HTTP server
 func (c *Conf) Initialize(configDir string) error {
 func (c *Conf) Initialize(configDir string) error {
-	logger.Debug(logSender, "", "initializing HTTP server with config %+v", c)
+	logger.Debug(logSender, "", "initializing HTTP server with config %v", c.getRedacted())
 	backupsPath = getConfigPath(c.BackupsPath, configDir)
 	backupsPath = getConfigPath(c.BackupsPath, configDir)
 	staticFilesPath := getConfigPath(c.StaticFilesPath, configDir)
 	staticFilesPath := getConfigPath(c.StaticFilesPath, configDir)
 	templatesPath := getConfigPath(c.TemplatesPath, configDir)
 	templatesPath := getConfigPath(c.TemplatesPath, configDir)
@@ -331,7 +342,7 @@ func (c *Conf) Initialize(configDir string) error {
 		certMgr = mgr
 		certMgr = mgr
 	}
 	}
 
 
-	csrfTokenAuth = jwtauth.New(jwa.HS256.String(), utils.GenerateRandomBytes(32), nil)
+	csrfTokenAuth = jwtauth.New(jwa.HS256.String(), getSigningKey(c.SigningPassphrase), nil)
 
 
 	exitChannel := make(chan error, 1)
 	exitChannel := make(chan error, 1)
 
 
@@ -344,7 +355,7 @@ func (c *Conf) Initialize(configDir string) error {
 		}
 		}
 
 
 		go func(b Binding) {
 		go func(b Binding) {
-			server := newHttpdServer(b, staticFilesPath)
+			server := newHttpdServer(b, staticFilesPath, c.SigningPassphrase)
 
 
 			exitChannel <- server.listenAndServe()
 			exitChannel <- server.listenAndServe()
 		}(binding)
 		}(binding)
@@ -473,7 +484,7 @@ func GetHTTPRouter() http.Handler {
 		EnableWebAdmin:  true,
 		EnableWebAdmin:  true,
 		EnableWebClient: true,
 		EnableWebClient: true,
 	}
 	}
-	server := newHttpdServer(b, "../static")
+	server := newHttpdServer(b, "../static", "")
 	server.initializeRouter()
 	server.initializeRouter()
 	return server.router
 	return server.router
 }
 }
@@ -513,3 +524,11 @@ func cleanupExpiredJWTTokens() {
 		return true
 		return true
 	})
 	})
 }
 }
+
+func getSigningKey(signingPassphrase string) []byte {
+	if signingPassphrase != "" {
+		sk := sha256.Sum256([]byte(signingPassphrase))
+		return sk[:]
+	}
+	return utils.GenerateRandomBytes(32)
+}

+ 34 - 3
httpd/internal_test.go

@@ -1078,7 +1078,7 @@ func TestProxyHeaders(t *testing.T) {
 	}
 	}
 	err = b.parseAllowedProxy()
 	err = b.parseAllowedProxy()
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	server := newHttpdServer(b, "")
+	server := newHttpdServer(b, "", "")
 	server.initializeRouter()
 	server.initializeRouter()
 	testServer := httptest.NewServer(server.router)
 	testServer := httptest.NewServer(server.router)
 	defer testServer.Close()
 	defer testServer.Close()
@@ -1164,7 +1164,7 @@ func TestRecoverer(t *testing.T) {
 		EnableWebAdmin:  true,
 		EnableWebAdmin:  true,
 		EnableWebClient: false,
 		EnableWebClient: false,
 	}
 	}
-	server := newHttpdServer(b, "../static")
+	server := newHttpdServer(b, "../static", "")
 	server.initializeRouter()
 	server.initializeRouter()
 	server.router.Get(recoveryPath, func(w http.ResponseWriter, r *http.Request) {
 	server.router.Get(recoveryPath, func(w http.ResponseWriter, r *http.Request) {
 		panic("panic")
 		panic("panic")
@@ -1276,7 +1276,7 @@ func TestWebAdminRedirect(t *testing.T) {
 		EnableWebAdmin:  true,
 		EnableWebAdmin:  true,
 		EnableWebClient: false,
 		EnableWebClient: false,
 	}
 	}
-	server := newHttpdServer(b, "../static")
+	server := newHttpdServer(b, "../static", "")
 	server.initializeRouter()
 	server.initializeRouter()
 	testServer := httptest.NewServer(server.router)
 	testServer := httptest.NewServer(server.router)
 	defer testServer.Close()
 	defer testServer.Close()
@@ -1571,3 +1571,34 @@ func TestTLSReq(t *testing.T) {
 	assert.False(t, isTLS(req.WithContext(ctx)))
 	assert.False(t, isTLS(req.WithContext(ctx)))
 	assert.Equal(t, "context value forwarded proto", forwardedProtoKey.String())
 	assert.Equal(t, "context value forwarded proto", forwardedProtoKey.String())
 }
 }
+
+func TestSigningKey(t *testing.T) {
+	signingPassphrase := "test"
+	server1 := httpdServer{
+		signingPassphrase: signingPassphrase,
+	}
+	server1.initializeRouter()
+
+	server2 := httpdServer{
+		signingPassphrase: signingPassphrase,
+	}
+	server2.initializeRouter()
+
+	user := dataprovider.User{
+		Username: "",
+		Password: "pwd",
+	}
+	c := jwtTokenClaims{
+		Username:    user.Username,
+		Permissions: nil,
+		Signature:   user.GetSignature(),
+	}
+	token, err := c.createTokenResponse(server1.tokenAuth, tokenAudienceWebClient)
+	assert.NoError(t, err)
+	accessToken := token["access_token"].(string)
+	assert.NotEmpty(t, accessToken)
+	_, err = server1.tokenAuth.Decode(accessToken)
+	assert.NoError(t, err)
+	_, err = server2.tokenAuth.Decode(accessToken)
+	assert.NoError(t, err)
+}

+ 14 - 12
httpd/server.go

@@ -31,20 +31,22 @@ var (
 )
 )
 
 
 type httpdServer struct {
 type httpdServer struct {
-	binding         Binding
-	staticFilesPath string
-	enableWebAdmin  bool
-	enableWebClient bool
-	router          *chi.Mux
-	tokenAuth       *jwtauth.JWTAuth
+	binding           Binding
+	staticFilesPath   string
+	enableWebAdmin    bool
+	enableWebClient   bool
+	router            *chi.Mux
+	tokenAuth         *jwtauth.JWTAuth
+	signingPassphrase string
 }
 }
 
 
-func newHttpdServer(b Binding, staticFilesPath string) *httpdServer {
+func newHttpdServer(b Binding, staticFilesPath, signingPassphrase string) *httpdServer {
 	return &httpdServer{
 	return &httpdServer{
-		binding:         b,
-		staticFilesPath: staticFilesPath,
-		enableWebAdmin:  b.EnableWebAdmin,
-		enableWebClient: b.EnableWebClient,
+		binding:           b,
+		staticFilesPath:   staticFilesPath,
+		enableWebAdmin:    b.EnableWebAdmin,
+		enableWebClient:   b.EnableWebClient,
+		signingPassphrase: signingPassphrase,
 	}
 	}
 }
 }
 
 
@@ -526,7 +528,7 @@ func (s *httpdServer) redirectToWebPath(w http.ResponseWriter, r *http.Request,
 }
 }
 
 
 func (s *httpdServer) initializeRouter() {
 func (s *httpdServer) initializeRouter() {
-	s.tokenAuth = jwtauth.New(jwa.HS256.String(), utils.GenerateRandomBytes(32), nil)
+	s.tokenAuth = jwtauth.New(jwa.HS256.String(), getSigningKey(s.signingPassphrase), nil)
 	s.router = chi.NewRouter()
 	s.router = chi.NewRouter()
 
 
 	s.router.Use(middleware.RequestID)
 	s.router.Use(middleware.RequestID)

+ 2 - 1
sftpgo.json

@@ -200,7 +200,8 @@
     "certificate_file": "",
     "certificate_file": "",
     "certificate_key_file": "",
     "certificate_key_file": "",
     "ca_certificates": [],
     "ca_certificates": [],
-    "ca_revocation_lists": []
+    "ca_revocation_lists": [],
+    "signing_passphrase": ""
   },
   },
   "telemetry": {
   "telemetry": {
     "bind_port": 10000,
     "bind_port": 10000,