瀏覽代碼

OIDC: allow to enable only OIDC login for Web UIs

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 2 年之前
父節點
當前提交
d5946da1e2

+ 1 - 1
.golangci.yml

@@ -1,5 +1,5 @@
 run:
 run:
-  timeout: 5m
+  timeout: 10m
   issues-exit-code: 1
   issues-exit-code: 1
   tests: true
   tests: true
 
 

+ 8 - 1
config/config.go

@@ -99,6 +99,7 @@ var (
 		Port:                  8080,
 		Port:                  8080,
 		EnableWebAdmin:        true,
 		EnableWebAdmin:        true,
 		EnableWebClient:       true,
 		EnableWebClient:       true,
+		EnabledLoginMethods:   0,
 		EnableHTTPS:           false,
 		EnableHTTPS:           false,
 		CertificateFile:       "",
 		CertificateFile:       "",
 		CertificateKeyFile:    "",
 		CertificateKeyFile:    "",
@@ -1623,7 +1624,7 @@ func getHTTPDBindingProxyConfigsFromEnv(idx int, binding *httpd.Binding) bool {
 	return isSet
 	return isSet
 }
 }
 
 
-func getHTTPDBindingFromEnv(idx int) {
+func getHTTPDBindingFromEnv(idx int) { //nolint:gocyclo
 	binding := getDefaultHTTPBinding(idx)
 	binding := getDefaultHTTPBinding(idx)
 	isSet := false
 	isSet := false
 
 
@@ -1663,6 +1664,12 @@ func getHTTPDBindingFromEnv(idx int) {
 		isSet = true
 		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))
 	renderOpenAPI, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__RENDER_OPENAPI", idx))
 	if ok {
 	if ok {
 		binding.RenderOpenAPI = renderOpenAPI
 		binding.RenderOpenAPI = renderOpenAPI

+ 5 - 0
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__PORT", "9000")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_ADMIN", "0")
 	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__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__RENDER_OPENAPI", "0")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_HTTPS", "1 ")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_HTTPS", "1 ")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__MIN_TLS_VERSION", "13")
 	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__MIN_TLS_VERSION")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_ADMIN")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_ADMIN")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_CLIENT")
 		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__RENDER_OPENAPI")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_AUTH_TYPE")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_AUTH_TYPE")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES")
 		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.Equal(t, 12, bindings[0].MinTLSVersion)
 	require.True(t, bindings[0].EnableWebAdmin)
 	require.True(t, bindings[0].EnableWebAdmin)
 	require.True(t, bindings[0].EnableWebClient)
 	require.True(t, bindings[0].EnableWebClient)
+	require.Equal(t, 0, bindings[0].EnabledLoginMethods)
 	require.True(t, bindings[0].RenderOpenAPI)
 	require.True(t, bindings[0].RenderOpenAPI)
 	require.Len(t, bindings[0].TLSCipherSuites, 1)
 	require.Len(t, bindings[0].TLSCipherSuites, 1)
 	require.Empty(t, bindings[0].OIDC.ConfigURL)
 	require.Empty(t, bindings[0].OIDC.ConfigURL)
@@ -1173,6 +1176,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
 	require.Equal(t, 12, bindings[0].MinTLSVersion)
 	require.Equal(t, 12, bindings[0].MinTLSVersion)
 	require.True(t, bindings[1].EnableWebAdmin)
 	require.True(t, bindings[1].EnableWebAdmin)
 	require.True(t, bindings[1].EnableWebClient)
 	require.True(t, bindings[1].EnableWebClient)
+	require.Equal(t, 0, bindings[1].EnabledLoginMethods)
 	require.True(t, bindings[1].RenderOpenAPI)
 	require.True(t, bindings[1].RenderOpenAPI)
 	require.Nil(t, bindings[1].TLSCipherSuites)
 	require.Nil(t, bindings[1].TLSCipherSuites)
 	require.Equal(t, 1, bindings[1].HideLoginURL)
 	require.Equal(t, 1, bindings[1].HideLoginURL)
@@ -1188,6 +1192,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
 	require.Equal(t, 13, bindings[2].MinTLSVersion)
 	require.Equal(t, 13, bindings[2].MinTLSVersion)
 	require.False(t, bindings[2].EnableWebAdmin)
 	require.False(t, bindings[2].EnableWebAdmin)
 	require.False(t, bindings[2].EnableWebClient)
 	require.False(t, bindings[2].EnableWebClient)
+	require.Equal(t, 3, bindings[2].EnabledLoginMethods)
 	require.False(t, bindings[2].RenderOpenAPI)
 	require.False(t, bindings[2].RenderOpenAPI)
 	require.Equal(t, 1, bindings[2].ClientAuthType)
 	require.Equal(t, 1, bindings[2].ClientAuthType)
 	require.Len(t, bindings[2].TLSCipherSuites, 2)
 	require.Len(t, bindings[2].TLSCipherSuites, 2)

+ 2 - 4
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 {
 	for _, username := range folder.Users {
 		var u []byte
 		var u []byte
 		if u = usersBucket.Get([]byte(username)); u == nil {
 		if u = usersBucket.Get([]byte(username)); u == nil {
@@ -1134,7 +1132,7 @@ func (p *BoltProvider) deleteFolder(folder vfs.BaseVirtualFolder) error {
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
-		if err = p.deleteFolderMappings(tx, folder, usersBucket, groupsBucket); err != nil {
+		if err = p.deleteFolderMappings(folder, usersBucket, groupsBucket); err != nil {
 			return err
 			return err
 		}
 		}
 
 

+ 12 - 2
dataprovider/sqlcommon.go

@@ -50,6 +50,7 @@ type sqlQuerier interface {
 	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
 	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
 	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
 	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
 	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
 	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
+	PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
 }
 }
 
 
 type sqlScanner interface {
 type sqlScanner interface {
@@ -3077,8 +3078,17 @@ func sqlCommonGetDatabaseVersion(dbHandle sqlQuerier, showInitWarn bool) (schema
 	defer cancel()
 	defer cancel()
 
 
 	q := getDatabaseVersionQuery()
 	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
 	return result, err
 }
 }
 
 

+ 1 - 0
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.
     - `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_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_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`.
     - `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_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.
     - `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.

+ 5 - 1
docs/oidc.md

@@ -42,8 +42,12 @@ Add the following configuration parameters to the SFTPGo configuration file (or
       "client_secret": "jRsmE0SWnuZjP7djBqNq0mrf8QN77j2c",
       "client_secret": "jRsmE0SWnuZjP7djBqNq0mrf8QN77j2c",
       "config_url": "http://192.168.1.12:8086/auth/realms/sftpgo",
       "config_url": "http://192.168.1.12:8086/auth/realms/sftpgo",
       "redirect_base_url": "http://192.168.1.50:8080",
       "redirect_base_url": "http://192.168.1.50:8080",
+      "scopes": [
+        "openid",
+        "profile",
+        "email"
+      ],
       "username_field": "preferred_username",
       "username_field": "preferred_username",
-      "scopes": [ "openid", "profile", "email" ],
       "role_field": "sftpgo_role",
       "role_field": "sftpgo_role",
       "implicit_roles": false,
       "implicit_roles": false,
       "custom_fields": []
       "custom_fields": []

+ 7 - 18
ftpd/ftpd_test.go

@@ -936,7 +936,7 @@ func TestLoginExternalAuth(t *testing.T) {
 	err = config.LoadConfig(configDir, "")
 	err = config.LoadConfig(configDir, "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	providerConf := config.GetProviderConf()
 	providerConf := config.GetProviderConf()
-	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u), os.ModePerm)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	providerConf.ExternalAuthHook = extAuthPath
 	providerConf.ExternalAuthHook = extAuthPath
 	providerConf.ExternalAuthScope = 0
 	providerConf.ExternalAuthScope = 0
@@ -960,7 +960,7 @@ func TestLoginExternalAuth(t *testing.T) {
 			Type: sdk.GroupTypePrimary,
 			Type: sdk.GroupTypePrimary,
 		},
 		},
 	}
 	}
-	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u), os.ModePerm)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	_, err = getFTPClient(u, true, nil)
 	_, err = getFTPClient(u, true, nil)
 	if !assert.Error(t, err) {
 	if !assert.Error(t, err) {
@@ -971,7 +971,7 @@ func TestLoginExternalAuth(t *testing.T) {
 	}
 	}
 
 
 	u.Groups = nil
 	u.Groups = nil
-	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u), os.ModePerm)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	u.Username = defaultUsername + "1"
 	u.Username = defaultUsername + "1"
 	client, err = getFTPClient(u, true, nil)
 	client, err = getFTPClient(u, true, nil)
@@ -3069,7 +3069,7 @@ func TestExternalAuthWithClientCert(t *testing.T) {
 	err = config.LoadConfig(configDir, "")
 	err = config.LoadConfig(configDir, "")
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	providerConf := config.GetProviderConf()
 	providerConf := config.GetProviderConf()
-	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
+	err = os.WriteFile(extAuthPath, getExtAuthScriptContent(u), os.ModePerm)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	providerConf.ExternalAuthHook = extAuthPath
 	providerConf.ExternalAuthHook = extAuthPath
 	providerConf.ExternalAuthScope = 8
 	providerConf.ExternalAuthScope = 8
@@ -3495,24 +3495,13 @@ func getTestUserWithHTTPFs() dataprovider.User {
 	return u
 	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 := []byte("#!/bin/sh\n\n")
 	extAuthContent = append(extAuthContent, []byte(fmt.Sprintf("if test \"$SFTPGO_AUTHD_USERNAME\" = \"%v\"; then\n", user.Username))...)
 	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)
 	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")...)
 	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")...)
 	extAuthContent = append(extAuthContent, []byte("fi\n")...)
 	return extAuthContent
 	return extAuthContent
 }
 }

+ 5 - 5
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/s3 v1.27.1
 	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.13
 	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/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/coreos/go-oidc/v3 v3.2.0
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
 	github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001
 	github.com/fclairamb/ftpserverlib v0.18.1-0.20220515214847-f96d31ec626e
 	github.com/fclairamb/ftpserverlib v0.18.1-0.20220515214847-f96d31ec626e
@@ -53,7 +53,7 @@ require (
 	github.com/rs/zerolog v1.27.0
 	github.com/rs/zerolog v1.27.0
 	github.com/sftpgo/sdk v0.1.2-0.20220611083241-b653555f7f4d
 	github.com/sftpgo/sdk v0.1.2-0.20220611083241-b653555f7f4d
 	github.com/shirou/gopsutil/v3 v3.22.6
 	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/cobra v1.5.0
 	github.com/spf13/viper v1.12.0
 	github.com/spf13/viper v1.12.0
 	github.com/stretchr/testify v1.8.0
 	github.com/stretchr/testify v1.8.0
@@ -67,7 +67,7 @@ require (
 	gocloud.dev v0.25.0
 	gocloud.dev v0.25.0
 	golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
 	golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
 	golang.org/x/net v0.0.0-20220708220712-1185a9018129
 	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/sys v0.0.0-20220715151400-c0bba94af5f8
 	golang.org/x/time v0.0.0-20220609170525-579cf78fd858
 	golang.org/x/time v0.0.0-20220609170525-579cf78fd858
 	google.golang.org/api v0.87.0
 	google.golang.org/api v0.87.0
@@ -112,7 +112,7 @@ require (
 	github.com/googleapis/go-type-adapters v1.0.0 // indirect
 	github.com/googleapis/go-type-adapters v1.0.0 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/hcl v1.0.0 // 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/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
 	github.com/klauspost/cpuid/v2 v2.1.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/tools v0.1.11 // indirect
 	golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
 	golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
 	google.golang.org/appengine v1.6.7 // 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/grpc v1.48.0 // indirect
 	google.golang.org/protobuf v1.28.0 // indirect
 	google.golang.org/protobuf v1.28.0 // indirect
 	gopkg.in/ini.v1 v1.66.6 // indirect
 	gopkg.in/ini.v1 v1.66.6 // indirect

+ 10 - 10
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-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/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/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 h1:2eR2MGR7thBXSQ2YbODlF0fcmgtliLCfr9iX6RW11fc=
 github.com/coreos/go-oidc/v3 v3.2.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo=
 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=
 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/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 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
 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-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/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 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.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
 github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
 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/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 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
 github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
 github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
 github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
 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-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-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-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-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-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/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-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-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-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.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=

+ 44 - 0
httpd/httpd.go

@@ -19,6 +19,7 @@ package httpd
 
 
 import (
 import (
 	"crypto/sha256"
 	"crypto/sha256"
+	"errors"
 	"fmt"
 	"fmt"
 	"net"
 	"net"
 	"net/http"
 	"net/http"
@@ -409,6 +410,15 @@ type Binding struct {
 	// Enable the built-in client interface.
 	// Enable the built-in client interface.
 	// You have to define TemplatesPath and StaticFilesPath for this to work
 	// You have to define TemplatesPath and StaticFilesPath for this to work
 	EnableWebClient bool `json:"enable_web_client" mapstructure:"enable_web_client"`
 	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
 	// you also need to provide a certificate for enabling HTTPS
 	EnableHTTPS bool `json:"enable_https" mapstructure:"enable_https"`
 	EnableHTTPS bool `json:"enable_https" mapstructure:"enable_https"`
 	// Certificate and matching private key for this specific binding, if empty the global
 	// Certificate and matching private key for this specific binding, if empty the global
@@ -519,6 +529,36 @@ func (b *Binding) IsValid() bool {
 	return false
 	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 {
 func (b *Binding) showAdminLoginURL() bool {
 	if !b.EnableWebAdmin {
 	if !b.EnableWebAdmin {
 		return false
 		return false
@@ -782,6 +822,10 @@ func (c *Conf) Initialize(configDir string, isShared int) error {
 				exitChannel <- err
 				exitChannel <- err
 				return
 				return
 			}
 			}
+			if err := b.checkLoginMethods(); err != nil {
+				exitChannel <- err
+				return
+			}
 			server := newHttpdServer(b, staticFilesPath, c.SigningPassphrase, c.Cors, openAPIPath)
 			server := newHttpdServer(b, staticFilesPath, c.SigningPassphrase, c.Cors, openAPIPath)
 			server.setShared(isShared)
 			server.setShared(isShared)
 
 

+ 23 - 0
httpd/httpd_test.go

@@ -523,6 +523,29 @@ func TestInitialization(t *testing.T) {
 	if assert.Error(t, err) {
 	if assert.Error(t, err) {
 		assert.Contains(t, err.Error(), "oidc")
 		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) {
 func TestBasicUserHandling(t *testing.T) {

+ 94 - 0
httpd/oidc_test.go

@@ -15,11 +15,13 @@
 package httpd
 package httpd
 
 
 import (
 import (
+	"bytes"
 	"context"
 	"context"
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptest"
+	"net/url"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
 	"reflect"
 	"reflect"
@@ -30,6 +32,7 @@ import (
 
 
 	"github.com/coreos/go-oidc/v3/oidc"
 	"github.com/coreos/go-oidc/v3/oidc"
 	"github.com/go-chi/jwtauth/v5"
 	"github.com/go-chi/jwtauth/v5"
+	"github.com/lestrrat-go/jwx/jwa"
 	"github.com/rs/xid"
 	"github.com/rs/xid"
 	"github.com/sftpgo/sdk"
 	"github.com/sftpgo/sdk"
 	"github.com/stretchr/testify/assert"
 	"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) {
 func TestDbOIDCManager(t *testing.T) {
 	if !isSharedProviderSupported() {
 	if !isSharedProviderSupported() {
 		t.Skip("this test it is not available with this provider")
 		t.Skip("this test it is not available with this provider")

+ 54 - 48
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) {
 func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, error, ip string) {
 	data := loginPage{
 	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() {
 	if s.binding.showAdminLoginURL() {
 		data.AltLoginURL = webAdminLoginPath
 		data.AltLoginURL = webAdminLoginPath
 		data.AltLoginName = s.binding.Branding.WebAdmin.ShortName
 		data.AltLoginName = s.binding.Branding.WebAdmin.ShortName
 	}
 	}
-	if smtp.IsEnabled() {
+	if smtp.IsEnabled() && !data.FormDisabled {
 		data.ForgotPwdURL = webClientForgotPwdPath
 		data.ForgotPwdURL = webClientForgotPwdPath
 	}
 	}
 	if s.binding.OIDC.isEnabled() {
 	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) {
 func (s *httpdServer) renderAdminLoginPage(w http.ResponseWriter, error, ip string) {
 	data := loginPage{
 	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() {
 	if s.binding.showClientLoginURL() {
 		data.AltLoginURL = webClientLoginPath
 		data.AltLoginURL = webClientLoginPath
 		data.AltLoginName = s.binding.Branding.WebClient.ShortName
 		data.AltLoginName = s.binding.Branding.WebClient.ShortName
 	}
 	}
-	if smtp.IsEnabled() {
+	if smtp.IsEnabled() && !data.FormDisabled {
 		data.ForgotPwdURL = webAdminForgotPwdPath
 		data.ForgotPwdURL = webAdminForgotPwdPath
 	}
 	}
 	if s.binding.OIDC.hasRoles() {
 	if s.binding.OIDC.hasRoles() {
@@ -1397,23 +1399,25 @@ func (s *httpdServer) setupWebClientRoutes() {
 		if s.binding.OIDC.isEnabled() {
 		if s.binding.OIDC.isEnabled() {
 			s.router.Get(webClientOIDCLoginPath, s.handleWebClientOIDCLogin)
 			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
 		// share API exposed to external users
 		s.router.Get(webClientPubSharesPath+"/{id}", s.downloadFromShare)
 		s.router.Get(webClientPubSharesPath+"/{id}", s.downloadFromShare)
 		s.router.Get(webClientPubSharesPath+"/{id}/browse", s.handleShareGetFiles)
 		s.router.Get(webClientPubSharesPath+"/{id}/browse", s.handleShareGetFiles)
@@ -1496,25 +1500,27 @@ func (s *httpdServer) setupWebAdminRoutes() {
 		if s.binding.OIDC.hasRoles() {
 		if s.binding.OIDC.hasRoles() {
 			s.router.Get(webAdminOIDCLoginPath, s.handleWebAdminOIDCLogin)
 			s.router.Get(webAdminOIDCLoginPath, s.handleWebAdminOIDCLogin)
 		}
 		}
-		s.router.Post(webAdminLoginPath, s.handleWebAdminLoginPost)
 		s.router.Get(webAdminSetupPath, s.handleWebAdminSetupGet)
 		s.router.Get(webAdminSetupPath, s.handleWebAdminSetupGet)
 		s.router.Post(webAdminSetupPath, s.handleWebAdminSetupPost)
 		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) {
 		s.router.Group(func(router chi.Router) {
 			if s.binding.OIDC.isEnabled() {
 			if s.binding.OIDC.isEnabled() {

+ 1 - 0
httpd/web.go

@@ -49,6 +49,7 @@ type loginPage struct {
 	ForgotPwdURL   string
 	ForgotPwdURL   string
 	OpenIDLoginURL string
 	OpenIDLoginURL string
 	Branding       UIBranding
 	Branding       UIBranding
+	FormDisabled   bool
 }
 }
 
 
 type twoFactorPage struct {
 type twoFactorPage struct {

+ 4 - 1
httpd/webadmin.go

@@ -1498,7 +1498,7 @@ func getAdminFromPostFields(r *http.Request) (dataprovider.Admin, error) {
 	}
 	}
 	status, err := strconv.Atoi(r.Form.Get("status"))
 	status, err := strconv.Atoi(r.Form.Get("status"))
 	if err != nil {
 	if err != nil {
-		return admin, err
+		return admin, fmt.Errorf("invalid status: %w", err)
 	}
 	}
 	admin.Username = r.Form.Get("username")
 	admin.Username = r.Form.Get("username")
 	admin.Password = r.Form.Get("password")
 	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)
 		s.renderAddUpdateAdminPage(w, r, &admin, err.Error(), true)
 		return
 		return
 	}
 	}
+	if admin.Password == "" && s.binding.isWebAdminLoginFormDisabled() {
+		admin.Password = util.GenerateUniqueID()
+	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
 		s.renderForbiddenPage(w, r, err.Error())
 		s.renderForbiddenPage(w, r, err.Error())

+ 1 - 0
sftpgo.json

@@ -240,6 +240,7 @@
         "address": "",
         "address": "",
         "enable_web_admin": true,
         "enable_web_admin": true,
         "enable_web_client": true,
         "enable_web_client": true,
+        "enabled_login_methods": 0,
         "enable_https": false,
         "enable_https": false,
         "certificate_file": "",
         "certificate_file": "",
         "certificate_key_file": "",
         "certificate_key_file": "",

+ 2 - 0
templates/webadmin/login.html

@@ -28,6 +28,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                                     {{end}}
                                     {{end}}
                                     <form id="login_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"
                                     <form id="login_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"
                                         class="user-custom">
                                         class="user-custom">
+                                        {{if not .FormDisabled}}
                                         <div class="form-group">
                                         <div class="form-group">
                                             <input type="text" class="form-control form-control-user-custom"
                                             <input type="text" class="form-control form-control-user-custom"
                                                 id="inputUsername" name="username" placeholder="Username" required>
                                                 id="inputUsername" name="username" placeholder="Username" required>
@@ -45,6 +46,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                                         <button type="submit" class="btn btn-primary btn-user-custom btn-block">
                                         <button type="submit" class="btn btn-primary btn-user-custom btn-block">
                                             Login
                                             Login
                                         </button>
                                         </button>
+                                        {{end}}
                                         {{if .OpenIDLoginURL}}
                                         {{if .OpenIDLoginURL}}
                                         <hr>
                                         <hr>
                                         <a href="{{.OpenIDLoginURL}}" class="btn btn-secondary btn-user-custom btn-block">
                                         <a href="{{.OpenIDLoginURL}}" class="btn btn-secondary btn-user-custom btn-block">

+ 2 - 0
templates/webclient/login.html

@@ -25,6 +25,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                                     {{end}}
                                     {{end}}
                                     <form id="login_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"
                                     <form id="login_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"
                                         class="user-custom">
                                         class="user-custom">
+                                        {{if not .FormDisabled}}
                                         <div class="form-group">
                                         <div class="form-group">
                                             <input type="text" class="form-control form-control-user-custom"
                                             <input type="text" class="form-control form-control-user-custom"
                                                 id="inputUsername" name="username" placeholder="Username" required>
                                                 id="inputUsername" name="username" placeholder="Username" required>
@@ -42,6 +43,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                                         <button type="submit" class="btn btn-primary btn-user-custom btn-block">
                                         <button type="submit" class="btn btn-primary btn-user-custom btn-block">
                                             Login
                                             Login
                                         </button>
                                         </button>
+                                        {{end}}
                                         {{if .OpenIDLoginURL}}
                                         {{if .OpenIDLoginURL}}
                                         <hr>
                                         <hr>
                                         <a href="{{.OpenIDLoginURL}}" class="btn btn-secondary btn-user-custom btn-block">
                                         <a href="{{.OpenIDLoginURL}}" class="btn btn-secondary btn-user-custom btn-block">