allow to use a persistent signing key for JWT and CSRF tokens
Fixes #466
This commit is contained in:
parent
04001f7ad3
commit
ff19879ffd
8 changed files with 86 additions and 24 deletions
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -200,7 +200,8 @@
|
|||
"certificate_file": "",
|
||||
"certificate_key_file": "",
|
||||
"ca_certificates": [],
|
||||
"ca_revocation_lists": []
|
||||
"ca_revocation_lists": [],
|
||||
"signing_passphrase": ""
|
||||
},
|
||||
"telemetry": {
|
||||
"bind_port": 10000,
|
||||
|
|
Loading…
Reference in a new issue