Web UIs: add OpenID Connect support

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2022-02-13 14:30:20 +01:00
parent fa0ca8fe89
commit 66945c0a02
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
30 changed files with 2307 additions and 236 deletions

View file

@ -16,7 +16,7 @@ jobs:
upload-coverage: [true]
include:
- go: 1.17
os: windows-latest
os: windows-2019
upload-coverage: false
steps:

34
DCO Normal file
View file

@ -0,0 +1,34 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.

View file

@ -28,6 +28,7 @@ Several storage backends are supported: local filesystem, encrypted local filesy
- Per user authentication methods.
- [Two-factor authentication](./docs/howto/two-factor-authentication.md) based on time-based one time passwords (RFC 6238) which works with Authy, Google Authenticator and other compatible apps.
- Custom authentication via external programs/HTTP API.
- Web Client and Web Admin user interfaces support [OpenID Connect](https://openid.net/connect/) authentication and so they can be integrated with identity providers such as [Keycloak](https://www.keycloak.org/). You can find more details [here](./docs/oidc.md).
- [Data At Rest Encryption](./docs/dare.md).
- Dynamic user modification before login via external programs/HTTP API.
- Quota support: accounts can have individual quota expressed as max total size and/or max number of files.
@ -63,8 +64,8 @@ SFTPGo is developed and tested on Linux. After each commit, the code is automati
## Requirements
- Go as build only dependency. We support the Go version(s) used in [continuous integration workflows](./tree/main/.github/workflows).
- A suitable SQL server to use as data provider: PostgreSQL 9.4+ or MySQL 5.6+ or SQLite 3.x or CockroachDB stable.
- Go as build only dependency. We support the Go version(s) used in [continuous integration workflows](./.github/workflows).
- A suitable SQL server to use as data provider: PostgreSQL 9.4+, MySQL 5.6+, SQLite 3.x, CockroachDB stable.
- The SQL server is optional: you can choose to use an embedded bolt database as key/value store or an in memory data provider.
## Installation

View file

@ -70,7 +70,7 @@ var (
ProxyAllowed: nil,
}
defaultHTTPDBinding = httpd.Binding{
Address: "127.0.0.1",
Address: "",
Port: 8080,
EnableWebAdmin: true,
EnableWebClient: true,
@ -81,6 +81,14 @@ var (
HideLoginURL: 0,
RenderOpenAPI: true,
WebClientIntegrations: nil,
OIDC: httpd.OIDC{
ClientID: "",
ClientSecret: "",
ConfigURL: "",
RedirectBaseURL: "",
UsernameField: "",
RoleField: "",
},
}
defaultRateLimiter = common.RateLimiterConfig{
Average: 0,
@ -490,6 +498,16 @@ func getRedactedGlobalConf() globalConfig {
conf.ProviderConf.PostLoginHook = util.GetRedactedURL(conf.ProviderConf.PostLoginHook)
conf.ProviderConf.CheckPasswordHook = util.GetRedactedURL(conf.ProviderConf.CheckPasswordHook)
conf.SMTPConfig.Password = getRedactedPassword()
conf.HTTPDConfig.Bindings = nil
for _, binding := range globalConf.HTTPDConfig.Bindings {
if binding.OIDC.ClientID != "" {
binding.OIDC.ClientID = getRedactedPassword()
}
if binding.OIDC.ClientSecret != "" {
binding.OIDC.ClientSecret = getRedactedPassword()
}
conf.HTTPDConfig.Bindings = append(conf.HTTPDConfig.Bindings, binding)
}
return conf
}
@ -1042,6 +1060,49 @@ func getWebDAVDBindingFromEnv(idx int) {
}
}
func getHTTPDOIDCFromEnv(idx int) (httpd.OIDC, bool) {
var result httpd.OIDC
isSet := false
clientID, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__OIDC__CLIENT_ID", idx))
if ok {
result.ClientID = clientID
isSet = true
}
clientSecret, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__OIDC__CLIENT_SECRET", idx))
if ok {
result.ClientSecret = clientSecret
isSet = true
}
configURL, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__OIDC__CONFIG_URL", idx))
if ok {
result.ConfigURL = configURL
isSet = true
}
redirectBaseURL, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__OIDC__REDIRECT_BASE_URL", idx))
if ok {
result.RedirectBaseURL = redirectBaseURL
isSet = true
}
usernameField, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__OIDC__USERNAME_FIELD", idx))
if ok {
result.UsernameField = usernameField
isSet = true
}
roleField, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__OIDC__ROLE_FIELD", idx))
if ok {
result.RoleField = roleField
isSet = true
}
return result, isSet
}
func getHTTPDWebClientIntegrationsFromEnv(idx int) []httpd.WebClientIntegration {
var integrations []httpd.WebClientIntegration
@ -1067,7 +1128,7 @@ func getHTTPDWebClientIntegrationsFromEnv(idx int) []httpd.WebClientIntegration
return integrations
}
func getHTTPDBindingFromEnv(idx int) {
func getDefaultHTTPBinding(idx int) httpd.Binding {
binding := httpd.Binding{
EnableWebAdmin: true,
EnableWebClient: true,
@ -1076,6 +1137,11 @@ func getHTTPDBindingFromEnv(idx int) {
if len(globalConf.HTTPDConfig.Bindings) > idx {
binding = globalConf.HTTPDConfig.Bindings[idx]
}
return binding
}
func getHTTPDBindingFromEnv(idx int) {
binding := getDefaultHTTPBinding(idx)
isSet := false
@ -1145,6 +1211,12 @@ func getHTTPDBindingFromEnv(idx int) {
isSet = true
}
oidc, ok := getHTTPDOIDCFromEnv(idx)
if ok {
binding.OIDC = oidc
isSet = true
}
if isSet {
if len(globalConf.HTTPDConfig.Bindings) > idx {
globalConf.HTTPDConfig.Bindings[idx] = binding

View file

@ -7,7 +7,7 @@ import (
"strings"
"testing"
sdkkms "github.com/sftpgo/sdk/kms"
"github.com/sftpgo/sdk/kms"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -467,8 +467,8 @@ func TestPluginsFromEnv(t *testing.T) {
os.Setenv("SFTPGO_PLUGINS__0__ARGS", "arg1,arg2")
os.Setenv("SFTPGO_PLUGINS__0__SHA256SUM", "0a71ded61fccd59c4f3695b51c1b3d180da8d2d77ea09ccee20dac242675c193")
os.Setenv("SFTPGO_PLUGINS__0__AUTO_MTLS", "1")
os.Setenv("SFTPGO_PLUGINS__0__KMS_OPTIONS__SCHEME", sdkkms.SchemeAWS)
os.Setenv("SFTPGO_PLUGINS__0__KMS_OPTIONS__ENCRYPTED_STATUS", sdkkms.SecretStatusAWS)
os.Setenv("SFTPGO_PLUGINS__0__KMS_OPTIONS__SCHEME", kms.SchemeAWS)
os.Setenv("SFTPGO_PLUGINS__0__KMS_OPTIONS__ENCRYPTED_STATUS", kms.SecretStatusAWS)
os.Setenv("SFTPGO_PLUGINS__0__AUTH_OPTIONS__SCOPE", "14")
t.Cleanup(func() {
os.Unsetenv("SFTPGO_PLUGINS__0__TYPE")
@ -510,8 +510,8 @@ func TestPluginsFromEnv(t *testing.T) {
require.Equal(t, "arg2", pluginConf.Args[1])
require.Equal(t, "0a71ded61fccd59c4f3695b51c1b3d180da8d2d77ea09ccee20dac242675c193", pluginConf.SHA256Sum)
require.True(t, pluginConf.AutoMTLS)
require.Equal(t, sdkkms.SchemeAWS, pluginConf.KMSOptions.Scheme)
require.Equal(t, sdkkms.SecretStatusAWS, pluginConf.KMSOptions.EncryptedStatus)
require.Equal(t, kms.SchemeAWS, pluginConf.KMSOptions.Scheme)
require.Equal(t, kms.SecretStatusAWS, pluginConf.KMSOptions.EncryptedStatus)
require.Equal(t, 14, pluginConf.AuthOptions.Scope)
configAsJSON, err := json.Marshal(pluginsConf)
@ -524,8 +524,8 @@ func TestPluginsFromEnv(t *testing.T) {
os.Setenv("SFTPGO_PLUGINS__0__CMD", "plugin_start_cmd1")
os.Setenv("SFTPGO_PLUGINS__0__ARGS", "")
os.Setenv("SFTPGO_PLUGINS__0__AUTO_MTLS", "0")
os.Setenv("SFTPGO_PLUGINS__0__KMS_OPTIONS__SCHEME", sdkkms.SchemeVaultTransit)
os.Setenv("SFTPGO_PLUGINS__0__KMS_OPTIONS__ENCRYPTED_STATUS", sdkkms.SecretStatusVaultTransit)
os.Setenv("SFTPGO_PLUGINS__0__KMS_OPTIONS__SCHEME", kms.SchemeVaultTransit)
os.Setenv("SFTPGO_PLUGINS__0__KMS_OPTIONS__ENCRYPTED_STATUS", kms.SecretStatusVaultTransit)
err = config.LoadConfig(configDir, confName)
assert.NoError(t, err)
pluginsConf = config.GetPluginsConfig()
@ -547,8 +547,8 @@ func TestPluginsFromEnv(t *testing.T) {
require.Len(t, pluginConf.Args, 0)
require.Equal(t, "0a71ded61fccd59c4f3695b51c1b3d180da8d2d77ea09ccee20dac242675c193", pluginConf.SHA256Sum)
require.False(t, pluginConf.AutoMTLS)
require.Equal(t, sdkkms.SchemeVaultTransit, pluginConf.KMSOptions.Scheme)
require.Equal(t, sdkkms.SecretStatusVaultTransit, pluginConf.KMSOptions.EncryptedStatus)
require.Equal(t, kms.SchemeVaultTransit, pluginConf.KMSOptions.Scheme)
require.Equal(t, kms.SecretStatusVaultTransit, pluginConf.KMSOptions.EncryptedStatus)
require.Equal(t, 14, pluginConf.AuthOptions.Scope)
err = os.Remove(configFilePath)
@ -803,6 +803,12 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__1__FILE_EXTENSIONS", ".pdf, .txt")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__2__URL", "http://127.0.1.1/")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__3__FILE_EXTENSIONS", ".jpg, .txt")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__CLIENT_ID", "client id")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__CLIENT_SECRET", "client secret")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__CONFIG_URL", "config url")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__REDIRECT_BASE_URL", "redirect base url")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__USERNAME_FIELD", "preferred_username")
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__ROLE_FIELD", "sftpgo_role")
t.Cleanup(func() {
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__0__ADDRESS")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__0__PORT")
@ -825,6 +831,12 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__1__FILE_EXTENSIONS")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__2__URL")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__WEB_CLIENT_INTEGRATIONS__3__FILE_EXTENSIONS")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__CLIENT_ID")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__CLIENT_SECRET")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__CONFIG_URL")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__REDIRECT_BASE_URL")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__USERNAME_FIELD")
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__ROLE_FIELD")
})
configDir := ".."
@ -839,6 +851,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
require.True(t, bindings[0].EnableWebClient)
require.True(t, bindings[0].RenderOpenAPI)
require.Len(t, bindings[0].TLSCipherSuites, 1)
require.Empty(t, bindings[0].OIDC.ConfigURL)
require.Equal(t, "TLS_AES_128_GCM_SHA256", bindings[0].TLSCipherSuites[0])
require.Equal(t, 0, bindings[0].HideLoginURL)
require.Equal(t, 8000, bindings[1].Port)
@ -849,7 +862,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
require.True(t, bindings[1].RenderOpenAPI)
require.Nil(t, bindings[1].TLSCipherSuites)
require.Equal(t, 1, bindings[1].HideLoginURL)
require.Empty(t, bindings[1].OIDC.ClientID)
require.Equal(t, 9000, bindings[2].Port)
require.Equal(t, "127.0.1.1", bindings[2].Address)
require.True(t, bindings[2].EnableHTTPS)
@ -867,6 +880,12 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
require.Len(t, bindings[2].WebClientIntegrations, 1)
require.Equal(t, "http://127.0.0.1/", bindings[2].WebClientIntegrations[0].URL)
require.Equal(t, []string{".pdf", ".txt"}, bindings[2].WebClientIntegrations[0].FileExtensions)
require.Equal(t, "client id", bindings[2].OIDC.ClientID)
require.Equal(t, "client secret", bindings[2].OIDC.ClientSecret)
require.Equal(t, "config url", bindings[2].OIDC.ConfigURL)
require.Equal(t, "redirect base url", bindings[2].OIDC.RedirectBaseURL)
require.Equal(t, "preferred_username", bindings[2].OIDC.UsernameField)
require.Equal(t, "sftpgo_role", bindings[2].OIDC.RoleField)
}
func TestHTTPClientCertificatesFromEnv(t *testing.T) {

View file

@ -114,7 +114,7 @@ The configuration file contains the following sections:
- `keyboard_interactive_authentication`, boolean. This setting specifies whether keyboard interactive authentication is allowed. If no keyboard interactive hook or auth plugin is defined the default is to prompt for the user password and then the one time authentication code, if defined. Default: `false`.
- `keyboard_interactive_auth_hook`, string. Absolute path to an external program or an HTTP URL to invoke for keyboard interactive authentication. See [Keyboard Interactive Authentication](./keyboard-interactive.md) for more details.
- `password_authentication`, boolean. Set to false to disable password authentication. This setting will disable multi-step authentication method using public key + password too. It is useful for public key only configurations if you need to manage old clients that will not attempt to authenticate with public keys if the password login method is advertised. Default: `true`.
- `folder_prefix`, string. Virtual root folder prefix to include in all file operations (ex: `/files`). The virtual paths used for per-directory permissions, file patterns etc. must not include the folder prefix. The prefix is only applied to SFTP requests (in SFTP server mode), SCP and other SSH commands will be automatically disabled if you configure a prefix. The prefix is ignored while running as OpenSSH's SFTP subsystem. This setting can help some specific migrations from SFTP servers based on OpenSSH and it is not recommended for general usage. Default: empty.
- `folder_prefix`, string. Virtual root folder prefix to include in all file operations (ex: `/files`). The virtual paths used for per-directory permissions, file patterns etc. must not include the folder prefix. The prefix is only applied to SFTP requests (in SFTP server mode), SCP and other SSH commands will be automatically disabled if you configure a prefix. The prefix is ignored while running as OpenSSH's SFTP subsystem. This setting can help some specific migrations from SFTP servers based on OpenSSH and it is not recommended for general usage. Default: blank.
- **"ftpd"**, the configuration for the FTP server
- `bindings`, list of structs. Each struct has the following fields:
- `port`, integer. The port used for serving FTP requests. 0 means disabled. Default: 0.
@ -221,18 +221,25 @@ The configuration file contains the following sections:
- **"httpd"**, the configuration for the HTTP server used to serve REST API and to expose the built-in web interface
- `bindings`, list of structs. Each struct has the following fields:
- `port`, integer. The port used for serving HTTP requests. Default: 8080.
- `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".
- `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: blank.
- `enable_web_admin`, boolean. Set to `false` to disable the built-in web admin for this binding. You also need to define `templates_path` and `static_files_path` to use the built-in web admin interface. Default `true`.
- `enable_web_client`, boolean. Set to `false` to disable the built-in web client for this binding. You also need to define `templates_path` and `static_files_path` to use the built-in web client interface. Default `true`.
- `enable_https`, boolean. Set to `true` and provide both a certificate and a key file to enable HTTPS connection for this binding. Default `false`.
- `client_auth_type`, integer. Set to `1` to require client certificate authentication in addition to JWT/Web authentication. You need to define at least a certificate authority for this to work. Default: 0.
- `tls_cipher_suites`, list of strings. List of supported cipher suites for TLS version 1.2. If empty, a default list of secure cipher suites is used, with a preference order based on hardware performance. Note that TLS 1.3 ciphersuites are not configurable. The supported ciphersuites names are defined [here](https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L52). Any invalid name will be silently ignored. The order matters, the ciphers listed first will be the preferred ones. Default: empty.
- `proxy_allowed`, list of IP addresses and IP ranges allowed to set `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto`, `CF-Connecting-IP`, `True-Client-IP` headers. Any of the indicated headers, if set on requests from a connection address not in this list, will be silently ignored. Default: empty.
- `tls_cipher_suites`, list of strings. List of supported cipher suites for TLS version 1.2. If empty, a default list of secure cipher suites is used, with a preference order based on hardware performance. Note that TLS 1.3 ciphersuites are not configurable. The supported ciphersuites names are defined [here](https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L52). Any invalid name will be silently ignored. The order matters, the ciphers listed first will be the preferred ones. Default: blank.
- `proxy_allowed`, list of IP addresses and IP ranges allowed to set `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto`, `CF-Connecting-IP`, `True-Client-IP` headers. Any of the indicated headers, if set on requests from a connection address not in this list, will be silently ignored. Default: blank.
- `hide_login_url`, integer. If both web admin and web client are enabled each login page will show a link to the other one. This setting allows to hide this link. 0 means that the login links are displayed on both admin and client login page. This is the default. 1 means that the login link to the web client login page is hidden on admin login page. 2 means that the login link to the web admin login page is hidden on client login page. The flags can be combined, for example 3 will disable both login links.
- `render_openapi`, boolean. Set to `false` to disable serving of the OpenAPI schema and renderer. Default `true`.
- `web_client_integrations`, list of struct. The SFTPGo web client allows to send the files with the specified extensions to the configured URL using the [postMessage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). This way you can integrate your own file viewer or editor. Take a look at the commentented example [here](../examples/webclient-integrations/test.html) to understand how to use this feature. Each struct has the following fields:
- `file_extensions`, list of strings. File extensions must be specified with the leading dot, for example `.pdf`.
- `url`, string. URL to open for the configured file extensions. The url will open in a new tab.
- `oidc`, struct. Defines the OpenID connect configuration. OpenID integration allows you to map your identity provider users to SFTPGo users and so you can login to SFTPGo Web Client and Web Admin user interfaces using your identity provider. The following fields are supported:
- `config_url`, string. Identifier for the service. If defined, SFTPGo will try to retrieve the provider configuration on startup and then will refuse to start if it fails to connect to the specified URL. Default: blank.
- `client_id`, string. Defines the application's ID. Default: blank.
- `client_secret`, string. Defines the application's secret. Default: blank.
- `redirect_base_url`, string. Defines the base URL to redirect to after OpenID authentication. The suffix `/web/oidc/redirect` will be added to this base URL, adding also the `web_root` if configured. Default: blank.
- `username_field`, string. Defines the ID token claims field to map to the SFTPGo username. Default: blank.
- `role_field`, string. Defines the optional ID token claims field to map to a SFTPGo role. If the defined ID token claims field is set to `admin` the authenticated user is mapped to an SFTPGo admin. You don't need to specify this field if you want to use OpenID only for the Web Client UI. Default: blank.
- `templates_path`, string. Path to the HTML web templates. This can be an absolute path or a path relative to the config dir
- `static_files_path`, string. Path to the static files for the web interface. This can be an absolute path or a path relative to the config dir. If both `templates_path` and `static_files_path` are empty the built-in web interface will be disabled
- `backups_path`, string. Path to the backup directory. This can be an absolute path or a path relative to the config dir. We don't allow backups in arbitrary paths for security reasons
@ -254,7 +261,7 @@ The configuration file contains the following sections:
- `max_age`, integer.
- **"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: 0
- `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`
- `enable_profiler`, boolean. Enable the built-in profiler. Default `false`
- `auth_user_file`, string. Path to a file used to store usernames and passwords for basic authentication. This can be an absolute path or a path relative to the config dir. We support HTTP basic authentication, and the file format must conform to the one generated using the Apache `htpasswd` tool. The supported password formats are bcrypt (`$2y$` prefix) and md5 crypt (`$apr1$` prefix). If empty, HTTP authentication is disabled. Authentication will be always disabled for the `/healthz` endpoint.
- `certificate_file`, string. Certificate for HTTPS. This can be an absolute path or a path relative to the config dir.
@ -276,23 +283,23 @@ The configuration file contains the following sections:
- `url`, string, optional. If not empty, the header will be added only if the request URL starts with the one specified here
- **kms**, configuration for the Key Management Service, more details can be found [here](./kms.md)
- `secrets`
- `url`, string. Defines the URI to the KMS service. Default: empty.
- `master_key`, string. Defines the master encryption key as string. If not empty, it takes precedence over `master_key_path`. Default: empty.
- `master_key_path, string. Defines the absolute path to a file containing the master encryption key. Default: empty.
- `url`, string. Defines the URI to the KMS service. Default: blank.
- `master_key`, string. Defines the master encryption key as string. If not empty, it takes precedence over `master_key_path`. Default: blank.
- `master_key_path`, string. Defines the absolute path to a file containing the master encryption key. Default: blank.
- **mfa**, multi-factor authentication settings
- `totp`, list of struct that define settings for time-based one time passwords (RFC 6238). Each struct has the following fields:
- `name`, string. Unique configuration name. This name should not be changed if there are users or admins using the configuration. The name is not exposed to the authentication apps. Default: `Default`.
- `issuer`, string. Name of the issuing Organization/Company. Default: `SFTPGo`.
- `algo`, string. Algorithm to use for HMAC. The supported algorithms are: `sha1`, `sha256`, `sha512`. Currently Google Authenticator app on iPhone seems to only support `sha1`, please check the compatibility with your target apps/device before setting a different algorithm. You can also define multiple configurations, for example one that uses `sha256` or `sha512` and another one that uses `sha1` and instruct your users to use the appropriate configuration for their devices/apps. The algorithm should not be changed if there are users or admins using the configuration. Default: `sha1`.
- **smtp**, SMTP configuration enables SFTPGo email sending capabilities
- `host`, string. Location of SMTP email server. Leavy empty to disable email sending capabilities. Default: empty.
- `host`, string. Location of SMTP email server. Leavy empty to disable email sending capabilities. Default: blank.
- `port`, integer. Port of SMTP email server.
- `from`, string. From address, for example `SFTPGo <sftpgo@example.com>`. Many SMTP servers reject emails without a `From` header so, if not set, SFTPGo will try to use the username as fallback, this may or may not be appropriate. Default: empty
- `user`, string. SMTP username. Default: empty
- `password`, string. SMTP password. Leaving both username and password empty the SMTP authentication will be disabled. Default: empty
- `from`, string. From address, for example `SFTPGo <sftpgo@example.com>`. Many SMTP servers reject emails without a `From` header so, if not set, SFTPGo will try to use the username as fallback, this may or may not be appropriate. Default: blank
- `user`, string. SMTP username. Default: blank
- `password`, string. SMTP password. Leaving both username and password empty the SMTP authentication will be disabled. Default: blank
- `auth_type`, integer. 0 means `Plain`, 1 means `Login`, 2 means `CRAM-MD5`. Default: `0`.
- `encryption`, integer. 0 means no encryption, 1 means `TLS`, 2 means `STARTTLS`. Default: `0`.
- `domain`, string. Domain to use for `HELO` command, if empty `localhost` will be used. Default: empty.
- `domain`, string. Domain to use for `HELO` command, if empty `localhost` will be used. Default: blank.
- `templates_path`, string. Path to the email templates. This can be an absolute path or a path relative to the config dir. Templates are searched within a subdirectory named "email" in the specified path. You can customize the email templates by simply specifying an alternate path and putting your custom templates there.
- **plugins**, list of external plugins. Each plugin is configured using a struct with the following fields:
- `type`, string. Defines the plugin type. Supported types: `notifier`, `kms`, `auth`, `metadata`.

99
docs/oidc.md Normal file
View file

@ -0,0 +1,99 @@
# OpenID Connect
OpenID Connect integration allows you to map your identity provider users to SFTPGo admins/users and so you can login to SFTPGo Web Client and Web Admin user interfaces using your identity provider.
SFTPGo allows to configure per-binding OpenID Connect configurations. The supported configuration parameters are documented within the `oidc` section [here](./full-configuration.md).
Let's see a basic integration with the [Keycloak](https://www.keycloak.org/) identify provider. Other OpenID connect compatible providers should work by configuring them in a similar way.
We'll not go through the complete process of creating a realm/clients/users in Keycloak. You can look this up [here](https://www.keycloak.org/docs/latest/server_admin/index.html#admin-console).
Here is just an outline:
- create a realm named `sftpgo`
- in "Realm Settings" -> "Login" adjust the "Require SSL" setting as per your requirements
- create a client named `sftpgo-client`
- for the `sftpgo-client` set the `Access Type` to `confidential` and a valid redirect URI, for example if your SFTPGo instance is running on `http://192.168.1.50:8080` a valid redirect URI is `http://192.168.1.50:8080/*`
- for the `sftpgo-client`, in the `Mappers` settings, make sure that the username and the sftpgo role are added to the ID token. For example you can add the user attribute `sftpgo_role` as JSON string to the ID token and the `username` as `preferred_username` JSON string to the ID token
- for your users who need to be mapped as SFTPGo administrators add a custom attribute specifying `sftpgo_role` as key and `admin` as value
The resulting JSON configuration for the `sftpgo-client` that you can obtain from the "Installation" tab is something like this:
```json
{
"realm": "sftpgo",
"auth-server-url": "http://192.168.1.12:8086/auth/",
"ssl-required": "none",
"resource": "sftpgo-client",
"credentials": {
"secret": "jRsmE0SWnuZjP7djBqNq0mrf8QN77j2c"
},
"confidential-port": 0
}
```
Add the following configuration parameters to the SFTPGo configuration file (or use env vars to set them):
```json
...
"oidc": {
"client_id": "sftpgo-client",
"client_secret": "jRsmE0SWnuZjP7djBqNq0mrf8QN77j2c",
"config_url": "http://192.168.1.12:8086/auth/realms/sftpgo",
"redirect_base_url": "http://192.168.1.50:8080",
"username_field": "preferred_username",
"role_field": "sftpgo_role"
}
...
```
From SFTPGo login page click `Login with OpenID` button, you will be redirected to the Keycloak login page, after a successful authentication Keyclock will redirect back to SFTPGo Web Admin or SFTPGo Web Client.
Please note that the ID token returned from Keycloak must contain the `username_field` specified in the SFTPGo configuration and optionally the `role_field`. The mapped usernames must exist in SFTPGo.
Here is an example ID token which allows the SFTPGo admin `root` to access to the Web Admin UI.
```json
{
"exp": 1644758026,
"iat": 1644757726,
"auth_time": 1644757647,
"jti": "c6cf172d-08d6-41cf-8e5d-20b7ac0b8011",
"iss": "http://192.168.1.12:8086/auth/realms/sftpgo",
"aud": "sftpgo-client",
"sub": "48b0de4b-3090-4315-bbcb-be63c48be1d2",
"typ": "ID",
"azp": "sftpgo-client",
"nonce": "XLxfYDhMmWwiYctgLTCZjC",
"session_state": "e20ab97c-d3a9-4e53-872d-09d104cbd286",
"at_hash": "UwubF1W8H0XItHU_DIpjfQ",
"acr": "0",
"sid": "e20ab97c-d3a9-4e53-872d-09d104cbd286",
"email_verified": false,
"preferred_username": "root",
"sftpgo_role": "admin"
}
```
And the following is an example ID token which allows the SFTPGo user `user1` to access to the Web Client UI.
```json
{
"exp": 1644758183,
"iat": 1644757883,
"auth_time": 1644757647,
"jti": "939de932-f941-4b04-90fc-7071b7cc6b10",
"iss": "http://192.168.1.12:8086/auth/realms/sftpgo",
"aud": "sftpgo-client",
"sub": "48b0de4b-3090-4315-bbcb-be63c48be1d2",
"typ": "ID",
"azp": "sftpgo-client",
"nonce": "wxcWPPi3H7ktembUdeToqQ",
"session_state": "e20ab97c-d3a9-4e53-872d-09d104cbd286",
"at_hash": "RSDpwzVG_6G2haaNF0jsJQ",
"acr": "0",
"sid": "e20ab97c-d3a9-4e53-872d-09d104cbd286",
"email_verified": false,
"preferred_username": "user1"
}
```

21
go.mod
View file

@ -7,10 +7,11 @@ require (
github.com/Azure/azure-storage-blob-go v0.14.0
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
github.com/aws/aws-sdk-go v1.42.48
github.com/cockroachdb/cockroach-go/v2 v2.2.6
github.com/aws/aws-sdk-go v1.42.52
github.com/cockroachdb/cockroach-go/v2 v2.2.8
github.com/coreos/go-oidc/v3 v3.1.0
github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
github.com/fclairamb/ftpserverlib v0.17.0
github.com/fclairamb/ftpserverlib v0.17.1-0.20220212161409-5157f18d716f
github.com/fclairamb/go-log v0.2.0
github.com/go-chi/chi/v5 v5.0.8-0.20220103230436-7dbe9a0bd10f
github.com/go-chi/jwtauth/v5 v5.0.2
@ -54,9 +55,10 @@ require (
gocloud.dev v0.24.0
golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd
golang.org/x/sys v0.0.0-20220207234003-57398862261d
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
google.golang.org/api v0.67.0
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
golang.org/x/sys v0.0.0-20220209214540-3681064d5158
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8
google.golang.org/api v0.68.0
gopkg.in/natefinch/lumberjack.v2 v2.0.0
)
@ -121,22 +123,21 @@ require (
github.com/yusufpapurcu/wmi v1.2.2 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/mod v0.5.1 // indirect
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/tools v0.1.9 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220207185906-7721543eae58 // indirect
google.golang.org/genproto v0.0.0-20220211171837-173942840c17 // indirect
google.golang.org/grpc v1.44.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/ini.v1 v1.66.3 // indirect
gopkg.in/ini.v1 v1.66.4 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)
replace (
github.com/eikenb/pipeat => github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639
github.com/fclairamb/ftpserverlib => github.com/drakkan/ftpserverlib v0.0.0-20220113173527-7442aa777ac0
github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20220130095207-a206cf284b7c
golang.org/x/net => github.com/drakkan/net v0.0.0-20220130095023-bd85f1236c34

44
go.sum
View file

@ -141,8 +141,8 @@ github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgI
github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go v1.42.48 h1:8ZVBAsA9X2eCpSr/8SrWDk4BOT91wRdqxpAog875+K0=
github.com/aws/aws-sdk-go v1.42.48/go.mod h1:OGr6lGMAKGlG9CVrYnWYDKIyb829c6EVBRjxqjmPepc=
github.com/aws/aws-sdk-go v1.42.52 h1:/+TZ46+0qu9Ph/UwjVrU3SG8OBi87uJLrLiYRNZKbHQ=
github.com/aws/aws-sdk-go v1.42.52/go.mod h1:OGr6lGMAKGlG9CVrYnWYDKIyb829c6EVBRjxqjmPepc=
github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY=
@ -190,8 +190,10 @@ github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cockroachdb/cockroach-go/v2 v2.2.6 h1:LTh++UIVvmDBihDo1oYbM8+OruXheusw+ILCONlAm/w=
github.com/cockroachdb/cockroach-go/v2 v2.2.6/go.mod h1:q4ZRgO6CQpwNyEvEwSxwNrOSVchsmzrBnAv3HuZ3Abc=
github.com/cockroachdb/cockroach-go/v2 v2.2.8 h1:IrQpwOXQza67nSSezygYjl4GQtQnE+rDrU2yK6MmNFA=
github.com/cockroachdb/cockroach-go/v2 v2.2.8/go.mod h1:q4ZRgO6CQpwNyEvEwSxwNrOSVchsmzrBnAv3HuZ3Abc=
github.com/coreos/go-oidc/v3 v3.1.0 h1:6avEvcdvTa1qYsOZ6I5PRkSYHzpTNWgKYmaJfaYbrRw=
github.com/coreos/go-oidc/v3 v3.1.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c=
@ -202,7 +204,6 @@ github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1
github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -219,8 +220,6 @@ github.com/drakkan/crypto v0.0.0-20220130095207-a206cf284b7c h1:IqTZK/MGRdMPRyyJ
github.com/drakkan/crypto v0.0.0-20220130095207-a206cf284b7c/go.mod h1:SiM6ypd8Xu1xldObYtbDztuUU7xUzMnUULfphXFZmro=
github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA=
github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
github.com/drakkan/ftpserverlib v0.0.0-20220113173527-7442aa777ac0 h1:8lhuOHaxuxiVuTiS8NHCXZKZ28WWxDzwwwIn673c6Jg=
github.com/drakkan/ftpserverlib v0.0.0-20220113173527-7442aa777ac0/go.mod h1:erV/bp9DEm6wvpPewC02KUJz0gdReWyz/7nHZP+4pAI=
github.com/drakkan/net v0.0.0-20220130095023-bd85f1236c34 h1:DRayAKtBRaVU3jg58b/HCbkRleByBD5q6NkN1wcJ2RU=
github.com/drakkan/net v0.0.0-20220130095023-bd85f1236c34/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639 h1:8tfGdb4kg/YCvAbIrsMazgoNtnqdOqQVDKW12uUCuuU=
@ -240,6 +239,8 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fclairamb/ftpserverlib v0.17.1-0.20220212161409-5157f18d716f h1:75ugogj/lKTVyDHTm0c5zgA16Fpfo/xiNpo8D/zn+TA=
github.com/fclairamb/ftpserverlib v0.17.1-0.20220212161409-5157f18d716f/go.mod h1:1y0ShfZWIRcgU0mVJaCjEYIu2+g37cRHgDIT8jemeO0=
github.com/fclairamb/go-log v0.2.0 h1:HzeOyomBVd0tEVLdIK0bBZr0j3xNip+zE1OqC1i5kbM=
github.com/fclairamb/go-log v0.2.0/go.mod h1:sd5oPNsxdVKRgWI8fVke99GXONszE3bsni2JxQMz8RU=
github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
@ -525,9 +526,8 @@ github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A=
github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y=
@ -706,7 +706,6 @@ github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrf
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/afero v1.8.0/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
github.com/spf13/afero v1.8.1 h1:izYHOT71f9iZ7iq37Uqjael60/vYC6vMtzedudZ0zEk=
github.com/spf13/afero v1.8.1/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
@ -949,8 +948,9 @@ golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220207234003-57398862261d h1:Bm7BNOQt2Qv7ZqysjeLjgCBanX+88Z/OtdvsrEv1Djc=
golang.org/x/sys v0.0.0-20220207234003-57398862261d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -968,8 +968,8 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M=
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@ -1082,8 +1082,8 @@ google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFd
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM=
google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M=
google.golang.org/api v0.67.0 h1:lYaaLa+x3VVUhtosaK9xihwQ9H9KRa557REHwwZ2orM=
google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
google.golang.org/api v0.68.0 h1:9eJiHhwJKIYX6sX2fUZxQLi7pDRA/MYu8c12q6WbJik=
google.golang.org/api v0.68.0/go.mod h1:sOM8pTpwgflXRhz+oC8H2Dr+UcbMqkPPWNJo88Q7TH8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -1170,8 +1170,9 @@ google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ6
google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220207185906-7721543eae58 h1:i67FGOy2/zGfhE3YgHdrOrcFbOBhqdcRoBrsDqSQrOI=
google.golang.org/genproto v0.0.0-20220207185906-7721543eae58/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220204002441-d6cc3cc0770e/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220211171837-173942840c17 h1:2X+CNIheCutWRyKRte8szGxrE5ggtV4U+NKAbh/oLhg=
google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
@ -1227,10 +1228,13 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.66.3 h1:jRskFVxYaMGAMUbN0UZ7niA9gzL9B49DOqE78vg0k3w=
gopkg.in/ini.v1 v1.66.3/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.66.4 h1:SsAcf+mM7mRZo2nJNGt8mZCjG8ZRaNGMURJw7BsIST4=
gopkg.in/ini.v1 v1.66.4/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View file

@ -32,6 +32,7 @@ const (
claimPermissionsKey = "permissions"
claimAPIKey = "api_key"
basicRealm = "Basic realm=\"SFTPGo\""
jwtCookieKey = "jwt"
)
var (
@ -135,7 +136,7 @@ func (c *jwtTokenClaims) hasPerm(perm string) bool {
return util.IsStringInSlice(perm, c.Permissions)
}
func (c *jwtTokenClaims) createTokenResponse(tokenAuth *jwtauth.JWTAuth, audience tokenAudience) (map[string]interface{}, error) {
func (c *jwtTokenClaims) createToken(tokenAuth *jwtauth.JWTAuth, audience tokenAudience) (jwt.Token, string, error) {
claims := c.asMap()
now := time.Now().UTC()
@ -144,7 +145,11 @@ func (c *jwtTokenClaims) createTokenResponse(tokenAuth *jwtauth.JWTAuth, audienc
claims[jwt.ExpirationKey] = now.Add(tokenDuration)
claims[jwt.AudienceKey] = audience
token, tokenString, err := tokenAuth.Encode(claims)
return tokenAuth.Encode(claims)
}
func (c *jwtTokenClaims) createTokenResponse(tokenAuth *jwtauth.JWTAuth, audience tokenAudience) (map[string]interface{}, error) {
token, tokenString, err := c.createToken(tokenAuth, audience)
if err != nil {
return nil, err
}
@ -168,7 +173,7 @@ func (c *jwtTokenClaims) createAndSetCookie(w http.ResponseWriter, r *http.Reque
basePath = webBaseClientPath
}
http.SetCookie(w, &http.Cookie{
Name: "jwt",
Name: jwtCookieKey,
Value: resp["access_token"].(string),
Path: basePath,
Expires: time.Now().Add(tokenDuration),
@ -183,7 +188,7 @@ func (c *jwtTokenClaims) createAndSetCookie(w http.ResponseWriter, r *http.Reque
func (c *jwtTokenClaims) removeCookie(w http.ResponseWriter, r *http.Request, cookiePath string) {
http.SetCookie(w, &http.Cookie{
Name: "jwt",
Name: jwtCookieKey,
Value: "",
Path: cookiePath,
Expires: time.Unix(0, 0),
@ -195,6 +200,13 @@ func (c *jwtTokenClaims) removeCookie(w http.ResponseWriter, r *http.Request, co
invalidateToken(r)
}
func tokenFromContext(r *http.Request) string {
if token, ok := r.Context().Value(oidcGeneratedToken).(string); ok {
return token
}
return ""
}
func isTLS(r *http.Request) bool {
if r.TLS != nil {
return true
@ -206,21 +218,22 @@ func isTLS(r *http.Request) bool {
}
func isTokenInvalidated(r *http.Request) bool {
var findTokenFns []func(r *http.Request) string
findTokenFns = append(findTokenFns, jwtauth.TokenFromHeader)
findTokenFns = append(findTokenFns, jwtauth.TokenFromCookie)
findTokenFns = append(findTokenFns, tokenFromContext)
isTokenFound := false
token := jwtauth.TokenFromHeader(r)
for _, fn := range findTokenFns {
token := fn(r)
if token != "" {
isTokenFound = true
if _, ok := invalidatedJWTTokens.Load(token); ok {
return true
}
}
token = jwtauth.TokenFromCookie(r)
if token != "" {
isTokenFound = true
if _, ok := invalidatedJWTTokens.Load(token); ok {
return true
}
}
return !isTokenFound
}

46
httpd/flash.go Normal file
View file

@ -0,0 +1,46 @@
package httpd
import (
"encoding/base64"
"net/http"
"time"
)
const (
flashCookieName = "message"
)
func setFlashMessage(w http.ResponseWriter, r *http.Request, value string) {
http.SetCookie(w, &http.Cookie{
Name: flashCookieName,
Value: base64.URLEncoding.EncodeToString([]byte(value)),
Path: "/",
Expires: time.Now().Add(60 * time.Second),
MaxAge: 60,
HttpOnly: true,
Secure: isTLS(r),
SameSite: http.SameSiteLaxMode,
})
}
func getFlashMessage(w http.ResponseWriter, r *http.Request) string {
cookie, err := r.Cookie(flashCookieName)
if err != nil {
return ""
}
http.SetCookie(w, &http.Cookie{
Name: flashCookieName,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: isTLS(r),
SameSite: http.SameSiteLaxMode,
})
message, err := base64.URLEncoding.DecodeString(cookie.Value)
if err != nil {
return ""
}
return string(message)
}

26
httpd/flash_test.go Normal file
View file

@ -0,0 +1,26 @@
package httpd
import (
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFlashMessages(t *testing.T) {
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, "/url", nil)
require.NoError(t, err)
message := "test message"
setFlashMessage(rr, req, message)
req.Header.Set("Cookie", fmt.Sprintf("%v=%v", flashCookieName, base64.URLEncoding.EncodeToString([]byte(message))))
msg := getFlashMessage(rr, req)
assert.Equal(t, message, msg)
req.Header.Set("Cookie", fmt.Sprintf("%v=%v", flashCookieName, "a"))
msg = getFlashMessage(rr, req)
assert.Empty(t, msg)
}

View file

@ -90,7 +90,9 @@ const (
webBasePathAdminDefault = "/web/admin"
webBasePathClientDefault = "/web/client"
webAdminSetupPathDefault = "/web/admin/setup"
webLoginPathDefault = "/web/admin/login"
webAdminLoginPathDefault = "/web/admin/login"
webAdminOIDCLoginPathDefault = "/web/admin/oidclogin"
webOIDCRedirectPathDefault = "/web/oidc/redirect"
webAdminTwoFactorPathDefault = "/web/admin/twofactor"
webAdminTwoFactorRecoveryPathDefault = "/web/admin/twofactor-recovery"
webLogoutPathDefault = "/web/admin/logout"
@ -121,6 +123,7 @@ const (
webDefenderPathDefault = "/web/admin/defender"
webDefenderHostsPathDefault = "/web/admin/defender/hosts"
webClientLoginPathDefault = "/web/client/login"
webClientOIDCLoginPathDefault = "/web/client/oidclogin"
webClientTwoFactorPathDefault = "/web/client/twofactor"
webClientTwoFactorRecoveryPathDefault = "/web/client/twofactor-recovery"
webClientFilesPathDefault = "/web/client/files"
@ -166,8 +169,10 @@ var (
webBasePath string
webBaseAdminPath string
webBaseClientPath string
webOIDCRedirectPath string
webAdminSetupPath string
webLoginPath string
webAdminOIDCLoginPath string
webAdminLoginPath string
webAdminTwoFactorPath string
webAdminTwoFactorRecoveryPath string
webLogoutPath string
@ -198,6 +203,7 @@ var (
webDefenderPath string
webDefenderHostsPath string
webClientLoginPath string
webClientOIDCLoginPath string
webClientTwoFactorPath string
webClientTwoFactorRecoveryPath string
webClientFilesPath string
@ -281,6 +287,8 @@ type Binding struct {
// Enabling web client integrations you can render or modify the files with the specified
// extensions using an external tool.
WebClientIntegrations []WebClientIntegration `json:"web_client_integrations" mapstructure:"web_client_integrations"`
// Defining an OIDC configuration the web admin and web client UI will use OpenID to authenticate users.
OIDC OIDC `json:"oidc" mapstructure:"oidc"`
allowHeadersFrom []func(net.IP) bool
}
@ -446,8 +454,19 @@ func (c *Conf) checkRequiredDirs(staticFilesPath, templatesPath string) error {
}
func (c *Conf) getRedacted() Conf {
redacted := "[redacted]"
conf := *c
conf.SigningPassphrase = "[redacted]"
conf.SigningPassphrase = redacted
conf.Bindings = nil
for _, binding := range c.Bindings {
if binding.OIDC.ClientID != "" {
binding.OIDC.ClientID = redacted
}
if binding.OIDC.ClientSecret != "" {
binding.OIDC.ClientSecret = redacted
}
conf.Bindings = append(conf.Bindings, binding)
}
return conf
}
@ -508,6 +527,10 @@ func (c *Conf) Initialize(configDir string) error {
binding.checkWebClientIntegrations()
go func(b Binding) {
if err := b.OIDC.initialize(); err != nil {
exitChannel <- err
return
}
server := newHttpdServer(b, staticFilesPath, c.SigningPassphrase, c.Cors, openAPIPath)
exitChannel <- server.listenAndServe()
@ -581,7 +604,9 @@ func updateWebClientURLs(baseURL string) {
webRootPath = path.Join(baseURL, webRootPathDefault)
webBasePath = path.Join(baseURL, webBasePathDefault)
webBaseClientPath = path.Join(baseURL, webBasePathClientDefault)
webOIDCRedirectPath = path.Join(baseURL, webOIDCRedirectPathDefault)
webClientLoginPath = path.Join(baseURL, webClientLoginPathDefault)
webClientOIDCLoginPath = path.Join(baseURL, webClientOIDCLoginPathDefault)
webClientTwoFactorPath = path.Join(baseURL, webClientTwoFactorPathDefault)
webClientTwoFactorRecoveryPath = path.Join(baseURL, webClientTwoFactorRecoveryPathDefault)
webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault)
@ -612,8 +637,10 @@ func updateWebAdminURLs(baseURL string) {
webRootPath = path.Join(baseURL, webRootPathDefault)
webBasePath = path.Join(baseURL, webBasePathDefault)
webBaseAdminPath = path.Join(baseURL, webBasePathAdminDefault)
webOIDCRedirectPath = path.Join(baseURL, webOIDCRedirectPathDefault)
webAdminSetupPath = path.Join(baseURL, webAdminSetupPathDefault)
webLoginPath = path.Join(baseURL, webLoginPathDefault)
webAdminLoginPath = path.Join(baseURL, webAdminLoginPathDefault)
webAdminOIDCLoginPath = path.Join(baseURL, webAdminOIDCLoginPathDefault)
webAdminTwoFactorPath = path.Join(baseURL, webAdminTwoFactorPathDefault)
webAdminTwoFactorRecoveryPath = path.Join(baseURL, webAdminTwoFactorRecoveryPathDefault)
webLogoutPath = path.Join(baseURL, webLogoutPathDefault)

File diff suppressed because one or more lines are too long

View file

@ -733,13 +733,13 @@ func TestCreateTokenError(t *testing.T) {
form.Set("username", admin.Username)
form.Set("password", admin.Password)
form.Set(csrfFormToken, createCSRFToken())
req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
req, _ = http.NewRequest(http.MethodPost, webAdminLoginPath, bytes.NewBuffer([]byte(form.Encode())))
req.RemoteAddr = "127.0.0.1:1234"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
server.handleWebAdminLoginPost(rr, req)
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
// req with no content type
req, _ = http.NewRequest(http.MethodPost, webLoginPath, nil)
req, _ = http.NewRequest(http.MethodPost, webAdminLoginPath, nil)
rr = httptest.NewRecorder()
server.handleWebAdminLoginPost(rr, req)
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
@ -747,19 +747,19 @@ func TestCreateTokenError(t *testing.T) {
rr = httptest.NewRecorder()
server.loginAdmin(rr, req, &admin, false, nil)
// req with no POST body
req, _ = http.NewRequest(http.MethodGet, webLoginPath+"?a=a%C3%AO%GG", nil)
req, _ = http.NewRequest(http.MethodGet, webAdminLoginPath+"?a=a%C3%AO%GG", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
server.handleWebAdminLoginPost(rr, req)
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
req, _ = http.NewRequest(http.MethodGet, webLoginPath+"?a=a%C3%A1%G2", nil)
req, _ = http.NewRequest(http.MethodGet, webAdminLoginPath+"?a=a%C3%A1%G2", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
handleWebAdminChangePwdPost(rr, req)
server.handleWebAdminChangePwdPost(rr, req)
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
assert.Contains(t, rr.Body.String(), "invalid URL escape")
req, _ = http.NewRequest(http.MethodGet, webLoginPath+"?a=a%C3%A2%G3", nil)
req, _ = http.NewRequest(http.MethodGet, webAdminLoginPath+"?a=a%C3%A2%G3", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
_, err := getAdminFromPostFields(req)
assert.Error(t, err)
@ -773,7 +773,7 @@ func TestCreateTokenError(t *testing.T) {
req, _ = http.NewRequest(http.MethodPost, webChangeClientPwdPath+"?a=a%C3%AO%GA", bytes.NewBuffer([]byte(form.Encode())))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
handleWebClientChangePwdPost(rr, req)
server.handleWebClientChangePwdPost(rr, req)
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
assert.Contains(t, rr.Body.String(), "invalid URL escape")
@ -943,7 +943,7 @@ func TestJWTTokenValidation(t *testing.T) {
ctx = jwtauth.NewContext(req.Context(), token, nil)
fn.ServeHTTP(rr, req.WithContext(ctx))
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webLoginPath, rr.Header().Get("Location"))
assert.Equal(t, webAdminLoginPath, rr.Header().Get("Location"))
fn = jwtAuthenticatorWebClient(r)
rr = httptest.NewRecorder()
@ -1469,7 +1469,7 @@ func TestProxyHeaders(t *testing.T) {
form.Set("username", username)
form.Set("password", password)
form.Set(csrfFormToken, createCSRFToken())
req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
req, err = http.NewRequest(http.MethodPost, webAdminLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = testIP
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@ -1478,7 +1478,7 @@ func TestProxyHeaders(t *testing.T) {
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
assert.Contains(t, rr.Body.String(), "login from IP 10.29.1.9 not allowed")
req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
req, err = http.NewRequest(http.MethodPost, webAdminLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = testIP
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@ -1489,7 +1489,7 @@ func TestProxyHeaders(t *testing.T) {
cookie := rr.Header().Get("Set-Cookie")
assert.NotContains(t, cookie, "Secure")
req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
req, err = http.NewRequest(http.MethodPost, webAdminLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = testIP
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@ -1501,7 +1501,7 @@ func TestProxyHeaders(t *testing.T) {
cookie = rr.Header().Get("Set-Cookie")
assert.Contains(t, cookie, "Secure")
req, err = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
req, err = http.NewRequest(http.MethodPost, webAdminLoginPath, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = testIP
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@ -1650,14 +1650,14 @@ func TestWebAdminRedirect(t *testing.T) {
rr := httptest.NewRecorder()
testServer.Config.Handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusFound, rr.Code, rr.Body.String())
assert.Equal(t, webLoginPath, rr.Header().Get("Location"))
assert.Equal(t, webAdminLoginPath, rr.Header().Get("Location"))
req, err = http.NewRequest(http.MethodGet, webBasePath, nil)
assert.NoError(t, err)
rr = httptest.NewRecorder()
testServer.Config.Handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusFound, rr.Code, rr.Body.String())
assert.Equal(t, webLoginPath, rr.Header().Get("Location"))
assert.Equal(t, webAdminLoginPath, rr.Header().Get("Location"))
}
func TestParseRangeRequests(t *testing.T) {

View file

@ -36,7 +36,7 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi
var redirectPath string
if audience == tokenAudienceWebAdmin {
redirectPath = webLoginPath
redirectPath = webAdminLoginPath
} else {
redirectPath = webClientLoginPath
}
@ -199,6 +199,20 @@ func checkHTTPUserPerm(perm string) func(next http.Handler) http.Handler {
}
}
func requireBuiltinLogin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isLoggedInWithOIDC(r) {
if isWebClientRequest(r) {
renderClientForbiddenPage(w, r, "This feature is not available if you are logged in with OpenID")
} else {
renderForbiddenPage(w, r, "This feature is not available if you are logged in with OpenID")
}
return
}
next.ServeHTTP(w, r)
})
}
func checkPerm(perm string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

722
httpd/oidc.go Normal file
View file

@ -0,0 +1,722 @@
package httpd
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/rs/xid"
"golang.org/x/oauth2"
"github.com/drakkan/sftpgo/v2/common"
"github.com/drakkan/sftpgo/v2/dataprovider"
"github.com/drakkan/sftpgo/v2/httpclient"
"github.com/drakkan/sftpgo/v2/logger"
"github.com/drakkan/sftpgo/v2/util"
)
const (
oidcCookieKey = "oidc"
authStateValidity = 1 * 60 * 1000 // 1 minute
tokenUpdateInterval = 3 * 60 * 1000 // 3 minutes
tokenDeleteInterval = 2 * 3600 * 1000 // 2 hours
)
var (
oidcTokenKey = &contextKey{"OIDC token key"}
oidcGeneratedToken = &contextKey{"OIDC generated token"}
oidcMgr *oidcManager
)
func init() {
oidcMgr = &oidcManager{
pendingAuths: make(map[string]oidcPendingAuth),
tokens: make(map[string]oidcToken),
lastCleanup: time.Now(),
}
}
// OAuth2Config defines an interface for OAuth2 methods, so we can mock them
type OAuth2Config interface {
AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
TokenSource(ctx context.Context, t *oauth2.Token) oauth2.TokenSource
}
// OIDCTokenVerifier defines an interface for OpenID token verifier, so we can mock them
type OIDCTokenVerifier interface {
Verify(ctx context.Context, rawIDToken string) (*oidc.IDToken, error)
}
// OIDC defines the OpenID Connect configuration
type OIDC struct {
// ClientID is the application's ID
ClientID string `json:"client_id" mapstructure:"client_id"`
// ClientSecret is the application's secret
ClientSecret string `json:"client_secret" mapstructure:"client_secret"`
// ConfigURL is the identifier for the service.
// SFTPGo will try to retrieve the provider configuration on startup and then
// will refuse to start if it fails to connect to the specified URL
ConfigURL string `json:"config_url" mapstructure:"config_url"`
// RedirectBaseURL is the base URL to redirect to after OpenID authentication.
// The suffix "/web/oidc/redirect" will be added to this base URL, adding also the
// "web_root" if configured
RedirectBaseURL string `json:"redirect_base_url" mapstructure:"redirect_base_url"`
// ID token claims field to map to the SFTPGo username
UsernameField string `json:"username_field" mapstructure:"username_field"`
// Optional ID token claims field to map to a SFTPGo role.
// If the defined ID token claims field is set to "admin" the authenticated user
// is mapped to an SFTPGo admin.
// You don't need to specify this field if you want to use OpenID only for the
// Web Client UI
RoleField string `json:"role_field" mapstructure:"role_field"`
provider *oidc.Provider
verifier OIDCTokenVerifier
providerLogoutURL string
oauth2Config OAuth2Config
}
func (o *OIDC) isEnabled() bool {
return o.provider != nil
}
func (o *OIDC) hasRoles() bool {
return o.isEnabled() && o.RoleField != ""
}
func (o *OIDC) getRedirectURL() string {
url := o.RedirectBaseURL
if strings.HasSuffix(o.RedirectBaseURL, "/") {
url = strings.TrimSuffix(o.RedirectBaseURL, "/")
}
url += webOIDCRedirectPath
logger.Debug(logSender, "", "oidc redirect URL: %#v", url)
return url
}
func (o *OIDC) initialize() error {
if o.ConfigURL == "" {
return nil
}
if o.UsernameField == "" {
return errors.New("oidc: username field cannot be empty")
}
if o.RedirectBaseURL == "" {
return errors.New("oidc: redirect base URL cannot be empty")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
provider, err := oidc.NewProvider(ctx, o.ConfigURL)
if err != nil {
return fmt.Errorf("oidc: unable to initialize provider for URL %#v: %w", o.ConfigURL, err)
}
claims := make(map[string]interface{})
// we cannot get an error here because the response body was already parsed as JSON
// on provider creation
provider.Claims(&claims) //nolint:errcheck
endSessionEndPoint, ok := claims["end_session_endpoint"]
if ok {
if val, ok := endSessionEndPoint.(string); ok {
o.providerLogoutURL = val
logger.Debug(logSender, "", "oidc end session endpoint %#v", o.providerLogoutURL)
}
}
o.provider = provider
o.verifier = provider.Verifier(&oidc.Config{
ClientID: o.ClientID,
})
o.oauth2Config = &oauth2.Config{
ClientID: o.ClientID,
ClientSecret: o.ClientSecret,
Endpoint: o.provider.Endpoint(),
RedirectURL: o.getRedirectURL(),
Scopes: []string{oidc.ScopeOpenID},
}
return nil
}
type oidcPendingAuth struct {
State string
Nonce string
Audience tokenAudience
IssueAt int64
}
func newOIDCPendingAuth(audience tokenAudience) oidcPendingAuth {
return oidcPendingAuth{
State: xid.New().String(),
Nonce: xid.New().String(),
Audience: audience,
IssueAt: util.GetTimeAsMsSinceEpoch(time.Now()),
}
}
type oidcToken struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresAt int64 `json:"expires_at,omitempty"`
SessionID string `json:"session_id"`
IDToken string `json:"id_token"`
Nonce string `json:"nonce"`
Username string `json:"username"`
Permissions []string `json:"permissions"`
Role string `json:"role"`
Cookie string `json:"cookie"`
UsedAt int64 `json:"used_at"`
}
func (t *oidcToken) parseClaims(claims map[string]interface{}, usernameField, roleField string) error {
username, ok := claims[usernameField].(string)
if !ok || username == "" {
return errors.New("no username field")
}
t.Username = username
if roleField != "" {
role, ok := claims[roleField].(string)
if ok {
t.Role = role
}
}
sid, ok := claims["sid"].(string)
if ok {
t.SessionID = sid
}
return nil
}
func (t *oidcToken) isAdmin() bool {
return t.Role == "admin"
}
func (t *oidcToken) isExpired() bool {
if t.ExpiresAt == 0 {
return false
}
return t.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now())
}
func (t *oidcToken) refresh(config OAuth2Config, verifier OIDCTokenVerifier) error {
if t.RefreshToken == "" {
logger.Debug(logSender, "", "refresh token not set, unable to refresh cookie %#v", t.Cookie)
return errors.New("refresh token not set")
}
oauth2Token := oauth2.Token{
AccessToken: t.AccessToken,
TokenType: t.TokenType,
RefreshToken: t.RefreshToken,
}
if t.ExpiresAt > 0 {
oauth2Token.Expiry = util.GetTimeFromMsecSinceEpoch(t.ExpiresAt)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
newToken, err := config.TokenSource(ctx, &oauth2Token).Token()
if err != nil {
logger.Debug(logSender, "", "unable to refresh token for cookie %#v: %v", t.Cookie, err)
return err
}
rawIDToken, ok := newToken.Extra("id_token").(string)
if !ok {
logger.Debug(logSender, "", "the refreshed token has no id token, cookie %#v", t.Cookie)
return errors.New("the refreshed token has no id token")
}
t.AccessToken = newToken.AccessToken
t.TokenType = newToken.TokenType
t.RefreshToken = newToken.RefreshToken
t.IDToken = rawIDToken
if !newToken.Expiry.IsZero() {
t.ExpiresAt = util.GetTimeAsMsSinceEpoch(newToken.Expiry)
} else {
t.ExpiresAt = 0
}
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
logger.Debug(logSender, "", "unable to verify refreshed id token for cookie %#v: %v", t.Cookie, err)
return err
}
if idToken.Nonce != t.Nonce {
logger.Debug(logSender, "", "unable to verify refreshed id token for cookie %#v: nonce mismatch", t.Cookie)
return errors.New("the refreshed token nonce mismatch")
}
claims := make(map[string]interface{})
err = idToken.Claims(&claims)
if err != nil {
logger.Debug(logSender, "", "unable to get refreshed id token claims for cookie %#v: %v", t.Cookie, err)
return err
}
sid, ok := claims["sid"].(string)
if ok {
t.SessionID = sid
}
logger.Debug(logSender, "", "oidc token refreshed for user %#v, cookie %#v", t.Username, t.Cookie)
oidcMgr.addToken(*t)
return nil
}
func (t *oidcToken) getUser(r *http.Request) error {
if t.isAdmin() {
admin, err := dataprovider.AdminExists(t.Username)
if err != nil {
return err
}
if err := admin.CanLogin(util.GetIPFromRemoteAddress(r.RemoteAddr)); err != nil {
return err
}
t.Permissions = admin.Permissions
dataprovider.UpdateAdminLastLogin(&admin)
return nil
}
user, err := dataprovider.UserExists(t.Username)
if err != nil {
return err
}
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolHTTP); err != nil {
updateLoginMetrics(&user, ipAddr, err)
return fmt.Errorf("access denied by post connect hook: %w", err)
}
if err := user.CheckLoginConditions(); err != nil {
updateLoginMetrics(&user, ipAddr, err)
return err
}
connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String())
if err := checkHTTPClientUser(&user, r, connectionID); err != nil {
updateLoginMetrics(&user, ipAddr, err)
return err
}
defer user.CloseFs() //nolint:errcheck
err = user.CheckFsRoot(connectionID)
if err != nil {
logger.Warn(logSender, connectionID, "unable to check fs root: %v", err)
updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure)
return err
}
updateLoginMetrics(&user, ipAddr, nil)
dataprovider.UpdateLastLogin(&user)
t.Permissions = user.Filters.WebClient
return nil
}
type oidcManager struct {
authMutex sync.RWMutex
pendingAuths map[string]oidcPendingAuth
tokenMutex sync.RWMutex
tokens map[string]oidcToken
lastCleanup time.Time
}
func (o *oidcManager) addPendingAuth(pendingAuth oidcPendingAuth) {
o.authMutex.Lock()
o.pendingAuths[pendingAuth.State] = pendingAuth
o.authMutex.Unlock()
o.checkCleanup()
}
func (o *oidcManager) removePendingAuth(key string) {
o.authMutex.Lock()
defer o.authMutex.Unlock()
delete(o.pendingAuths, key)
}
func (o *oidcManager) getPendingAuth(state string) (oidcPendingAuth, error) {
o.authMutex.RLock()
defer o.authMutex.RUnlock()
authReq, ok := o.pendingAuths[state]
if !ok {
return oidcPendingAuth{}, errors.New("oidc: no auth request found for the specified state")
}
diff := util.GetTimeAsMsSinceEpoch(time.Now()) - authReq.IssueAt
if diff > authStateValidity {
return oidcPendingAuth{}, errors.New("oidc: auth request is too old")
}
return authReq, nil
}
func (o *oidcManager) addToken(token oidcToken) {
o.tokenMutex.Lock()
token.UsedAt = util.GetTimeAsMsSinceEpoch(time.Now())
o.tokens[token.Cookie] = token
o.tokenMutex.Unlock()
o.checkCleanup()
}
func (o *oidcManager) getToken(cookie string) (oidcToken, error) {
o.tokenMutex.RLock()
defer o.tokenMutex.RUnlock()
token, ok := o.tokens[cookie]
if !ok {
return oidcToken{}, errors.New("oidc: no token found for the specified session")
}
return token, nil
}
func (o *oidcManager) removeToken(cookie string) {
o.tokenMutex.Lock()
defer o.tokenMutex.Unlock()
delete(o.tokens, cookie)
}
func (o *oidcManager) updateTokenUsage(token oidcToken) {
diff := util.GetTimeAsMsSinceEpoch(time.Now()) - token.UsedAt
if diff > tokenUpdateInterval {
o.addToken(token)
}
}
func (o *oidcManager) checkCleanup() {
o.authMutex.RLock()
needCleanup := o.lastCleanup.Add(20 * time.Minute).Before(time.Now())
o.authMutex.RUnlock()
if needCleanup {
o.authMutex.Lock()
o.lastCleanup = time.Now()
o.authMutex.Unlock()
o.cleanupAuthRequests()
o.cleanupTokens()
}
}
func (o *oidcManager) cleanupAuthRequests() {
o.authMutex.Lock()
defer o.authMutex.Unlock()
for k, auth := range o.pendingAuths {
diff := util.GetTimeAsMsSinceEpoch(time.Now()) - auth.IssueAt
// remove old pending auth requests
if diff < 0 || diff > authStateValidity {
delete(o.pendingAuths, k)
}
}
}
func (o *oidcManager) cleanupTokens() {
o.tokenMutex.Lock()
defer o.tokenMutex.Unlock()
for k, token := range o.tokens {
diff := util.GetTimeAsMsSinceEpoch(time.Now()) - token.UsedAt
// remove tokens unused from more than 1 hour
if diff > tokenDeleteInterval {
delete(o.tokens, k)
}
}
}
func (s *httpdServer) validateOIDCToken(w http.ResponseWriter, r *http.Request, isAdmin bool) (oidcToken, error) {
doRedirect := func() {
removeOIDCCookie(w, r)
if isAdmin {
http.Redirect(w, r, webAdminLoginPath, http.StatusFound)
return
}
http.Redirect(w, r, webClientLoginPath, http.StatusFound)
}
cookie, err := r.Cookie(oidcCookieKey)
if err != nil {
logger.Debug(logSender, "", "no oidc cookie, redirecting to login page")
doRedirect()
return oidcToken{}, errInvalidToken
}
token, err := oidcMgr.getToken(cookie.Value)
if err != nil {
logger.Debug(logSender, "", "error getting oidc token associated with cookie %#v: %v", cookie.Value, err)
doRedirect()
return oidcToken{}, errInvalidToken
}
if token.isExpired() {
logger.Debug(logSender, "", "oidc token associated with cookie %#v is expired", token.Cookie)
if err = token.refresh(s.binding.OIDC.oauth2Config, s.binding.OIDC.verifier); err != nil {
setFlashMessage(w, r, "Your OpenID token is expired, please log-in again")
doRedirect()
return oidcToken{}, errInvalidToken
}
} else {
oidcMgr.updateTokenUsage(token)
}
if isAdmin {
if !token.isAdmin() {
logger.Debug(logSender, "", "oidc token associated with cookie %#v is not valid for admin users", token.Cookie)
setFlashMessage(w, r, "Your OpenID token is not valid for the SFTPGo Web Admin UI. Please logout from your OpenID server and log-in as an SFTPGo admin")
doRedirect()
return oidcToken{}, errInvalidToken
}
return token, nil
}
if token.isAdmin() {
logger.Debug(logSender, "", "oidc token associated with cookie %#v is valid for admin users", token.Cookie)
setFlashMessage(w, r, "Your OpenID token is not valid for the SFTPGo Web Client UI. Please logout from your OpenID server and log-in as an SFTPGo user")
doRedirect()
return oidcToken{}, errInvalidToken
}
return token, nil
}
func (s *httpdServer) oidcTokenAuthenticator(audience tokenAudience) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if canSkipOIDCValidation(r) {
next.ServeHTTP(w, r)
return
}
token, err := s.validateOIDCToken(w, r, audience == tokenAudienceWebAdmin)
if err != nil {
return
}
jwtTokenClaims := jwtTokenClaims{
Username: token.Username,
Permissions: token.Permissions,
}
_, tokenString, err := jwtTokenClaims.createToken(s.tokenAuth, audience)
if err != nil {
setFlashMessage(w, r, "Unable to create cookie")
if audience == tokenAudienceWebAdmin {
http.Redirect(w, r, webAdminLoginPath, http.StatusFound)
} else {
http.Redirect(w, r, webClientLoginPath, http.StatusFound)
}
return
}
ctx := context.WithValue(r.Context(), oidcTokenKey, token.Cookie)
ctx = context.WithValue(ctx, oidcGeneratedToken, tokenString)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func (s *httpdServer) handleWebAdminOIDCLogin(w http.ResponseWriter, r *http.Request) {
s.oidcLoginRedirect(w, r, tokenAudienceWebAdmin)
}
func (s *httpdServer) handleWebClientOIDCLogin(w http.ResponseWriter, r *http.Request) {
s.oidcLoginRedirect(w, r, tokenAudienceWebClient)
}
func (s *httpdServer) oidcLoginRedirect(w http.ResponseWriter, r *http.Request, audience tokenAudience) {
pendingAuth := newOIDCPendingAuth(audience)
oidcMgr.addPendingAuth(pendingAuth)
http.Redirect(w, r, s.binding.OIDC.oauth2Config.AuthCodeURL(pendingAuth.State,
oidc.Nonce(pendingAuth.Nonce)), http.StatusFound)
}
func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
authReq, err := oidcMgr.getPendingAuth(state)
if err != nil {
logger.Debug(logSender, "", "oidc authentication state did not match")
renderClientMessagePage(w, r, "Invalid authentication request", "Authentication state did not match",
http.StatusBadRequest, nil, "")
return
}
oidcMgr.removePendingAuth(state)
doRedirect := func() {
if authReq.Audience == tokenAudienceWebAdmin {
http.Redirect(w, r, webAdminLoginPath, http.StatusFound)
return
}
http.Redirect(w, r, webClientLoginPath, http.StatusFound)
}
doLogout := func(rawIDToken string) {
s.logoutFromOIDCOP(rawIDToken)
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
oauth2Token, err := s.binding.OIDC.oauth2Config.Exchange(ctx, r.URL.Query().Get("code"))
if err != nil {
logger.Debug(logSender, "", "failed to exchange oidc token: %v", err)
setFlashMessage(w, r, "Failed to exchange OpenID token")
doRedirect()
return
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
logger.Debug(logSender, "", "no id_token field in OAuth2 OpenID token")
setFlashMessage(w, r, "No id_token field in OAuth2 OpenID token")
doRedirect()
return
}
idToken, err := s.binding.OIDC.verifier.Verify(ctx, rawIDToken)
if err != nil {
logger.Debug(logSender, "", "failed to verify oidc token: %v", err)
setFlashMessage(w, r, "Failed to verify OpenID token")
doRedirect()
doLogout(rawIDToken)
return
}
if idToken.Nonce != authReq.Nonce {
logger.Debug(logSender, "", "oidc authentication nonce did not match")
setFlashMessage(w, r, "OpenID authentication nonce did not match")
doRedirect()
doLogout(rawIDToken)
return
}
claims := make(map[string]interface{})
err = idToken.Claims(&claims)
if err != nil {
logger.Debug(logSender, "", "unable to get oidc token claims: %v", err)
setFlashMessage(w, r, "Unable to get OpenID token claims")
doRedirect()
doLogout(rawIDToken)
return
}
token := oidcToken{
AccessToken: oauth2Token.AccessToken,
TokenType: oauth2Token.TokenType,
RefreshToken: oauth2Token.RefreshToken,
IDToken: rawIDToken,
Nonce: idToken.Nonce,
Cookie: xid.New().String(),
}
if !oauth2Token.Expiry.IsZero() {
token.ExpiresAt = util.GetTimeAsMsSinceEpoch(oauth2Token.Expiry)
}
if err = token.parseClaims(claims, s.binding.OIDC.UsernameField, s.binding.OIDC.RoleField); err != nil {
logger.Debug(logSender, "", "unable to parse oidc token claims: %v", err)
setFlashMessage(w, r, fmt.Sprintf("Unable to parse OpenID token claims: %v", err))
doRedirect()
doLogout(rawIDToken)
return
}
switch authReq.Audience {
case tokenAudienceWebAdmin:
if !token.isAdmin() {
logger.Debug(logSender, "", "wrong oidc token role, the mapped user is not an SFTPGo admin")
setFlashMessage(w, r, "Wrong OpenID role, the logged in user is not an SFTPGo admin")
doRedirect()
doLogout(rawIDToken)
return
}
case tokenAudienceWebClient:
if token.isAdmin() {
logger.Debug(logSender, "", "wrong oidc token role, the mapped user is an SFTPGo admin")
setFlashMessage(w, r, "Wrong OpenID role, the logged in user is an SFTPGo admin")
doRedirect()
doLogout(rawIDToken)
return
}
}
err = token.getUser(r)
if err != nil {
logger.Debug(logSender, "", "unable to get the sftpgo user associated with oidc token: %v", err)
setFlashMessage(w, r, "Unable to get the user associated with the OpenID token")
doRedirect()
doLogout(rawIDToken)
return
}
loginOIDCUser(w, r, token)
}
func loginOIDCUser(w http.ResponseWriter, r *http.Request, token oidcToken) {
oidcMgr.addToken(token)
cookie := http.Cookie{
Name: oidcCookieKey,
Value: token.Cookie,
Path: "/",
HttpOnly: true,
Secure: isTLS(r),
SameSite: http.SameSiteLaxMode,
}
// we don't set a cookie expiration so we can refresh the token without setting a new cookie
// the cookie will be invalidated on browser close
http.SetCookie(w, &cookie)
if token.isAdmin() {
http.Redirect(w, r, webUsersPath, http.StatusFound)
return
}
http.Redirect(w, r, webClientFilesPath, http.StatusFound)
}
func (s *httpdServer) logoutOIDCUser(w http.ResponseWriter, r *http.Request) {
if oidcKey, ok := r.Context().Value(oidcTokenKey).(string); ok {
removeOIDCCookie(w, r)
token, err := oidcMgr.getToken(oidcKey)
if err == nil {
s.logoutFromOIDCOP(token.IDToken)
}
oidcMgr.removeToken(oidcKey)
}
}
func (s *httpdServer) logoutFromOIDCOP(idToken string) {
if s.binding.OIDC.providerLogoutURL == "" {
logger.Debug(logSender, "", "oidc: provider logout URL not set, unable to logout from the OP")
return
}
go s.doOIDCFromLogout(idToken)
}
func (s *httpdServer) doOIDCFromLogout(idToken string) {
logoutURL, err := url.Parse(s.binding.OIDC.providerLogoutURL)
if err != nil {
logger.Warn(logSender, "", "oidc: unable to parse logout URL: %v", err)
return
}
query := logoutURL.Query()
if idToken != "" {
query.Set("id_token_hint", idToken)
}
logoutURL.RawQuery = query.Encode()
resp, err := httpclient.RetryableGet(logoutURL.String())
if err != nil {
logger.Warn(logSender, "", "oidc: error calling logout URL %#v: %v", logoutURL.String(), err)
return
}
defer resp.Body.Close()
logger.Debug(logSender, "", "oidc: logout url response code %v", resp.StatusCode)
}
func removeOIDCCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: oidcCookieKey,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: isTLS(r),
SameSite: http.SameSiteLaxMode,
})
}
// canSkipOIDCValidation returns true if there is no OIDC cookie but a jwt cookie is set
// and so we check if the user is logged in using a built-in user
func canSkipOIDCValidation(r *http.Request) bool {
_, err := r.Cookie(oidcCookieKey)
if err != nil {
_, err = r.Cookie(jwtCookieKey)
return err == nil
}
return false
}
func isLoggedInWithOIDC(r *http.Request) bool {
_, ok := r.Context().Value(oidcTokenKey).(string)
return ok
}

883
httpd/oidc_test.go Normal file
View file

@ -0,0 +1,883 @@
package httpd
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"
"time"
"unsafe"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-chi/jwtauth/v5"
"github.com/rs/xid"
"github.com/sftpgo/sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"github.com/drakkan/sftpgo/v2/common"
"github.com/drakkan/sftpgo/v2/dataprovider"
"github.com/drakkan/sftpgo/v2/kms"
"github.com/drakkan/sftpgo/v2/util"
"github.com/drakkan/sftpgo/v2/vfs"
)
const (
oidcMockAddr = "127.0.0.1:11111"
)
type mockTokenSource struct {
token *oauth2.Token
err error
}
func (t *mockTokenSource) Token() (*oauth2.Token, error) {
return t.token, t.err
}
type mockOAuth2Config struct {
tokenSource *mockTokenSource
authCodeURL string
token *oauth2.Token
err error
}
func (c *mockOAuth2Config) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
return c.authCodeURL
}
func (c *mockOAuth2Config) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
return c.token, c.err
}
func (c *mockOAuth2Config) TokenSource(ctx context.Context, t *oauth2.Token) oauth2.TokenSource {
return c.tokenSource
}
type mockOIDCVerifier struct {
token *oidc.IDToken
err error
}
func (v *mockOIDCVerifier) Verify(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) {
return v.token, v.err
}
// hack because the field is unexported
func setIDTokenClaims(idToken *oidc.IDToken, claims []byte) {
pointerVal := reflect.ValueOf(idToken)
val := reflect.Indirect(pointerVal)
member := val.FieldByName("claims")
ptr := unsafe.Pointer(member.UnsafeAddr())
realPtr := (*[]byte)(ptr)
*realPtr = claims
}
func TestOIDCInitialization(t *testing.T) {
config := OIDC{
ClientID: "sftpgo-client",
ClientSecret: "jRsmE0SWnuZjP7djBqNq0mrf8QN77j2c",
ConfigURL: fmt.Sprintf("http://%v/", oidcMockAddr),
RedirectBaseURL: "http://127.0.0.1:8081/",
UsernameField: "preferred_username",
RoleField: "sftpgo_role",
}
err := config.initialize()
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "oidc: unable to initialize provider")
}
config.ConfigURL = fmt.Sprintf("http://%v/auth/realms/sftpgo", oidcMockAddr)
err = config.initialize()
assert.NoError(t, err)
assert.Equal(t, "http://127.0.0.1:8081"+webOIDCRedirectPath, config.getRedirectURL())
}
func TestOIDCLoginLogout(t *testing.T) {
server := getTestOIDCServer()
err := server.binding.OIDC.initialize()
assert.NoError(t, err)
server.initializeRouter()
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, webOIDCRedirectPath, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Contains(t, rr.Body.String(), "Authentication state did not match")
expiredAuthReq := oidcPendingAuth{
State: xid.New().String(),
Nonce: xid.New().String(),
Audience: tokenAudienceWebClient,
IssueAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(-10 * time.Minute)),
}
oidcMgr.addPendingAuth(expiredAuthReq)
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+expiredAuthReq.State, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Contains(t, rr.Body.String(), "Authentication state did not match")
oidcMgr.removePendingAuth(expiredAuthReq.State)
server.binding.OIDC.oauth2Config = &mockOAuth2Config{
tokenSource: &mockTokenSource{},
authCodeURL: webOIDCRedirectPath,
err: common.ErrGenericFailure,
}
server.binding.OIDC.verifier = &mockOIDCVerifier{
err: common.ErrGenericFailure,
}
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webAdminOIDCLoginPath, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webOIDCRedirectPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 1)
var state string
for k := range oidcMgr.pendingAuths {
state = k
}
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+state, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webAdminLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webAdminLoginPath, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusOK, rr.Code)
// now the same for the web client
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webClientOIDCLoginPath, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webOIDCRedirectPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 1)
for k := range oidcMgr.pendingAuths {
state = k
}
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+state, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webClientLoginPath, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusOK, rr.Code)
// now return an OAuth2 token without the id_token
server.binding.OIDC.oauth2Config = &mockOAuth2Config{
tokenSource: &mockTokenSource{},
authCodeURL: webOIDCRedirectPath,
token: &oauth2.Token{
AccessToken: "123",
Expiry: time.Now().Add(5 * time.Minute),
},
err: nil,
}
authReq := newOIDCPendingAuth(tokenAudienceWebClient)
oidcMgr.addPendingAuth(authReq)
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+authReq.State, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
// now fail to verify the id token
token := &oauth2.Token{
AccessToken: "123",
Expiry: time.Now().Add(5 * time.Minute),
}
token = token.WithExtra(map[string]interface{}{
"id_token": "id_token_val",
})
server.binding.OIDC.oauth2Config = &mockOAuth2Config{
tokenSource: &mockTokenSource{},
authCodeURL: webOIDCRedirectPath,
token: token,
err: nil,
}
authReq = newOIDCPendingAuth(tokenAudienceWebClient)
oidcMgr.addPendingAuth(authReq)
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+authReq.State, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
// id token nonce does not match
server.binding.OIDC.verifier = &mockOIDCVerifier{
err: nil,
token: &oidc.IDToken{},
}
authReq = newOIDCPendingAuth(tokenAudienceWebClient)
oidcMgr.addPendingAuth(authReq)
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+authReq.State, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
// null id token claims
authReq = newOIDCPendingAuth(tokenAudienceWebClient)
oidcMgr.addPendingAuth(authReq)
server.binding.OIDC.verifier = &mockOIDCVerifier{
err: nil,
token: &oidc.IDToken{
Nonce: authReq.Nonce,
},
}
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+authReq.State, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
// invalid id token claims (no username)
authReq = newOIDCPendingAuth(tokenAudienceWebClient)
oidcMgr.addPendingAuth(authReq)
idToken := &oidc.IDToken{
Nonce: authReq.Nonce,
Expiry: time.Now().Add(5 * time.Minute),
}
setIDTokenClaims(idToken, []byte(`{}`))
server.binding.OIDC.verifier = &mockOIDCVerifier{
err: nil,
token: idToken,
}
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+authReq.State, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
// invalid audience
authReq = newOIDCPendingAuth(tokenAudienceWebClient)
oidcMgr.addPendingAuth(authReq)
idToken = &oidc.IDToken{
Nonce: authReq.Nonce,
Expiry: time.Now().Add(5 * time.Minute),
}
setIDTokenClaims(idToken, []byte(`{"preferred_username":"test","sftpgo_role":"admin"}`))
server.binding.OIDC.verifier = &mockOIDCVerifier{
err: nil,
token: idToken,
}
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+authReq.State, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
// invalid audience
authReq = newOIDCPendingAuth(tokenAudienceWebAdmin)
oidcMgr.addPendingAuth(authReq)
idToken = &oidc.IDToken{
Nonce: authReq.Nonce,
Expiry: time.Now().Add(5 * time.Minute),
}
setIDTokenClaims(idToken, []byte(`{"preferred_username":"test"}`))
server.binding.OIDC.verifier = &mockOIDCVerifier{
err: nil,
token: idToken,
}
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+authReq.State, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webAdminLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
// mapped user not found
authReq = newOIDCPendingAuth(tokenAudienceWebAdmin)
oidcMgr.addPendingAuth(authReq)
idToken = &oidc.IDToken{
Nonce: authReq.Nonce,
Expiry: time.Now().Add(5 * time.Minute),
}
setIDTokenClaims(idToken, []byte(`{"preferred_username":"test","sftpgo_role":"admin"}`))
server.binding.OIDC.verifier = &mockOIDCVerifier{
err: nil,
token: idToken,
}
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+authReq.State, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webAdminLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
// admin login ok
authReq = newOIDCPendingAuth(tokenAudienceWebAdmin)
oidcMgr.addPendingAuth(authReq)
idToken = &oidc.IDToken{
Nonce: authReq.Nonce,
Expiry: time.Now().Add(5 * time.Minute),
}
setIDTokenClaims(idToken, []byte(`{"preferred_username":"admin","sftpgo_role":"admin","sid":"sid123"}`))
server.binding.OIDC.verifier = &mockOIDCVerifier{
err: nil,
token: idToken,
}
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+authReq.State, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webUsersPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
require.Len(t, oidcMgr.tokens, 1)
// admin profile is not available
var tokenCookie string
for k := range oidcMgr.tokens {
tokenCookie = k
}
oidcToken, err := oidcMgr.getToken(tokenCookie)
assert.NoError(t, err)
assert.Equal(t, "sid123", oidcToken.SessionID)
assert.True(t, oidcToken.isAdmin())
assert.False(t, oidcToken.isExpired())
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webAdminProfilePath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, tokenCookie))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusForbidden, rr.Code)
// the admin can access the allowed pages
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webUsersPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, tokenCookie))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusOK, rr.Code)
// try with an invalid cookie
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webUsersPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, xid.New().String()))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webAdminLoginPath, rr.Header().Get("Location"))
// Web Client is not available with an admin token
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, tokenCookie))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
// logout the admin user
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webLogoutPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, tokenCookie))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webAdminLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
require.Len(t, oidcMgr.tokens, 0)
// now login and logout a user
username := "test_oidc_user"
user := dataprovider.User{
BaseUser: sdk.BaseUser{
Username: username,
Password: "pwd",
HomeDir: filepath.Join(os.TempDir(), username),
Status: 1,
Permissions: map[string][]string{
"/": {dataprovider.PermAny},
},
},
Filters: dataprovider.UserFilters{
BaseUserFilters: sdk.BaseUserFilters{
WebClient: []string{sdk.WebClientSharesDisabled},
},
},
}
err = dataprovider.AddUser(&user, "", "")
assert.NoError(t, err)
authReq = newOIDCPendingAuth(tokenAudienceWebClient)
oidcMgr.addPendingAuth(authReq)
idToken = &oidc.IDToken{
Nonce: authReq.Nonce,
Expiry: time.Now().Add(5 * time.Minute),
}
setIDTokenClaims(idToken, []byte(`{"preferred_username":"test_oidc_user"}`))
server.binding.OIDC.verifier = &mockOIDCVerifier{
err: nil,
token: idToken,
}
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webOIDCRedirectPath+"?state="+authReq.State, nil)
assert.NoError(t, err)
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientFilesPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
require.Len(t, oidcMgr.tokens, 1)
// user profile is not available
for k := range oidcMgr.tokens {
tokenCookie = k
}
oidcToken, err = oidcMgr.getToken(tokenCookie)
assert.NoError(t, err)
assert.Empty(t, oidcToken.SessionID)
assert.False(t, oidcToken.isAdmin())
assert.False(t, oidcToken.isExpired())
if assert.Len(t, oidcToken.Permissions, 1) {
assert.Equal(t, sdk.WebClientSharesDisabled, oidcToken.Permissions[0])
}
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webClientProfilePath, nil)
assert.NoError(t, err)
r.RequestURI = webClientProfilePath
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, tokenCookie))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusForbidden, rr.Code)
// the user can access the allowed pages
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, tokenCookie))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusOK, rr.Code)
// try with an invalid cookie
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, xid.New().String()))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
// Web Admin is not available with a client cookie
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webUsersPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, tokenCookie))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webAdminLoginPath, rr.Header().Get("Location"))
// logout the user
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webClientLogoutPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, tokenCookie))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
require.Len(t, oidcMgr.pendingAuths, 0)
require.Len(t, oidcMgr.tokens, 0)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
err = dataprovider.DeleteUser(username, "", "")
assert.NoError(t, err)
}
func TestOIDCRefreshToken(t *testing.T) {
token := oidcToken{
Cookie: xid.New().String(),
AccessToken: xid.New().String(),
TokenType: "Bearer",
ExpiresAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(-1 * time.Minute)),
Nonce: xid.New().String(),
}
config := mockOAuth2Config{
tokenSource: &mockTokenSource{
err: common.ErrGenericFailure,
},
}
verifier := mockOIDCVerifier{
err: common.ErrGenericFailure,
}
err := token.refresh(&config, &verifier)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "refresh token not set")
}
token.RefreshToken = xid.New().String()
err = token.refresh(&config, &verifier)
assert.ErrorIs(t, err, common.ErrGenericFailure)
newToken := &oauth2.Token{
AccessToken: xid.New().String(),
RefreshToken: xid.New().String(),
Expiry: time.Now().Add(5 * time.Minute),
}
config = mockOAuth2Config{
tokenSource: &mockTokenSource{
token: newToken,
},
}
verifier = mockOIDCVerifier{
token: &oidc.IDToken{},
}
err = token.refresh(&config, &verifier)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "the refreshed token has no id token")
}
newToken = newToken.WithExtra(map[string]interface{}{
"id_token": "id_token_val",
})
newToken.Expiry = time.Time{}
config = mockOAuth2Config{
tokenSource: &mockTokenSource{
token: newToken,
},
}
verifier = mockOIDCVerifier{
err: common.ErrGenericFailure,
}
err = token.refresh(&config, &verifier)
assert.ErrorIs(t, err, common.ErrGenericFailure)
newToken = newToken.WithExtra(map[string]interface{}{
"id_token": "id_token_val",
})
newToken.Expiry = time.Now().Add(5 * time.Minute)
config = mockOAuth2Config{
tokenSource: &mockTokenSource{
token: newToken,
},
}
verifier = mockOIDCVerifier{
token: &oidc.IDToken{},
}
err = token.refresh(&config, &verifier)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "the refreshed token nonce mismatch")
}
verifier = mockOIDCVerifier{
token: &oidc.IDToken{
Nonce: token.Nonce,
},
}
err = token.refresh(&config, &verifier)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "oidc: claims not set")
}
idToken := &oidc.IDToken{
Nonce: token.Nonce,
}
setIDTokenClaims(idToken, []byte(`{"sid":"id_token_sid"}`))
verifier = mockOIDCVerifier{
token: idToken,
}
err = token.refresh(&config, &verifier)
assert.NoError(t, err)
require.Len(t, oidcMgr.tokens, 1)
oidcMgr.removeToken(token.Cookie)
require.Len(t, oidcMgr.tokens, 0)
}
func TestValidateOIDCToken(t *testing.T) {
server := getTestOIDCServer()
err := server.binding.OIDC.initialize()
assert.NoError(t, err)
server.initializeRouter()
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, webClientLogoutPath, nil)
assert.NoError(t, err)
_, err = server.validateOIDCToken(rr, r, false)
assert.ErrorIs(t, err, errInvalidToken)
// expired token and refresh error
server.binding.OIDC.oauth2Config = &mockOAuth2Config{
tokenSource: &mockTokenSource{
err: common.ErrGenericFailure,
},
}
token := oidcToken{
Cookie: xid.New().String(),
AccessToken: xid.New().String(),
ExpiresAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(-2 * time.Minute)),
}
oidcMgr.addToken(token)
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webClientLogoutPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, token.Cookie))
_, err = server.validateOIDCToken(rr, r, false)
assert.ErrorIs(t, err, errInvalidToken)
oidcMgr.removeToken(token.Cookie)
assert.Len(t, oidcMgr.tokens, 0)
server.tokenAuth = jwtauth.New("PS256", util.GenerateRandomBytes(32), nil)
token = oidcToken{
Cookie: xid.New().String(),
AccessToken: xid.New().String(),
}
oidcMgr.addToken(token)
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webClientLogoutPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, token.Cookie))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
oidcMgr.removeToken(token.Cookie)
assert.Len(t, oidcMgr.tokens, 0)
token = oidcToken{
Cookie: xid.New().String(),
AccessToken: xid.New().String(),
Role: "admin",
}
oidcMgr.addToken(token)
rr = httptest.NewRecorder()
r, err = http.NewRequest(http.MethodGet, webLogoutPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, token.Cookie))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webAdminLoginPath, rr.Header().Get("Location"))
oidcMgr.removeToken(token.Cookie)
assert.Len(t, oidcMgr.tokens, 0)
}
func TestSkipOIDCAuth(t *testing.T) {
server := getTestOIDCServer()
err := server.binding.OIDC.initialize()
assert.NoError(t, err)
server.initializeRouter()
jwtTokenClaims := jwtTokenClaims{
Username: "user",
}
_, tokenString, err := jwtTokenClaims.createToken(server.tokenAuth, tokenAudienceWebClient)
assert.NoError(t, err)
rr := httptest.NewRecorder()
r, err := http.NewRequest(http.MethodGet, webClientLogoutPath, nil)
assert.NoError(t, err)
r.Header.Set("Cookie", fmt.Sprintf("%v=%v", jwtCookieKey, tokenString))
server.router.ServeHTTP(rr, r)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
}
func TestOIDCLogoutErrors(t *testing.T) {
server := getTestOIDCServer()
assert.Empty(t, server.binding.OIDC.providerLogoutURL)
server.logoutFromOIDCOP("")
server.binding.OIDC.providerLogoutURL = "http://foo\x7f.com/"
server.doOIDCFromLogout("")
server.binding.OIDC.providerLogoutURL = "http://127.0.0.1:11234"
server.doOIDCFromLogout("")
}
func TestOIDCToken(t *testing.T) {
admin := dataprovider.Admin{
Username: "test_oidc_admin",
Password: "p",
Permissions: []string{dataprovider.PermAdminAny},
Status: 0,
}
err := dataprovider.AddAdmin(&admin, "", "")
assert.NoError(t, err)
token := oidcToken{
Username: admin.Username,
Role: "admin",
}
req, err := http.NewRequest(http.MethodGet, webUsersPath, nil)
assert.NoError(t, err)
err = token.getUser(req)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "is disabled")
}
err = dataprovider.DeleteAdmin(admin.Username, "", "")
assert.NoError(t, err)
username := "test_oidc_user"
token.Username = username
token.Role = ""
err = token.getUser(req)
if assert.Error(t, err) {
_, ok := err.(*util.RecordNotFoundError)
assert.True(t, ok)
}
user := dataprovider.User{
BaseUser: sdk.BaseUser{
Username: username,
Password: "p",
HomeDir: filepath.Join(os.TempDir(), username),
Status: 0,
Permissions: map[string][]string{
"/": {dataprovider.PermAny},
},
},
Filters: dataprovider.UserFilters{
BaseUserFilters: sdk.BaseUserFilters{
DeniedProtocols: []string{common.ProtocolHTTP},
},
},
}
err = dataprovider.AddUser(&user, "", "")
assert.NoError(t, err)
err = token.getUser(req)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "is disabled")
}
user, err = dataprovider.UserExists(username)
assert.NoError(t, err)
user.Status = 1
user.Password = "np"
err = dataprovider.UpdateUser(&user, "", "")
assert.NoError(t, err)
err = token.getUser(req)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "protocol HTTP is not allowed")
}
user.Filters.DeniedProtocols = nil
user.FsConfig.Provider = sdk.SFTPFilesystemProvider
user.FsConfig.SFTPConfig = vfs.SFTPFsConfig{
BaseSFTPFsConfig: sdk.BaseSFTPFsConfig{
Endpoint: "127.0.0.1:8022",
Username: username,
},
Password: kms.NewPlainSecret("np"),
}
err = dataprovider.UpdateUser(&user, "", "")
assert.NoError(t, err)
err = token.getUser(req)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "SFTP loop")
}
common.Config.PostConnectHook = fmt.Sprintf("http://%v/404", oidcMockAddr)
err = token.getUser(req)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "access denied by post connect hook")
}
common.Config.PostConnectHook = ""
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
err = dataprovider.DeleteUser(username, "", "")
assert.NoError(t, err)
}
func getTestOIDCServer() *httpdServer {
return &httpdServer{
binding: Binding{
OIDC: OIDC{
ClientID: "sftpgo-client",
ClientSecret: "jRsmE0SWnuZjP7djBqNq0mrf8QN77j2c",
ConfigURL: fmt.Sprintf("http://%v/auth/realms/sftpgo", oidcMockAddr),
RedirectBaseURL: "http://127.0.0.1:8081/",
UsernameField: "preferred_username",
RoleField: "sftpgo_role",
},
},
enableWebAdmin: true,
enableWebClient: true,
}
}
func TestOIDCManager(t *testing.T) {
require.Len(t, oidcMgr.pendingAuths, 0)
authReq := newOIDCPendingAuth(tokenAudienceWebAdmin)
oidcMgr.addPendingAuth(authReq)
require.Len(t, oidcMgr.pendingAuths, 1)
_, err := oidcMgr.getPendingAuth(authReq.State)
assert.NoError(t, err)
oidcMgr.removePendingAuth(authReq.State)
require.Len(t, oidcMgr.pendingAuths, 0)
authReq.IssueAt = util.GetTimeAsMsSinceEpoch(time.Now().Add(-61 * time.Second))
oidcMgr.addPendingAuth(authReq)
require.Len(t, oidcMgr.pendingAuths, 1)
_, err = oidcMgr.getPendingAuth(authReq.State)
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "too old")
}
oidcMgr.checkCleanup()
require.Len(t, oidcMgr.pendingAuths, 1)
oidcMgr.lastCleanup = time.Now().Add(-1 * time.Hour)
oidcMgr.checkCleanup()
require.Len(t, oidcMgr.pendingAuths, 0)
assert.True(t, oidcMgr.lastCleanup.After(time.Now().Add(-10*time.Second)))
token := oidcToken{
AccessToken: xid.New().String(),
Nonce: xid.New().String(),
SessionID: xid.New().String(),
Cookie: xid.New().String(),
Username: xid.New().String(),
Role: "admin",
Permissions: []string{dataprovider.PermAdminAny},
}
require.Len(t, oidcMgr.tokens, 0)
oidcMgr.addToken(token)
require.Len(t, oidcMgr.tokens, 1)
_, err = oidcMgr.getToken(xid.New().String())
assert.Error(t, err)
storedToken, err := oidcMgr.getToken(token.Cookie)
assert.NoError(t, err)
assert.Greater(t, storedToken.UsedAt, int64(0))
token.UsedAt = storedToken.UsedAt
assert.Equal(t, token, storedToken)
// the usage will not be updated, it is recent
oidcMgr.updateTokenUsage(storedToken)
storedToken, err = oidcMgr.getToken(token.Cookie)
assert.NoError(t, err)
assert.Equal(t, token, storedToken)
usedAt := util.GetTimeAsMsSinceEpoch(time.Now().Add(-5 * time.Minute))
storedToken.UsedAt = usedAt
oidcMgr.tokens[token.Cookie] = storedToken
storedToken, err = oidcMgr.getToken(token.Cookie)
assert.NoError(t, err)
assert.Equal(t, usedAt, storedToken.UsedAt)
token.UsedAt = storedToken.UsedAt
assert.Equal(t, token, storedToken)
oidcMgr.updateTokenUsage(storedToken)
storedToken, err = oidcMgr.getToken(token.Cookie)
assert.NoError(t, err)
assert.Greater(t, storedToken.UsedAt, usedAt)
token.UsedAt = storedToken.UsedAt
assert.Equal(t, token, storedToken)
oidcMgr.removeToken(xid.New().String())
require.Len(t, oidcMgr.tokens, 1)
oidcMgr.removeToken(token.Cookie)
require.Len(t, oidcMgr.tokens, 0)
oidcMgr.addToken(token)
usedAt = util.GetTimeAsMsSinceEpoch(time.Now().Add(-6 * time.Hour))
token.UsedAt = usedAt
oidcMgr.tokens[token.Cookie] = token
newToken := oidcToken{
Cookie: xid.New().String(),
}
oidcMgr.lastCleanup = time.Now().Add(-1 * time.Hour)
oidcMgr.addToken(newToken)
require.Len(t, oidcMgr.tokens, 1)
_, err = oidcMgr.getToken(token.Cookie)
assert.Error(t, err)
_, err = oidcMgr.getToken(newToken.Cookie)
assert.NoError(t, err)
oidcMgr.removeToken(newToken.Cookie)
require.Len(t, oidcMgr.tokens, 0)
}

View file

@ -141,21 +141,53 @@ func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, error string)
StaticURL: webStaticFilesPath,
}
if s.binding.showAdminLoginURL() {
data.AltLoginURL = webLoginPath
data.AltLoginURL = webAdminLoginPath
}
if smtp.IsEnabled() {
data.ForgotPwdURL = webClientForgotPwdPath
}
if s.binding.OIDC.isEnabled() {
data.OpenIDLoginURL = webClientOIDCLoginPath
}
renderClientTemplate(w, templateClientLogin, data)
}
func (s *httpdServer) handleWebClientLogout(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
c := jwtTokenClaims{}
c.removeCookie(w, r, webBaseClientPath)
s.logoutOIDCUser(w, r)
http.Redirect(w, r, webClientLoginPath, http.StatusFound)
}
func (s *httpdServer) handleWebClientChangePwdPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
err := r.ParseForm()
if err != nil {
renderClientChangePasswordPage(w, r, err.Error())
return
}
if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
renderClientForbiddenPage(w, r, err.Error())
return
}
err = doChangeUserPassword(r, r.Form.Get("current_password"), r.Form.Get("new_password1"),
r.Form.Get("new_password2"))
if err != nil {
renderClientChangePasswordPage(w, r, err.Error())
return
}
s.handleWebClientLogout(w, r)
}
func (s *httpdServer) handleClientWebLogin(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
if !dataprovider.HasAdmin() {
http.Redirect(w, r, webAdminSetupPath, http.StatusFound)
return
}
s.renderClientLoginPage(w, "")
s.renderClientLoginPage(w, getFlashMessage(w, r))
}
func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Request) {
@ -470,7 +502,7 @@ func (s *httpdServer) handleWebAdminLoginPost(w http.ResponseWriter, r *http.Req
func (s *httpdServer) renderAdminLoginPage(w http.ResponseWriter, error string) {
data := loginPage{
CurrentURL: webLoginPath,
CurrentURL: webAdminLoginPath,
Version: version.Get().Version,
Error: error,
CSRFToken: createCSRFToken(),
@ -482,6 +514,9 @@ func (s *httpdServer) renderAdminLoginPage(w http.ResponseWriter, error string)
if smtp.IsEnabled() {
data.ForgotPwdURL = webAdminForgotPwdPath
}
if s.binding.OIDC.hasRoles() {
data.OpenIDLoginURL = webAdminOIDCLoginPath
}
renderAdminTemplate(w, templateLogin, data)
}
@ -491,7 +526,36 @@ func (s *httpdServer) handleWebAdminLogin(w http.ResponseWriter, r *http.Request
http.Redirect(w, r, webAdminSetupPath, http.StatusFound)
return
}
s.renderAdminLoginPage(w, "")
s.renderAdminLoginPage(w, getFlashMessage(w, r))
}
func (s *httpdServer) handleWebAdminLogout(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
c := jwtTokenClaims{}
c.removeCookie(w, r, webBaseAdminPath)
s.logoutOIDCUser(w, r)
http.Redirect(w, r, webAdminLoginPath, http.StatusFound)
}
func (s *httpdServer) handleWebAdminChangePwdPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
err := r.ParseForm()
if err != nil {
renderChangePasswordPage(w, r, err.Error())
return
}
if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
renderForbiddenPage(w, r, err.Error())
return
}
err = doChangeAdminPassword(r, r.Form.Get("current_password"), r.Form.Get("new_password1"),
r.Form.Get("new_password2"))
if err != nil {
renderChangePasswordPage(w, r, err.Error())
return
}
s.handleWebAdminLogout(w, r)
}
func (s *httpdServer) handleWebAdminPasswordResetPost(w http.ResponseWriter, r *http.Request) {
@ -794,6 +858,9 @@ func (s *httpdServer) generateAndSendToken(w http.ResponseWriter, r *http.Reques
}
func (s *httpdServer) checkCookieExpiration(w http.ResponseWriter, r *http.Request) {
if _, ok := r.Context().Value(oidcTokenKey).(string); ok {
return
}
token, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
return
@ -857,7 +924,7 @@ func (s *httpdServer) refreshAdminToken(w http.ResponseWriter, r *http.Request,
func (s *httpdServer) updateContextFromCookie(r *http.Request) *http.Request {
token, _, err := jwtauth.FromContext(r.Context())
if token == nil || err != nil {
_, err = r.Cookie("jwt")
_, err = r.Cookie(jwtCookieKey)
if err != nil {
return r
}
@ -1182,6 +1249,9 @@ func (s *httpdServer) initializeRouter() {
router.Use(compressor.Handler)
fileServer(router, webStaticFilesPath, http.Dir(s.staticFilesPath))
})
if s.binding.OIDC.isEnabled() {
s.router.Get(webOIDCRedirectPath, s.handleOIDCRedirect)
}
if s.enableWebClient {
s.router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
@ -1194,21 +1264,29 @@ func (s *httpdServer) initializeRouter() {
} else {
s.router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
s.redirectToWebPath(w, r, webLoginPath)
s.redirectToWebPath(w, r, webAdminLoginPath)
})
s.router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
s.redirectToWebPath(w, r, webLoginPath)
s.redirectToWebPath(w, r, webAdminLoginPath)
})
}
}
s.setupWebClientRoutes()
s.setupWebAdminRoutes()
}
func (s *httpdServer) setupWebClientRoutes() {
if s.enableWebClient {
s.router.Get(webBaseClientPath, func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
http.Redirect(w, r, webClientLoginPath, http.StatusFound)
})
s.router.Get(webClientLoginPath, s.handleClientWebLogin)
if s.binding.OIDC.isEnabled() {
s.router.Get(webClientOIDCLoginPath, s.handleWebClientOIDCLogin)
}
s.router.Post(webClientLoginPath, s.handleWebClientLoginPost)
s.router.Get(webClientForgotPwdPath, handleWebClientForgotPwd)
s.router.Post(webClientForgotPwdPath, handleWebClientForgotPwdPost)
@ -1234,10 +1312,13 @@ func (s *httpdServer) initializeRouter() {
s.router.Post(webClientPubSharesPath+"/{id}/{name}", uploadFileToShare)
s.router.Group(func(router chi.Router) {
router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie))
if s.binding.OIDC.isEnabled() {
router.Use(s.oidcTokenAuthenticator(tokenAudienceWebClient))
}
router.Use(jwtauth.Verify(s.tokenAuth, tokenFromContext, jwtauth.TokenFromCookie))
router.Use(jwtAuthenticatorWebClient)
router.Get(webClientLogoutPath, handleWebClientLogout)
router.Get(webClientLogoutPath, s.handleWebClientLogout)
router.With(s.refreshCookie).Get(webClientFilesPath, s.handleClientGetFiles)
router.With(s.refreshCookie).Get(webClientViewPDFPath, handleClientViewPDF)
router.With(s.refreshCookie, verifyCSRFHeader).Get(webClientFilePath, getUserFile)
@ -1256,12 +1337,12 @@ func (s *httpdServer) initializeRouter() {
router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
Delete(webClientDirsPath, deleteUserDir)
router.With(s.refreshCookie).Get(webClientDownloadZipPath, handleWebClientDownloadZip)
router.With(s.refreshCookie).Get(webClientProfilePath, handleClientGetProfile)
router.Post(webClientProfilePath, handleWebClientProfilePost)
router.With(s.refreshCookie, requireBuiltinLogin).Get(webClientProfilePath, handleClientGetProfile)
router.With(requireBuiltinLogin).Post(webClientProfilePath, handleWebClientProfilePost)
router.With(checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
Get(webChangeClientPwdPath, handleWebClientChangePwd)
router.With(checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
Post(webChangeClientPwdPath, handleWebClientChangePwdPost)
Post(webChangeClientPwdPath, s.handleWebClientChangePwdPost)
router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), s.refreshCookie).
Get(webClientMFAPath, handleWebClientMFA)
router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
@ -1288,14 +1369,19 @@ func (s *httpdServer) initializeRouter() {
Delete(webClientSharePath+"/{id}", deleteShare)
})
}
}
func (s *httpdServer) setupWebAdminRoutes() {
if s.enableWebAdmin {
s.router.Get(webBaseAdminPath, func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
s.redirectToWebPath(w, r, webLoginPath)
s.redirectToWebPath(w, r, webAdminLoginPath)
})
s.router.Get(webLoginPath, s.handleWebAdminLogin)
s.router.Post(webLoginPath, s.handleWebAdminLoginPost)
s.router.Get(webAdminLoginPath, s.handleWebAdminLogin)
if s.binding.OIDC.hasRoles() {
s.router.Get(webAdminOIDCLoginPath, s.handleWebAdminOIDCLogin)
}
s.router.Post(webAdminLoginPath, s.handleWebAdminLoginPost)
s.router.Get(webAdminSetupPath, handleWebAdminSetupGet)
s.router.Post(webAdminSetupPath, s.handleWebAdminSetupPost)
s.router.Get(webAdminForgotPwdPath, handleWebAdminForgotPwd)
@ -1316,21 +1402,24 @@ func (s *httpdServer) initializeRouter() {
Post(webAdminTwoFactorRecoveryPath, s.handleWebAdminTwoFactorRecoveryPost)
s.router.Group(func(router chi.Router) {
router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie))
if s.binding.OIDC.isEnabled() {
router.Use(s.oidcTokenAuthenticator(tokenAudienceWebAdmin))
}
router.Use(jwtauth.Verify(s.tokenAuth, tokenFromContext, jwtauth.TokenFromCookie))
router.Use(jwtAuthenticatorWebAdmin)
router.Get(webLogoutPath, handleWebLogout)
router.With(s.refreshCookie).Get(webAdminProfilePath, handleWebAdminProfile)
router.Post(webAdminProfilePath, handleWebAdminProfilePost)
router.With(s.refreshCookie).Get(webChangeAdminPwdPath, handleWebAdminChangePwd)
router.Post(webChangeAdminPwdPath, handleWebAdminChangePwdPost)
router.Get(webLogoutPath, s.handleWebAdminLogout)
router.With(s.refreshCookie, requireBuiltinLogin).Get(webAdminProfilePath, handleWebAdminProfile)
router.With(requireBuiltinLogin).Post(webAdminProfilePath, handleWebAdminProfilePost)
router.With(s.refreshCookie, requireBuiltinLogin).Get(webChangeAdminPwdPath, handleWebAdminChangePwd)
router.With(requireBuiltinLogin).Post(webChangeAdminPwdPath, s.handleWebAdminChangePwdPost)
router.With(s.refreshCookie).Get(webAdminMFAPath, handleWebAdminMFA)
router.With(verifyCSRFHeader).Post(webAdminTOTPGeneratePath, generateTOTPSecret)
router.With(verifyCSRFHeader).Post(webAdminTOTPValidatePath, validateTOTPPasscode)
router.With(verifyCSRFHeader).Post(webAdminTOTPSavePath, saveTOTPConfig)
router.With(verifyCSRFHeader, s.refreshCookie).Get(webAdminRecoveryCodesPath, getRecoveryCodes)
router.With(verifyCSRFHeader).Post(webAdminRecoveryCodesPath, generateRecoveryCodes)
router.With(s.refreshCookie, requireBuiltinLogin).Get(webAdminMFAPath, handleWebAdminMFA)
router.With(verifyCSRFHeader, requireBuiltinLogin).Post(webAdminTOTPGeneratePath, generateTOTPSecret)
router.With(verifyCSRFHeader, requireBuiltinLogin).Post(webAdminTOTPValidatePath, validateTOTPPasscode)
router.With(verifyCSRFHeader, requireBuiltinLogin).Post(webAdminTOTPSavePath, saveTOTPConfig)
router.With(verifyCSRFHeader, requireBuiltinLogin, s.refreshCookie).Get(webAdminRecoveryCodesPath, getRecoveryCodes)
router.With(verifyCSRFHeader, requireBuiltinLogin).Post(webAdminRecoveryCodesPath, generateRecoveryCodes)
router.With(checkPerm(dataprovider.PermAdminViewUsers), s.refreshCookie).
Get(webUsersPath, handleGetWebUsers)

View file

@ -31,6 +31,7 @@ type loginPage struct {
StaticURL string
AltLoginURL string
ForgotPwdURL string
OpenIDLoginURL string
}
type twoFactorPage struct {

View file

@ -115,6 +115,7 @@ type basePage struct {
Version string
CSRFToken string
HasDefender bool
HasExternalLogin bool
LoggedAdmin *dataprovider.Admin
}
@ -405,6 +406,7 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage {
Version: version.GetAsString(),
LoggedAdmin: getAdminFromToken(r),
HasDefender: common.Config.DefenderConfig.Enabled,
HasExternalLogin: isLoggedInWithOIDC(r),
CSRFToken: csrfToken,
}
}
@ -1406,26 +1408,6 @@ func handleWebAdminChangePwd(w http.ResponseWriter, r *http.Request) {
renderChangePasswordPage(w, r, "")
}
func handleWebAdminChangePwdPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
err := r.ParseForm()
if err != nil {
renderChangePasswordPage(w, r, err.Error())
return
}
if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
renderForbiddenPage(w, r, err.Error())
return
}
err = doChangeAdminPassword(r, r.Form.Get("current_password"), r.Form.Get("new_password1"),
r.Form.Get("new_password2"))
if err != nil {
renderChangePasswordPage(w, r, err.Error())
return
}
handleWebLogout(w, r)
}
func handleWebAdminProfilePost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
err := r.ParseForm()
@ -1459,14 +1441,6 @@ func handleWebAdminProfilePost(w http.ResponseWriter, r *http.Request) {
"Your profile has been successfully updated")
}
func handleWebLogout(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
c := jwtTokenClaims{}
c.removeCookie(w, r, webBaseAdminPath)
http.Redirect(w, r, webLoginPath, http.StatusFound)
}
func handleWebMaintenance(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
renderMaintenancePage(w, r, "")
@ -1555,7 +1529,7 @@ func handleGetWebAdmins(w http.ResponseWriter, r *http.Request) {
func handleWebAdminSetupGet(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
if dataprovider.HasAdmin() {
http.Redirect(w, r, webLoginPath, http.StatusFound)
http.Redirect(w, r, webAdminLoginPath, http.StatusFound)
return
}
renderAdminSetupPage(w, r, "", "")

View file

@ -94,6 +94,7 @@ type baseClientPage struct {
ProfileTitle string
Version string
CSRFToken string
HasExternalLogin bool
LoggedUser *dataprovider.User
}
@ -318,6 +319,7 @@ func getBaseClientPageData(title, currentURL string, r *http.Request) baseClient
ProfileTitle: pageClientProfileTitle,
Version: fmt.Sprintf("%v-%v", v.Version, v.CommitHash),
CSRFToken: csrfToken,
HasExternalLogin: isLoggedInWithOIDC(r),
LoggedUser: getUserFromToken(r),
}
}
@ -543,14 +545,6 @@ func renderClientChangePasswordPage(w http.ResponseWriter, r *http.Request, erro
renderClientTemplate(w, templateClientChangePwd, data)
}
func handleWebClientLogout(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
c := jwtTokenClaims{}
c.removeCookie(w, r, webBaseClientPath)
http.Redirect(w, r, webClientLoginPath, http.StatusFound)
}
func handleWebClientDownloadZip(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
claims, err := getTokenClaims(r)
@ -1042,26 +1036,6 @@ func handleWebClientChangePwd(w http.ResponseWriter, r *http.Request) {
renderClientChangePasswordPage(w, r, "")
}
func handleWebClientChangePwdPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
err := r.ParseForm()
if err != nil {
renderClientChangePasswordPage(w, r, err.Error())
return
}
if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
renderClientForbiddenPage(w, r, err.Error())
return
}
err = doChangeUserPassword(r, r.Form.Get("current_password"), r.Form.Get("new_password1"),
r.Form.Get("new_password2"))
if err != nil {
renderClientChangePasswordPage(w, r, err.Error())
return
}
handleWebClientLogout(w, r)
}
func handleWebClientProfilePost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
err := r.ParseForm()

View file

@ -1,6 +1,6 @@
#!/bin/bash
NFPM_VERSION=2.12.1
NFPM_VERSION=2.13.0
NFPM_ARCH=${NFPM_ARCH:-amd64}
if [ -z ${SFTPGO_VERSION} ]
then

View file

@ -216,7 +216,15 @@
"proxy_allowed": [],
"hide_login_url": 0,
"render_openapi": true,
"web_client_integrations": []
"web_client_integrations": [],
"oidc": {
"client_id": "",
"client_secret": "",
"config_url": "",
"redirect_base_url": "",
"username_field": "",
"role_field": ""
}
}
],
"templates_path": "templates",

View file

@ -167,6 +167,7 @@
</a>
<!-- Dropdown - User Information -->
<div class="dropdown-menu dropdown-menu-right shadow animated--grow-in" aria-labelledby="userDropdown">
{{if not .HasExternalLogin}}
<a class="dropdown-item" href="{{.ProfileURL}}">
<i class="fas fa-user fa-sm fa-fw mr-2 text-gray-400"></i>
Profile
@ -182,6 +183,7 @@
</a>
{{end}}
<div class="dropdown-divider"></div>
{{end}}
<a class="dropdown-item" href="#" data-toggle="modal" data-target="#logoutModal">
<i class="fas fa-sign-out-alt fa-sm fa-fw mr-2 text-gray-400"></i>
Logout

View file

@ -30,6 +30,12 @@
<button type="submit" class="btn btn-primary btn-user-custom btn-block">
Login
</button>
{{if .OpenIDLoginURL}}
<hr>
<a href="{{.OpenIDLoginURL}}" class="btn btn-secondary btn-user-custom btn-block">
Login with OpenID
</a>
{{end}}
</form>
{{if .AltLoginURL}}
<hr>

View file

@ -85,12 +85,13 @@
<span>{{.SharesTitle}}</span></a>
</li>
{{end}}
{{if not .HasExternalLogin}}
<li class="nav-item {{if eq .CurrentURL .ProfileURL}}active{{end}}">
<a class="nav-link" href="{{.ProfileURL}}">
<i class="fas fa-user"></i>
<span>{{.ProfileTitle}}</span></a>
</li>
{{end}}
{{if .LoggedUser.CanManageMFA}}
<li class="nav-item {{if eq .CurrentURL .MFAURL}}active{{end}}">
<a class="nav-link" href="{{.MFAURL}}">

View file

@ -27,6 +27,12 @@
<button type="submit" class="btn btn-primary btn-user-custom btn-block">
Login
</button>
{{if .OpenIDLoginURL}}
<hr>
<a href="{{.OpenIDLoginURL}}" class="btn btn-secondary btn-user-custom btn-block">
Login with OpenID
</a>
{{end}}
</form>
{{if .AltLoginURL}}
<hr>

View file

@ -4,7 +4,7 @@ go 1.17
require (
github.com/hashicorp/go-plugin v1.4.3
github.com/sftpgo/sdk v0.0.0-20220115154521-b31d253a0bea
github.com/sftpgo/sdk v0.1.0
)
require (
@ -17,11 +17,11 @@ require (
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/oklog/run v1.1.0 // indirect
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d // indirect
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/genproto v0.0.0-20220118154757-00ab72f36ad5 // indirect
google.golang.org/grpc v1.43.0 // indirect
google.golang.org/genproto v0.0.0-20220211171837-173942840c17 // indirect
google.golang.org/grpc v1.44.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)

View file

@ -86,8 +86,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/sftpgo/sdk v0.0.0-20220115154521-b31d253a0bea h1:ouwL3x9tXiAXIhdXtJGONd905f1dBLu3HhfFoaTq24k=
github.com/sftpgo/sdk v0.0.0-20220115154521-b31d253a0bea/go.mod h1:Bhgac6kiwIziILXLzH4wepT8lQXyhF83poDXqZorN6Q=
github.com/sftpgo/sdk v0.1.0 h1:t94VfxsmNbCYLYRDr3x/UTwSbFFtL9DJ171zkQ3MchQ=
github.com/sftpgo/sdk v0.1.0/go.mod h1:Bhgac6kiwIziILXLzH4wepT8lQXyhF83poDXqZorN6Q=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
@ -112,8 +112,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220105145211-5b0dc2dfae98/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d h1:1n1fc535VhN8SYtD4cDUyNlfpAF2ROMM9+11equK3hs=
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -132,12 +132,14 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -160,8 +162,8 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220118154757-00ab72f36ad5 h1:zzNejm+EgrbLfDZ6lu9Uud2IVvHySPl8vQzf04laR5Q=
google.golang.org/genproto v0.0.0-20220118154757-00ab72f36ad5/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220211171837-173942840c17 h1:2X+CNIheCutWRyKRte8szGxrE5ggtV4U+NKAbh/oLhg=
google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
@ -171,8 +173,9 @@ google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.43.0 h1:Eeu7bZtDZ2DpRCsLhUlcrLnvYaMK1Gz86a+hMVvELmM=
google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc v1.44.0 h1:weqSxi/TMs1SqFRMHCtBgXRs8k3X39QIDEZ0pRcttUg=
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=