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

Fixes #466
This commit is contained in:
Nicola Murino
2021-07-01 20:17:40 +02:00
parent 04001f7ad3
commit ff19879ffd
8 changed files with 86 additions and 24 deletions

View File

@@ -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)

View File

@@ -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"

View File

@@ -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

View File

@@ -32,7 +32,10 @@ const (
) )
var ( var (
tokenDuration = 15 * 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 tokenRefreshMin = 10 * time.Minute
) )
@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -37,14 +37,16 @@ type httpdServer struct {
enableWebClient bool enableWebClient bool
router *chi.Mux router *chi.Mux
tokenAuth *jwtauth.JWTAuth 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, binding: b,
staticFilesPath: staticFilesPath, staticFilesPath: staticFilesPath,
enableWebAdmin: b.EnableWebAdmin, enableWebAdmin: b.EnableWebAdmin,
enableWebClient: b.EnableWebClient, 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)

View File

@@ -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,