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
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
8 changed files with 86 additions and 24 deletions

View file

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

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.
- `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"

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

View file

@ -32,7 +32,10 @@ const (
)
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
)
@ -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)

View file

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

View file

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

View file

@ -37,14 +37,16 @@ type httpdServer struct {
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,
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)

View file

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