diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index cdd7c899..da950322 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -16,7 +16,7 @@ jobs: upload-coverage: [true] include: - go: 1.17 - os: windows-latest + os: windows-2019 upload-coverage: false steps: diff --git a/DCO b/DCO new file mode 100644 index 00000000..49b8cb05 --- /dev/null +++ b/DCO @@ -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. diff --git a/README.md b/README.md index 27150d70..529ecd13 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/config.go b/config/config.go index b8a66af5..03990df5 100644 --- a/config/config.go +++ b/config/config.go @@ -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 diff --git a/config/config_test.go b/config/config_test.go index b5ffb791..49149168 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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) { diff --git a/docs/full-configuration.md b/docs/full-configuration.md index de20cfd2..e09d98bb 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -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 `. 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 `. 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`. diff --git a/docs/oidc.md b/docs/oidc.md new file mode 100644 index 00000000..383b10a2 --- /dev/null +++ b/docs/oidc.md @@ -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" +} +``` diff --git a/go.mod b/go.mod index 49aa178a..0e9e3170 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 1dd963d5..fbf56f2c 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/httpd/auth_utils.go b/httpd/auth_utils.go index c1c923c2..2b13855b 100644 --- a/httpd/auth_utils.go +++ b/httpd/auth_utils.go @@ -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) - 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 + for _, fn := range findTokenFns { + token := fn(r) + if token != "" { + isTokenFound = true + if _, ok := invalidatedJWTTokens.Load(token); ok { + return true + } } } + return !isTokenFound } diff --git a/httpd/flash.go b/httpd/flash.go new file mode 100644 index 00000000..d17e6705 --- /dev/null +++ b/httpd/flash.go @@ -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) +} diff --git a/httpd/flash_test.go b/httpd/flash_test.go new file mode 100644 index 00000000..81297564 --- /dev/null +++ b/httpd/flash_test.go @@ -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) +} diff --git a/httpd/httpd.go b/httpd/httpd.go index af927c7f..7933c625 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -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,7 +287,9 @@ 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"` - allowHeadersFrom []func(net.IP) bool + // 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 } func (b *Binding) checkWebClientIntegrations() { @@ -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) diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 9814f02a..3afec763 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -198,6 +198,7 @@ AAAEA0E24gi8ab/XRSvJ85TGZJMe6HVmwxSG4ExPfTMwwe2n5EHjI1NnP2Yc6RrDBSJs11 sftpPkeyFingerprint = "SHA256:QVQ06XHZZbYZzqfrsZcf3Yozy2WTnqQPeLOkcJCdbP0" redactedSecret = "[**redacted**]" osWindows = "windows" + oidcMockAddr = "127.0.0.1:11111" ) var ( @@ -313,6 +314,8 @@ func TestMain(m *testing.M) { preActionPath = filepath.Join(homeBasePath, "preaction.sh") httpConfig := config.GetHTTPConfig() + httpConfig.RetryMax = 1 + httpConfig.Timeout = 5 httpConfig.Initialize(configDir) //nolint:errcheck kmsConfig := config.GetKMSConfig() err = kmsConfig.Initialize() @@ -369,6 +372,7 @@ func TestMain(m *testing.M) { }() startSMTPServer() + startOIDCMockServer() waitTCPListening(httpdConf.Bindings[0].GetAddress()) waitTCPListening(sftpdConf.Bindings[0].GetAddress()) @@ -471,6 +475,21 @@ func TestInitialization(t *testing.T) { httpdConf.Bindings[0].ClientAuthType = 1 err = httpdConf.Initialize(configDir) assert.Error(t, err) + + httpdConf.Bindings[0].OIDC = httpd.OIDC{ + ClientID: "123", + ClientSecret: "secret", + ConfigURL: "http://127.0.0.1:11111", + } + err = httpdConf.Initialize(configDir) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "oidc") + } + httpdConf.Bindings[0].OIDC.UsernameField = "preferred_username" + err = httpdConf.Initialize(configDir) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "oidc") + } } func TestBasicUserHandling(t *testing.T) { @@ -16635,6 +16654,26 @@ func TestStaticFilesMock(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) } +func startOIDCMockServer() { + go func() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "OK\n") + }) + http.HandleFunc("/auth/realms/sftpgo/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"issuer":"http://127.0.0.1:11111/auth/realms/sftpgo","authorization_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/auth","token_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/token","introspection_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/token/introspect","userinfo_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/userinfo","end_session_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/logout","frontchannel_logout_session_supported":true,"frontchannel_logout_supported":true,"jwks_uri":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/certs","check_session_iframe":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/login-status-iframe.html","grant_types_supported":["authorization_code","implicit","refresh_token","password","client_credentials","urn:ietf:params:oauth:grant-type:device_code","urn:openid:params:grant-type:ciba"],"response_types_supported":["code","none","id_token","token","id_token token","code id_token","code token","code id_token token"],"subject_types_supported":["public","pairwise"],"id_token_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"id_token_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"id_token_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"userinfo_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"request_object_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"request_object_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"request_object_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"response_modes_supported":["query","fragment","form_post","query.jwt","fragment.jwt","form_post.jwt","jwt"],"registration_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/clients-registrations/openid-connect","token_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"token_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"introspection_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"authorization_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"claims_supported":["aud","sub","iss","auth_time","name","given_name","family_name","preferred_username","email","acr"],"claim_types_supported":["normal"],"claims_parameter_supported":true,"scopes_supported":["openid","phone","email","web-origins","offline_access","microprofile-jwt","profile","address","roles"],"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"code_challenge_methods_supported":["plain","S256"],"tls_client_certificate_bound_access_tokens":true,"revocation_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/revoke","revocation_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"device_authorization_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/auth/device","backchannel_token_delivery_modes_supported":["poll","ping"],"backchannel_authentication_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/ext/ciba/auth","backchannel_authentication_request_signing_alg_values_supported":["PS384","ES384","RS384","ES256","RS256","ES512","PS256","PS512","RS512"],"require_pushed_authorization_requests":false,"pushed_authorization_request_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/ext/par/request","mtls_endpoint_aliases":{"token_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/token","revocation_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/revoke","introspection_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/token/introspect","device_authorization_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/auth/device","registration_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/clients-registrations/openid-connect","userinfo_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/userinfo","pushed_authorization_request_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/ext/par/request","backchannel_authentication_endpoint":"http://127.0.0.1:11111/auth/realms/sftpgo/protocol/openid-connect/ext/ciba/auth"}}`) + }) + http.HandleFunc("/404", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "Not found\n") + }) + if err := http.ListenAndServe(oidcMockAddr, nil); err != nil { + logger.ErrorToConsole("could not start HTTP notification server: %v", err) + os.Exit(1) + } + }() + waitTCPListening(oidcMockAddr) +} + func waitForUsersQuotaScan(t *testing.T, token string) { for { var scans []common.ActiveQuotaScan diff --git a/httpd/internal_test.go b/httpd/internal_test.go index e46d7999..f3650982 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -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) { diff --git a/httpd/middleware.go b/httpd/middleware.go index 5aa26ab4..ab7798cd 100644 --- a/httpd/middleware.go +++ b/httpd/middleware.go @@ -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) { diff --git a/httpd/oidc.go b/httpd/oidc.go new file mode 100644 index 00000000..8435e48a --- /dev/null +++ b/httpd/oidc.go @@ -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 +} diff --git a/httpd/oidc_test.go b/httpd/oidc_test.go new file mode 100644 index 00000000..39cba0f5 --- /dev/null +++ b/httpd/oidc_test.go @@ -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) +} diff --git a/httpd/server.go b/httpd/server.go index 91517980..99275f02 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -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) diff --git a/httpd/web.go b/httpd/web.go index 9d3850d9..2b819b11 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -24,13 +24,14 @@ const ( ) type loginPage struct { - CurrentURL string - Version string - Error string - CSRFToken string - StaticURL string - AltLoginURL string - ForgotPwdURL string + CurrentURL string + Version string + Error string + CSRFToken string + StaticURL string + AltLoginURL string + ForgotPwdURL string + OpenIDLoginURL string } type twoFactorPage struct { diff --git a/httpd/webadmin.go b/httpd/webadmin.go index fef566ab..7e885553 100644 --- a/httpd/webadmin.go +++ b/httpd/webadmin.go @@ -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, "", "") diff --git a/httpd/webclient.go b/httpd/webclient.go index edea61e5..1697e9ac 100644 --- a/httpd/webclient.go +++ b/httpd/webclient.go @@ -78,23 +78,24 @@ func isZeroTime(t time.Time) bool { } type baseClientPage struct { - Title string - CurrentURL string - FilesURL string - SharesURL string - ShareURL string - ProfileURL string - ChangePwdURL string - StaticURL string - LogoutURL string - MFAURL string - MFATitle string - FilesTitle string - SharesTitle string - ProfileTitle string - Version string - CSRFToken string - LoggedUser *dataprovider.User + Title string + CurrentURL string + FilesURL string + SharesURL string + ShareURL string + ProfileURL string + ChangePwdURL string + StaticURL string + LogoutURL string + MFAURL string + MFATitle string + FilesTitle string + SharesTitle string + ProfileTitle string + Version string + CSRFToken string + HasExternalLogin bool + LoggedUser *dataprovider.User } type dirMapping struct { @@ -302,23 +303,24 @@ func getBaseClientPageData(title, currentURL string, r *http.Request) baseClient v := version.Get() return baseClientPage{ - Title: title, - CurrentURL: currentURL, - FilesURL: webClientFilesPath, - SharesURL: webClientSharesPath, - ShareURL: webClientSharePath, - ProfileURL: webClientProfilePath, - ChangePwdURL: webChangeClientPwdPath, - StaticURL: webStaticFilesPath, - LogoutURL: webClientLogoutPath, - MFAURL: webClientMFAPath, - MFATitle: pageClient2FATitle, - FilesTitle: pageClientFilesTitle, - SharesTitle: pageClientSharesTitle, - ProfileTitle: pageClientProfileTitle, - Version: fmt.Sprintf("%v-%v", v.Version, v.CommitHash), - CSRFToken: csrfToken, - LoggedUser: getUserFromToken(r), + Title: title, + CurrentURL: currentURL, + FilesURL: webClientFilesPath, + SharesURL: webClientSharesPath, + ShareURL: webClientSharePath, + ProfileURL: webClientProfilePath, + ChangePwdURL: webChangeClientPwdPath, + StaticURL: webStaticFilesPath, + LogoutURL: webClientLogoutPath, + MFAURL: webClientMFAPath, + MFATitle: pageClient2FATitle, + FilesTitle: pageClientFilesTitle, + SharesTitle: pageClientSharesTitle, + 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() diff --git a/pkgs/build.sh b/pkgs/build.sh index ccbe7b3b..06dcb618 100755 --- a/pkgs/build.sh +++ b/pkgs/build.sh @@ -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 diff --git a/sftpgo.json b/sftpgo.json index 7e327c58..66e3604e 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -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", diff --git a/templates/webadmin/base.html b/templates/webadmin/base.html index 608aed57..cc2a6193 100644 --- a/templates/webadmin/base.html +++ b/templates/webadmin/base.html @@ -167,6 +167,7 @@