From d5946da1e21c53491c21b7bd9bdf8e4ca2636b6f Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Tue, 19 Jul 2022 22:25:00 +0200 Subject: [PATCH] OIDC: allow to enable only OIDC login for Web UIs Signed-off-by: Nicola Murino --- .golangci.yml | 2 +- config/config.go | 9 ++- config/config_test.go | 5 ++ dataprovider/bolt.go | 6 +- dataprovider/sqlcommon.go | 14 ++++- docs/full-configuration.md | 1 + docs/oidc.md | 6 +- ftpd/ftpd_test.go | 25 +++----- go.mod | 10 ++-- go.sum | 20 +++---- httpd/httpd.go | 44 ++++++++++++++ httpd/httpd_test.go | 23 ++++++++ httpd/oidc_test.go | 94 ++++++++++++++++++++++++++++++ httpd/server.go | 102 +++++++++++++++++---------------- httpd/web.go | 1 + httpd/webadmin.go | 5 +- sftpgo.json | 1 + templates/webadmin/login.html | 2 + templates/webclient/login.html | 2 + 19 files changed, 281 insertions(+), 91 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 820a03e0..ac545673 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,5 @@ run: - timeout: 5m + timeout: 10m issues-exit-code: 1 tests: true diff --git a/config/config.go b/config/config.go index eeba56fe..6928f4ca 100644 --- a/config/config.go +++ b/config/config.go @@ -99,6 +99,7 @@ var ( Port: 8080, EnableWebAdmin: true, EnableWebClient: true, + EnabledLoginMethods: 0, EnableHTTPS: false, CertificateFile: "", CertificateKeyFile: "", @@ -1623,7 +1624,7 @@ func getHTTPDBindingProxyConfigsFromEnv(idx int, binding *httpd.Binding) bool { return isSet } -func getHTTPDBindingFromEnv(idx int) { +func getHTTPDBindingFromEnv(idx int) { //nolint:gocyclo binding := getDefaultHTTPBinding(idx) isSet := false @@ -1663,6 +1664,12 @@ func getHTTPDBindingFromEnv(idx int) { isSet = true } + enabledLoginMethods, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__ENABLED_LOGIN_METHODS", idx)) + if ok { + binding.EnabledLoginMethods = int(enabledLoginMethods) + isSet = true + } + renderOpenAPI, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__RENDER_OPENAPI", idx)) if ok { binding.RenderOpenAPI = renderOpenAPI diff --git a/config/config_test.go b/config/config_test.go index 5d6211e3..510a2bd5 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1032,6 +1032,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { os.Setenv("SFTPGO_HTTPD__BINDINGS__2__PORT", "9000") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_ADMIN", "0") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_CLIENT", "0") + os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLED_LOGIN_METHODS", "3") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__RENDER_OPENAPI", "0") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_HTTPS", "1 ") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__MIN_TLS_VERSION", "13") @@ -1099,6 +1100,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__MIN_TLS_VERSION") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_ADMIN") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_CLIENT") + os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLED_LOGIN_METHODS") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__RENDER_OPENAPI") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_AUTH_TYPE") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES") @@ -1159,6 +1161,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { require.Equal(t, 12, bindings[0].MinTLSVersion) require.True(t, bindings[0].EnableWebAdmin) require.True(t, bindings[0].EnableWebClient) + require.Equal(t, 0, bindings[0].EnabledLoginMethods) require.True(t, bindings[0].RenderOpenAPI) require.Len(t, bindings[0].TLSCipherSuites, 1) require.Empty(t, bindings[0].OIDC.ConfigURL) @@ -1173,6 +1176,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { require.Equal(t, 12, bindings[0].MinTLSVersion) require.True(t, bindings[1].EnableWebAdmin) require.True(t, bindings[1].EnableWebClient) + require.Equal(t, 0, bindings[1].EnabledLoginMethods) require.True(t, bindings[1].RenderOpenAPI) require.Nil(t, bindings[1].TLSCipherSuites) require.Equal(t, 1, bindings[1].HideLoginURL) @@ -1188,6 +1192,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { require.Equal(t, 13, bindings[2].MinTLSVersion) require.False(t, bindings[2].EnableWebAdmin) require.False(t, bindings[2].EnableWebClient) + require.Equal(t, 3, bindings[2].EnabledLoginMethods) require.False(t, bindings[2].RenderOpenAPI) require.Equal(t, 1, bindings[2].ClientAuthType) require.Len(t, bindings[2].TLSCipherSuites, 2) diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index a63b110b..dabbb40b 100644 --- a/dataprovider/bolt.go +++ b/dataprovider/bolt.go @@ -1052,9 +1052,7 @@ func (p *BoltProvider) updateFolder(folder *vfs.BaseVirtualFolder) error { }) } -func (p *BoltProvider) deleteFolderMappings(tx *bolt.Tx, folder vfs.BaseVirtualFolder, usersBucket, - groupsBucket *bolt.Bucket, -) error { +func (p *BoltProvider) deleteFolderMappings(folder vfs.BaseVirtualFolder, usersBucket, groupsBucket *bolt.Bucket) error { for _, username := range folder.Users { var u []byte if u = usersBucket.Get([]byte(username)); u == nil { @@ -1134,7 +1132,7 @@ func (p *BoltProvider) deleteFolder(folder vfs.BaseVirtualFolder) error { if err != nil { return err } - if err = p.deleteFolderMappings(tx, folder, usersBucket, groupsBucket); err != nil { + if err = p.deleteFolderMappings(folder, usersBucket, groupsBucket); err != nil { return err } diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index 5c6d0de2..1a38bb61 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -50,6 +50,7 @@ type sqlQuerier interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) } type sqlScanner interface { @@ -3077,8 +3078,17 @@ func sqlCommonGetDatabaseVersion(dbHandle sqlQuerier, showInitWarn bool) (schema defer cancel() q := getDatabaseVersionQuery() - row := dbHandle.QueryRowContext(ctx, q) - err := row.Scan(&result.Version) + stmt, err := dbHandle.PrepareContext(ctx, q) + if err != nil { + providerLog(logger.LevelError, "error preparing database query %#v: %v", q, err) + if showInitWarn && strings.Contains(err.Error(), sqlTableSchemaVersion) { + logger.WarnToConsole("database query error, did you forgot to run the \"initprovider\" command?") + } + return result, err + } + defer stmt.Close() + row := stmt.QueryRowContext(ctx) + err = row.Scan(&result.Version) return result, err } diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 613a6539..ec723da5 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -255,6 +255,7 @@ The configuration file contains the following sections: - `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`. + - `enabled_login_methods`, integer. Defines the login methods available for the WebAdmin and WebClient UIs. `0` means any configured method: username/password login form and OIDC, if enabled. `1` means OIDC for the WebAdmin UI. The username/password login form will not be available for the WebAdmin UI. `2` means OIDC for the WebClient UI. The username/password login form will not be available for the WebClient UI. You can combine the values. For example `3` means that you can only login using OIDC on both WebClient and WebAdmin UI. Default: `0`. - `enable_https`, boolean. Set to `true` and provide both a certificate and a key file to enable HTTPS connection for this binding. Default `false`. - `certificate_file`, string. Binding specific TLS certificate. This can be an absolute path or a path relative to the config dir. - `certificate_key_file`, string. Binding specific private key matching the above certificate. This can be an absolute path or a path relative to the config dir. If not set the global ones will be used, if any. diff --git a/docs/oidc.md b/docs/oidc.md index fb32d945..3b52bf4a 100644 --- a/docs/oidc.md +++ b/docs/oidc.md @@ -42,8 +42,12 @@ Add the following configuration parameters to the SFTPGo configuration file (or "client_secret": "jRsmE0SWnuZjP7djBqNq0mrf8QN77j2c", "config_url": "http://192.168.1.12:8086/auth/realms/sftpgo", "redirect_base_url": "http://192.168.1.50:8080", + "scopes": [ + "openid", + "profile", + "email" + ], "username_field": "preferred_username", - "scopes": [ "openid", "profile", "email" ], "role_field": "sftpgo_role", "implicit_roles": false, "custom_fields": [] diff --git a/ftpd/ftpd_test.go b/ftpd/ftpd_test.go index faa9f511..d6bfa578 100644 --- a/ftpd/ftpd_test.go +++ b/ftpd/ftpd_test.go @@ -936,7 +936,7 @@ func TestLoginExternalAuth(t *testing.T) { err = config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() - err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm) + err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u), os.ModePerm) assert.NoError(t, err) providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthScope = 0 @@ -960,7 +960,7 @@ func TestLoginExternalAuth(t *testing.T) { Type: sdk.GroupTypePrimary, }, } - err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm) + err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u), os.ModePerm) assert.NoError(t, err) _, err = getFTPClient(u, true, nil) if !assert.Error(t, err) { @@ -971,7 +971,7 @@ func TestLoginExternalAuth(t *testing.T) { } u.Groups = nil - err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm) + err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u), os.ModePerm) assert.NoError(t, err) u.Username = defaultUsername + "1" client, err = getFTPClient(u, true, nil) @@ -3069,7 +3069,7 @@ func TestExternalAuthWithClientCert(t *testing.T) { err = config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() - err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm) + err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u), os.ModePerm) assert.NoError(t, err) providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthScope = 8 @@ -3495,24 +3495,13 @@ func getTestUserWithHTTPFs() dataprovider.User { return u } -func getExtAuthScriptContent(user dataprovider.User, nonJSONResponse bool, username string) []byte { +func getExtAuthScriptContent(user dataprovider.User) []byte { extAuthContent := []byte("#!/bin/sh\n\n") extAuthContent = append(extAuthContent, []byte(fmt.Sprintf("if test \"$SFTPGO_AUTHD_USERNAME\" = \"%v\"; then\n", user.Username))...) - if len(username) > 0 { - user.Username = username - } u, _ := json.Marshal(user) - if nonJSONResponse { - extAuthContent = append(extAuthContent, []byte("echo 'text response'\n")...) - } else { - extAuthContent = append(extAuthContent, []byte(fmt.Sprintf("echo '%v'\n", string(u)))...) - } + extAuthContent = append(extAuthContent, []byte(fmt.Sprintf("echo '%v'\n", string(u)))...) extAuthContent = append(extAuthContent, []byte("else\n")...) - if nonJSONResponse { - extAuthContent = append(extAuthContent, []byte("echo 'text response'\n")...) - } else { - extAuthContent = append(extAuthContent, []byte("echo '{\"username\":\"\"}'\n")...) - } + extAuthContent = append(extAuthContent, []byte("echo '{\"username\":\"\"}'\n")...) extAuthContent = append(extAuthContent, []byte("fi\n")...) return extAuthContent } diff --git a/go.mod b/go.mod index b4df027f..fe05be29 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.27.1 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.13 github.com/aws/aws-sdk-go-v2/service/sts v1.16.9 - github.com/cockroachdb/cockroach-go/v2 v2.2.14 + github.com/cockroachdb/cockroach-go/v2 v2.2.15 github.com/coreos/go-oidc/v3 v3.2.0 github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001 github.com/fclairamb/ftpserverlib v0.18.1-0.20220515214847-f96d31ec626e @@ -53,7 +53,7 @@ require ( github.com/rs/zerolog v1.27.0 github.com/sftpgo/sdk v0.1.2-0.20220611083241-b653555f7f4d github.com/shirou/gopsutil/v3 v3.22.6 - github.com/spf13/afero v1.9.0 + github.com/spf13/afero v1.9.2 github.com/spf13/cobra v1.5.0 github.com/spf13/viper v1.12.0 github.com/stretchr/testify v1.8.0 @@ -67,7 +67,7 @@ require ( gocloud.dev v0.25.0 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d golang.org/x/net v0.0.0-20220708220712-1185a9018129 - golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0 + golang.org/x/oauth2 v0.0.0-20220718184931-c8730f7fcb92 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 golang.org/x/time v0.0.0-20220609170525-579cf78fd858 google.golang.org/api v0.87.0 @@ -112,7 +112,7 @@ require ( github.com/googleapis/go-type-adapters v1.0.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 // indirect + github.com/hashicorp/yamux v0.1.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/klauspost/cpuid/v2 v2.1.0 // indirect @@ -155,7 +155,7 @@ require ( golang.org/x/tools v0.1.11 // indirect golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220715211116-798f69b842b9 // indirect + google.golang.org/genproto v0.0.0-20220718134204-073382fd740c // indirect google.golang.org/grpc v1.48.0 // indirect google.golang.org/protobuf v1.28.0 // indirect gopkg.in/ini.v1 v1.66.6 // indirect diff --git a/go.sum b/go.sum index a04a99de..974b3cd1 100644 --- a/go.sum +++ b/go.sum @@ -233,8 +233,8 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/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.14 h1:wUJwq9OgsvICHwFgVc5n9ooF+AAyDhKgi+be5uEEYm8= -github.com/cockroachdb/cockroach-go/v2 v2.2.14/go.mod h1:xZ2VHjUEb/cySv0scXBx7YsBnHtLHkR1+w/w73b5i3M= +github.com/cockroachdb/cockroach-go/v2 v2.2.15 h1:6TeTC1JLSlHJWJCswWZ7mQyT16kY5mQSs53C2coQISI= +github.com/cockroachdb/cockroach-go/v2 v2.2.15/go.mod h1:xZ2VHjUEb/cySv0scXBx7YsBnHtLHkR1+w/w73b5i3M= github.com/coreos/go-oidc/v3 v3.2.0 h1:2eR2MGR7thBXSQ2YbODlF0fcmgtliLCfr9iX6RW11fc= github.com/coreos/go-oidc/v3 v3.2.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -469,8 +469,8 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 h1:xixZ2bWeofWV68J+x6AzmKuVM/JWCQwkWm6GW/MUR6I= -github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/hashicorp/yamux v0.1.0 h1:DzDIF6Sd7GD2sX0kDFpHAsJMY4L+OfTvtuaQsOYXxzk= +github.com/hashicorp/yamux v0.1.0/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= @@ -719,8 +719,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.9.0 h1:sFSLUHgxdnN32Qy38hK3QkYBFXZj9DKjVjCUCtD7juY= -github.com/spf13/afero v1.9.0/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= +github.com/spf13/afero v1.9.2 h1:j49Hj62F0n+DaZ1dDCvhABaPNSGNkt32oRFxI33IEMw= +github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= @@ -869,8 +869,8 @@ golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= -golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0 h1:VnGaRqoLmqZH/3TMLJwYCEWkR4j1nuIU1U9TvbqsDUw= -golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.0.0-20220718184931-c8730f7fcb92 h1:oVlhw3Oe+1reYsE2Nqu19PDJfLzwdU3QUUrG86rLK68= +golang.org/x/oauth2 v0.0.0-20220718184931-c8730f7fcb92/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1222,8 +1222,8 @@ google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljW google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220715211116-798f69b842b9 h1:1aEQRgZ4Gks2SRAkLzIPpIszRazwVfjSFe1cKc+e0Jg= -google.golang.org/genproto v0.0.0-20220715211116-798f69b842b9/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE= +google.golang.org/genproto v0.0.0-20220718134204-073382fd740c h1:xDUAhRezFnKF6wopxkOfdWYvz2XCiRQzndyDdpwFgbc= +google.golang.org/genproto v0.0.0-20220718134204-073382fd740c/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= diff --git a/httpd/httpd.go b/httpd/httpd.go index 1ec6b4bf..24c20a33 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -19,6 +19,7 @@ package httpd import ( "crypto/sha256" + "errors" "fmt" "net" "net/http" @@ -409,6 +410,15 @@ type Binding struct { // Enable the built-in client interface. // You have to define TemplatesPath and StaticFilesPath for this to work EnableWebClient bool `json:"enable_web_client" mapstructure:"enable_web_client"` + // Defines the login methods available for the WebAdmin and WebClient UIs: + // + // - 0 means any configured method: username/password login form and OIDC, if enabled + // - 1 means OIDC for the WebAdmin UI. The username/password login form will not be available + // - 2 means OIDC for the WebClient UI. The username/password login form will not be available + // + // You can combine the values. For example 3 means that you can only login using OIDC on + // both WebClient and WebAdmin UI. + EnabledLoginMethods int `json:"enabled_login_methods" mapstructure:"enabled_login_methods"` // you also need to provide a certificate for enabling HTTPS EnableHTTPS bool `json:"enable_https" mapstructure:"enable_https"` // Certificate and matching private key for this specific binding, if empty the global @@ -519,6 +529,36 @@ func (b *Binding) IsValid() bool { return false } +func (b *Binding) isWebAdminLoginFormDisabled() bool { + if b.EnableWebAdmin { + if b.EnabledLoginMethods == 0 { + return false + } + return b.EnabledLoginMethods&1 != 0 + } + return false +} + +func (b *Binding) isWebClientLoginFormDisabled() bool { + if b.EnableWebClient { + if b.EnabledLoginMethods == 0 { + return false + } + return b.EnabledLoginMethods&2 != 0 + } + return false +} + +func (b *Binding) checkLoginMethods() error { + if b.isWebAdminLoginFormDisabled() && !b.OIDC.hasRoles() { + return errors.New("no login method available for WebAdmin UI") + } + if b.isWebClientLoginFormDisabled() && !b.OIDC.isEnabled() { + return errors.New("no login method available for WebClient UI") + } + return nil +} + func (b *Binding) showAdminLoginURL() bool { if !b.EnableWebAdmin { return false @@ -782,6 +822,10 @@ func (c *Conf) Initialize(configDir string, isShared int) error { exitChannel <- err return } + if err := b.checkLoginMethods(); err != nil { + exitChannel <- err + return + } server := newHttpdServer(b, staticFilesPath, c.SigningPassphrase, c.Cors, openAPIPath) server.setShared(isShared) diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index eac4b6f0..abb9d044 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -523,6 +523,29 @@ func TestInitialization(t *testing.T) { if assert.Error(t, err) { assert.Contains(t, err.Error(), "oidc") } + httpdConf.Bindings[0].OIDC = httpd.OIDC{} + httpdConf.Bindings[0].EnableWebClient = true + httpdConf.Bindings[0].EnableWebAdmin = true + httpdConf.Bindings[0].EnabledLoginMethods = 1 + err = httpdConf.Initialize(configDir, isShared) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "no login method available for WebAdmin UI") + } + httpdConf.Bindings[0].EnabledLoginMethods = 2 + err = httpdConf.Initialize(configDir, isShared) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "no login method available for WebClient UI") + } + httpdConf.Bindings[0].EnabledLoginMethods = 3 + err = httpdConf.Initialize(configDir, isShared) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "no login method available for WebAdmin UI") + } + httpdConf.Bindings[0].EnableWebAdmin = false + err = httpdConf.Initialize(configDir, isShared) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "no login method available for WebClient UI") + } } func TestBasicUserHandling(t *testing.T) { diff --git a/httpd/oidc_test.go b/httpd/oidc_test.go index 337e750b..3893c374 100644 --- a/httpd/oidc_test.go +++ b/httpd/oidc_test.go @@ -15,11 +15,13 @@ package httpd import ( + "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "reflect" @@ -30,6 +32,7 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/go-chi/jwtauth/v5" + "github.com/lestrrat-go/jwx/jwa" "github.com/rs/xid" "github.com/sftpgo/sdk" "github.com/stretchr/testify/assert" @@ -1160,6 +1163,97 @@ func TestOIDCIsAdmin(t *testing.T) { } } +func TestOIDCWithLoginFormsDisabled(t *testing.T) { + oidcMgr, ok := oidcMgr.(*memoryOIDCManager) + require.True(t, ok) + + server := getTestOIDCServer() + server.binding.OIDC.ImplicitRoles = true + server.binding.EnabledLoginMethods = 3 + server.binding.EnableWebAdmin = true + server.binding.EnableWebClient = true + err := server.binding.OIDC.initialize() + assert.NoError(t, err) + server.initializeRouter() + // login with an admin user + authReq := newOIDCPendingAuth(tokenAudienceWebAdmin) + oidcMgr.addPendingAuth(authReq) + token := &oauth2.Token{ + AccessToken: "1234", + Expiry: time.Now().Add(5 * time.Minute), + } + token = token.WithExtra(map[string]any{ + "id_token": "id_token_val", + }) + server.binding.OIDC.oauth2Config = &mockOAuth2Config{ + tokenSource: &mockTokenSource{}, + authCodeURL: webOIDCRedirectPath, + token: token, + } + idToken := &oidc.IDToken{ + Nonce: authReq.Nonce, + Expiry: time.Now().Add(5 * time.Minute), + } + setIDTokenClaims(idToken, []byte(`{"preferred_username":"admin","sid":"sid456"}`)) + 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")) + var tokenCookie string + for k := range oidcMgr.tokens { + tokenCookie = k + } + // we should be able to create admins without setting a password + if csrfTokenAuth == nil { + csrfTokenAuth = jwtauth.New(jwa.HS256.String(), util.GenerateRandomBytes(32), nil) + } + adminUsername := "testAdmin" + form := make(url.Values) + form.Set(csrfFormToken, createCSRFToken("")) + form.Set("username", adminUsername) + form.Set("password", "") + form.Set("status", "1") + form.Set("permissions", "*") + rr = httptest.NewRecorder() + r, err = http.NewRequest(http.MethodPost, webAdminPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + r.Header.Set("Cookie", fmt.Sprintf("%v=%v", oidcCookieKey, tokenCookie)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + server.router.ServeHTTP(rr, r) + assert.Equal(t, http.StatusSeeOther, rr.Code) + _, err = dataprovider.AdminExists(adminUsername) + assert.NoError(t, err) + err = dataprovider.DeleteAdmin(adminUsername, "", "") + assert.NoError(t, err) + // login and password related routes are disabled + rr = httptest.NewRecorder() + r, err = http.NewRequest(http.MethodPost, webAdminLoginPath, nil) + assert.NoError(t, err) + server.router.ServeHTTP(rr, r) + assert.Equal(t, http.StatusMethodNotAllowed, rr.Code) + rr = httptest.NewRecorder() + r, err = http.NewRequest(http.MethodPost, webAdminTwoFactorPath, nil) + assert.NoError(t, err) + server.router.ServeHTTP(rr, r) + assert.Equal(t, http.StatusNotFound, rr.Code) + rr = httptest.NewRecorder() + r, err = http.NewRequest(http.MethodPost, webClientLoginPath, nil) + assert.NoError(t, err) + server.router.ServeHTTP(rr, r) + assert.Equal(t, http.StatusMethodNotAllowed, rr.Code) + rr = httptest.NewRecorder() + r, err = http.NewRequest(http.MethodPost, webClientForgotPwdPath, nil) + assert.NoError(t, err) + server.router.ServeHTTP(rr, r) + assert.Equal(t, http.StatusNotFound, rr.Code) +} + func TestDbOIDCManager(t *testing.T) { if !isSharedProviderSupported() { t.Skip("this test it is not available with this provider") diff --git a/httpd/server.go b/httpd/server.go index 1c483e5b..e48387ea 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -159,18 +159,19 @@ func (s *httpdServer) refreshCookie(next http.Handler) http.Handler { func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, error, ip string) { data := loginPage{ - CurrentURL: webClientLoginPath, - Version: version.Get().Version, - Error: error, - CSRFToken: createCSRFToken(ip), - StaticURL: webStaticFilesPath, - Branding: s.binding.Branding.WebClient, + CurrentURL: webClientLoginPath, + Version: version.Get().Version, + Error: error, + CSRFToken: createCSRFToken(ip), + StaticURL: webStaticFilesPath, + Branding: s.binding.Branding.WebClient, + FormDisabled: s.binding.isWebClientLoginFormDisabled(), } if s.binding.showAdminLoginURL() { data.AltLoginURL = webAdminLoginPath data.AltLoginName = s.binding.Branding.WebAdmin.ShortName } - if smtp.IsEnabled() { + if smtp.IsEnabled() && !data.FormDisabled { data.ForgotPwdURL = webClientForgotPwdPath } if s.binding.OIDC.isEnabled() { @@ -536,18 +537,19 @@ func (s *httpdServer) handleWebAdminLoginPost(w http.ResponseWriter, r *http.Req func (s *httpdServer) renderAdminLoginPage(w http.ResponseWriter, error, ip string) { data := loginPage{ - CurrentURL: webAdminLoginPath, - Version: version.Get().Version, - Error: error, - CSRFToken: createCSRFToken(ip), - StaticURL: webStaticFilesPath, - Branding: s.binding.Branding.WebAdmin, + CurrentURL: webAdminLoginPath, + Version: version.Get().Version, + Error: error, + CSRFToken: createCSRFToken(ip), + StaticURL: webStaticFilesPath, + Branding: s.binding.Branding.WebAdmin, + FormDisabled: s.binding.isWebAdminLoginFormDisabled(), } if s.binding.showClientLoginURL() { data.AltLoginURL = webClientLoginPath data.AltLoginName = s.binding.Branding.WebClient.ShortName } - if smtp.IsEnabled() { + if smtp.IsEnabled() && !data.FormDisabled { data.ForgotPwdURL = webAdminForgotPwdPath } if s.binding.OIDC.hasRoles() { @@ -1397,23 +1399,25 @@ func (s *httpdServer) setupWebClientRoutes() { if s.binding.OIDC.isEnabled() { s.router.Get(webClientOIDCLoginPath, s.handleWebClientOIDCLogin) } - s.router.Post(webClientLoginPath, s.handleWebClientLoginPost) - s.router.Get(webClientForgotPwdPath, s.handleWebClientForgotPwd) - s.router.Post(webClientForgotPwdPath, s.handleWebClientForgotPwdPost) - s.router.Get(webClientResetPwdPath, s.handleWebClientPasswordReset) - s.router.Post(webClientResetPwdPath, s.handleWebClientPasswordResetPost) - s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), - s.jwtAuthenticatorPartial(tokenAudienceWebClientPartial)). - Get(webClientTwoFactorPath, s.handleWebClientTwoFactor) - s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), - s.jwtAuthenticatorPartial(tokenAudienceWebClientPartial)). - Post(webClientTwoFactorPath, s.handleWebClientTwoFactorPost) - s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), - s.jwtAuthenticatorPartial(tokenAudienceWebClientPartial)). - Get(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecovery) - s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), - s.jwtAuthenticatorPartial(tokenAudienceWebClientPartial)). - Post(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecoveryPost) + if !s.binding.isWebClientLoginFormDisabled() { + s.router.Post(webClientLoginPath, s.handleWebClientLoginPost) + s.router.Get(webClientForgotPwdPath, s.handleWebClientForgotPwd) + s.router.Post(webClientForgotPwdPath, s.handleWebClientForgotPwdPost) + s.router.Get(webClientResetPwdPath, s.handleWebClientPasswordReset) + s.router.Post(webClientResetPwdPath, s.handleWebClientPasswordResetPost) + s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), + s.jwtAuthenticatorPartial(tokenAudienceWebClientPartial)). + Get(webClientTwoFactorPath, s.handleWebClientTwoFactor) + s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), + s.jwtAuthenticatorPartial(tokenAudienceWebClientPartial)). + Post(webClientTwoFactorPath, s.handleWebClientTwoFactorPost) + s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), + s.jwtAuthenticatorPartial(tokenAudienceWebClientPartial)). + Get(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecovery) + s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), + s.jwtAuthenticatorPartial(tokenAudienceWebClientPartial)). + Post(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecoveryPost) + } // share API exposed to external users s.router.Get(webClientPubSharesPath+"/{id}", s.downloadFromShare) s.router.Get(webClientPubSharesPath+"/{id}/browse", s.handleShareGetFiles) @@ -1496,25 +1500,27 @@ func (s *httpdServer) setupWebAdminRoutes() { if s.binding.OIDC.hasRoles() { s.router.Get(webAdminOIDCLoginPath, s.handleWebAdminOIDCLogin) } - s.router.Post(webAdminLoginPath, s.handleWebAdminLoginPost) s.router.Get(webAdminSetupPath, s.handleWebAdminSetupGet) s.router.Post(webAdminSetupPath, s.handleWebAdminSetupPost) - s.router.Get(webAdminForgotPwdPath, s.handleWebAdminForgotPwd) - s.router.Post(webAdminForgotPwdPath, s.handleWebAdminForgotPwdPost) - s.router.Get(webAdminResetPwdPath, s.handleWebAdminPasswordReset) - s.router.Post(webAdminResetPwdPath, s.handleWebAdminPasswordResetPost) - s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), - s.jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)). - Get(webAdminTwoFactorPath, s.handleWebAdminTwoFactor) - s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), - s.jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)). - Post(webAdminTwoFactorPath, s.handleWebAdminTwoFactorPost) - s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), - s.jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)). - Get(webAdminTwoFactorRecoveryPath, s.handleWebAdminTwoFactorRecovery) - s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), - s.jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)). - Post(webAdminTwoFactorRecoveryPath, s.handleWebAdminTwoFactorRecoveryPost) + if !s.binding.isWebAdminLoginFormDisabled() { + s.router.Post(webAdminLoginPath, s.handleWebAdminLoginPost) + s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), + s.jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)). + Get(webAdminTwoFactorPath, s.handleWebAdminTwoFactor) + s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), + s.jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)). + Post(webAdminTwoFactorPath, s.handleWebAdminTwoFactorPost) + s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), + s.jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)). + Get(webAdminTwoFactorRecoveryPath, s.handleWebAdminTwoFactorRecovery) + s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), + s.jwtAuthenticatorPartial(tokenAudienceWebAdminPartial)). + Post(webAdminTwoFactorRecoveryPath, s.handleWebAdminTwoFactorRecoveryPost) + s.router.Get(webAdminForgotPwdPath, s.handleWebAdminForgotPwd) + s.router.Post(webAdminForgotPwdPath, s.handleWebAdminForgotPwdPost) + s.router.Get(webAdminResetPwdPath, s.handleWebAdminPasswordReset) + s.router.Post(webAdminResetPwdPath, s.handleWebAdminPasswordResetPost) + } s.router.Group(func(router chi.Router) { if s.binding.OIDC.isEnabled() { diff --git a/httpd/web.go b/httpd/web.go index 9dd9a53c..5083e0f3 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -49,6 +49,7 @@ type loginPage struct { ForgotPwdURL string OpenIDLoginURL string Branding UIBranding + FormDisabled bool } type twoFactorPage struct { diff --git a/httpd/webadmin.go b/httpd/webadmin.go index b9f2eec7..5470971b 100644 --- a/httpd/webadmin.go +++ b/httpd/webadmin.go @@ -1498,7 +1498,7 @@ func getAdminFromPostFields(r *http.Request) (dataprovider.Admin, error) { } status, err := strconv.Atoi(r.Form.Get("status")) if err != nil { - return admin, err + return admin, fmt.Errorf("invalid status: %w", err) } admin.Username = r.Form.Get("username") admin.Password = r.Form.Get("password") @@ -2243,6 +2243,9 @@ func (s *httpdServer) handleWebAddAdminPost(w http.ResponseWriter, r *http.Reque s.renderAddUpdateAdminPage(w, r, &admin, err.Error(), true) return } + if admin.Password == "" && s.binding.isWebAdminLoginFormDisabled() { + admin.Password = util.GenerateUniqueID() + } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil { s.renderForbiddenPage(w, r, err.Error()) diff --git a/sftpgo.json b/sftpgo.json index d3a8fd91..de1f8136 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -240,6 +240,7 @@ "address": "", "enable_web_admin": true, "enable_web_client": true, + "enabled_login_methods": 0, "enable_https": false, "certificate_file": "", "certificate_key_file": "", diff --git a/templates/webadmin/login.html b/templates/webadmin/login.html index d8962c79..83de40cd 100644 --- a/templates/webadmin/login.html +++ b/templates/webadmin/login.html @@ -28,6 +28,7 @@ along with this program. If not, see . {{end}}
+ {{if not .FormDisabled}}