diff --git a/config/config.go b/config/config.go index ac238d2b..877b151f 100644 --- a/config/config.go +++ b/config/config.go @@ -248,6 +248,7 @@ func Init() { CertificateKeyFile: "", CACertificates: nil, CARevocationLists: nil, + SigningPassphrase: "", }, HTTPConfig: httpclient.Config{ Timeout: 20, @@ -391,6 +392,7 @@ func getRedactedGlobalConf() globalConfig { conf.Common.StartupHook = utils.GetRedactedURL(conf.Common.StartupHook) conf.Common.PostConnectHook = utils.GetRedactedURL(conf.Common.PostConnectHook) conf.SFTPD.KeyboardInteractiveHook = utils.GetRedactedURL(conf.SFTPD.KeyboardInteractiveHook) + conf.HTTPDConfig.SigningPassphrase = "[redacted]" conf.ProviderConf.Password = "[redacted]" conf.ProviderConf.Actions.Hook = utils.GetRedactedURL(conf.ProviderConf.Actions.Hook) 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.ca_certificates", globalConf.HTTPDConfig.CACertificates) 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.retry_wait_min", globalConf.HTTPConfig.RetryWaitMin) viper.SetDefault("http.retry_wait_max", globalConf.HTTPConfig.RetryWaitMax) diff --git a/docs/full-configuration.md b/docs/full-configuration.md index ddb58a9d..b1963d1d 100644 --- a/docs/full-configuration.md +++ b/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. - `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. + - `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) - `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" diff --git a/docs/rest-api.md b/docs/rest-api.md index 8182bc18..99befec4 100644 --- a/docs/rest-api.md +++ b/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. -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, 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: - add users diff --git a/httpd/auth_utils.go b/httpd/auth_utils.go index 7cf24e92..4cd67847 100644 --- a/httpd/auth_utils.go +++ b/httpd/auth_utils.go @@ -32,8 +32,11 @@ const ( ) 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 { @@ -232,7 +235,7 @@ func createCSRFToken() string { claims[jwt.JwtIDKey] = xid.New().String() claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second) - claims[jwt.ExpirationKey] = now.Add(tokenDuration) + claims[jwt.ExpirationKey] = now.Add(csrfTokenDuration) claims[jwt.AudienceKey] = tokenAudienceCSRF _, tokenString, err := csrfTokenAuth.Encode(claims) diff --git a/httpd/httpd.go b/httpd/httpd.go index 4187d88e..22c46497 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -4,6 +4,7 @@ package httpd import ( + "crypto/sha256" "fmt" "net" "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 // if a client certificate has been revoked 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 { @@ -289,9 +294,15 @@ func (c *Conf) checkRequiredDirs(staticFilesPath, templatesPath string) error { return nil } +func (c *Conf) getRedacted() Conf { + conf := *c + conf.SigningPassphrase = "[redacted]" + return conf +} + // Initialize configures and starts the HTTP server 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) staticFilesPath := getConfigPath(c.StaticFilesPath, configDir) templatesPath := getConfigPath(c.TemplatesPath, configDir) @@ -331,7 +342,7 @@ func (c *Conf) Initialize(configDir string) error { 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) @@ -344,7 +355,7 @@ func (c *Conf) Initialize(configDir string) error { } go func(b Binding) { - server := newHttpdServer(b, staticFilesPath) + server := newHttpdServer(b, staticFilesPath, c.SigningPassphrase) exitChannel <- server.listenAndServe() }(binding) @@ -473,7 +484,7 @@ func GetHTTPRouter() http.Handler { EnableWebAdmin: true, EnableWebClient: true, } - server := newHttpdServer(b, "../static") + server := newHttpdServer(b, "../static", "") server.initializeRouter() return server.router } @@ -513,3 +524,11 @@ func cleanupExpiredJWTTokens() { return true }) } + +func getSigningKey(signingPassphrase string) []byte { + if signingPassphrase != "" { + sk := sha256.Sum256([]byte(signingPassphrase)) + return sk[:] + } + return utils.GenerateRandomBytes(32) +} diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 730e9bdb..d27a109f 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -1078,7 +1078,7 @@ func TestProxyHeaders(t *testing.T) { } err = b.parseAllowedProxy() assert.NoError(t, err) - server := newHttpdServer(b, "") + server := newHttpdServer(b, "", "") server.initializeRouter() testServer := httptest.NewServer(server.router) defer testServer.Close() @@ -1164,7 +1164,7 @@ func TestRecoverer(t *testing.T) { EnableWebAdmin: true, EnableWebClient: false, } - server := newHttpdServer(b, "../static") + server := newHttpdServer(b, "../static", "") server.initializeRouter() server.router.Get(recoveryPath, func(w http.ResponseWriter, r *http.Request) { panic("panic") @@ -1276,7 +1276,7 @@ func TestWebAdminRedirect(t *testing.T) { EnableWebAdmin: true, EnableWebClient: false, } - server := newHttpdServer(b, "../static") + server := newHttpdServer(b, "../static", "") server.initializeRouter() testServer := httptest.NewServer(server.router) defer testServer.Close() @@ -1571,3 +1571,34 @@ func TestTLSReq(t *testing.T) { assert.False(t, isTLS(req.WithContext(ctx))) 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) +} diff --git a/httpd/server.go b/httpd/server.go index 12154782..43471c4a 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -31,20 +31,22 @@ var ( ) 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{ - 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() { - 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.Use(middleware.RequestID) diff --git a/sftpgo.json b/sftpgo.json index 6944f950..ede96cfa 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -200,7 +200,8 @@ "certificate_file": "", "certificate_key_file": "", "ca_certificates": [], - "ca_revocation_lists": [] + "ca_revocation_lists": [], + "signing_passphrase": "" }, "telemetry": { "bind_port": 10000,