From 90b324d707427e693a40716f4b51445ce8062add Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Tue, 27 Jul 2021 18:43:00 +0200 Subject: [PATCH] Add a link on the login pages to switch between admin and web client login The links are hidden if only the web admin or only thw web client is enabled and can also be controlled using the "hide_login_url" setting Fixes #485 --- config/config.go | 11 +++++- config/config_test.go | 9 ++++- docs/full-configuration.md | 1 + httpd/httpd.go | 29 +++++++++++++- httpd/internal_test.go | 28 ++++++++++++++ httpd/schema/openapi.yaml | 2 +- httpd/server.go | 70 ++++++++++++++++++++++++++-------- httpd/web.go | 11 +++--- httpd/webadmin.go | 19 --------- httpd/webclient.go | 15 -------- sftpgo.json | 3 +- templates/webadmin/login.html | 8 +++- templates/webclient/login.html | 8 +++- 13 files changed, 152 insertions(+), 62 deletions(-) diff --git a/config/config.go b/config/config.go index 29a0874a..9ec24081 100644 --- a/config/config.go +++ b/config/config.go @@ -72,6 +72,7 @@ var ( ClientAuthType: 0, TLSCipherSuites: nil, ProxyAllowed: nil, + HideLoginURL: 0, } defaultRateLimiter = common.RateLimiterConfig{ Average: 0, @@ -876,6 +877,12 @@ func getHTTPDBindingFromEnv(idx int) { isSet = true } + hideLoginURL, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__HIDE_LOGIN_URL", idx)) + if ok { + binding.HideLoginURL = int(hideLoginURL) + isSet = true + } + if isSet { if len(globalConf.HTTPDConfig.Bindings) > idx { globalConf.HTTPDConfig.Bindings[idx] = binding @@ -1062,7 +1069,7 @@ func setViperDefaults() { func lookupBoolFromEnv(envName string) (bool, bool) { value, ok := os.LookupEnv(envName) if ok { - converted, err := strconv.ParseBool(value) + converted, err := strconv.ParseBool(strings.TrimSpace(value)) if err == nil { return converted, ok } @@ -1074,7 +1081,7 @@ func lookupBoolFromEnv(envName string) (bool, bool) { func lookupIntFromEnv(envName string) (int64, bool) { value, ok := os.LookupEnv(envName) if ok { - converted, err := strconv.ParseInt(value, 10, 64) + converted, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) if err == nil { return converted, ok } diff --git a/config/config_test.go b/config/config_test.go index d57c432a..3721911b 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -584,14 +584,16 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { os.Setenv("SFTPGO_HTTPD__BINDINGS__1__ADDRESS", "127.0.0.1") os.Setenv("SFTPGO_HTTPD__BINDINGS__1__PORT", "8000") os.Setenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_HTTPS", "0") + os.Setenv("SFTPGO_HTTPD__BINDINGS__1__HIDE_LOGIN_URL", " 1") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ADDRESS", "127.0.1.1") 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__ENABLE_HTTPS", "1") + os.Setenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_HTTPS", "1 ") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_AUTH_TYPE", "1") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES", " TLS_AES_256_GCM_SHA384 , TLS_CHACHA20_POLY1305_SHA256") os.Setenv("SFTPGO_HTTPD__BINDINGS__2__PROXY_ALLOWED", " 192.168.9.1 , 172.16.25.0/24") + os.Setenv("SFTPGO_HTTPD__BINDINGS__2__HIDE_LOGIN_URL", "3") t.Cleanup(func() { os.Unsetenv("SFTPGO_HTTPD__BINDINGS__0__ADDRESS") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__0__PORT") @@ -599,6 +601,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__ADDRESS") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__PORT") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_HTTPS") + os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__HIDE_LOGIN_URL") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ADDRESS") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__PORT") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_HTTPS") @@ -607,6 +610,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_AUTH_TYPE") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES") os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__PROXY_ALLOWED") + os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__HIDE_LOGIN_URL") }) configDir := ".." @@ -621,12 +625,14 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { require.True(t, bindings[0].EnableWebClient) require.Len(t, bindings[0].TLSCipherSuites, 1) 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) require.Equal(t, "127.0.0.1", bindings[1].Address) require.False(t, bindings[1].EnableHTTPS) require.True(t, bindings[1].EnableWebAdmin) require.True(t, bindings[1].EnableWebClient) require.Nil(t, bindings[1].TLSCipherSuites) + require.Equal(t, 1, bindings[1].HideLoginURL) require.Equal(t, 9000, bindings[2].Port) require.Equal(t, "127.0.1.1", bindings[2].Address) @@ -640,6 +646,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) { require.Len(t, bindings[2].ProxyAllowed, 2) require.Equal(t, "192.168.9.1", bindings[2].ProxyAllowed[0]) require.Equal(t, "172.16.25.0/24", bindings[2].ProxyAllowed[1]) + require.Equal(t, 3, bindings[2].HideLoginURL) } func TestHTTPClientCertificatesFromEnv(t *testing.T) { diff --git a/docs/full-configuration.md b/docs/full-configuration.md index ad6e786e..b27df6a6 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -204,6 +204,7 @@ The configuration file contains the following sections: - `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. + - `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. - `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 diff --git a/httpd/httpd.go b/httpd/httpd.go index a31a1499..c4fa1873 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -184,7 +184,14 @@ type Binding struct { TLSCipherSuites []string `json:"tls_cipher_suites" mapstructure:"tls_cipher_suites"` // List of IP addresses and IP ranges allowed to set X-Forwarded-For, X-Real-IP, // X-Forwarded-Proto headers. - ProxyAllowed []string `json:"proxy_allowed" mapstructure:"proxy_allowed"` + ProxyAllowed []string `json:"proxy_allowed" mapstructure:"proxy_allowed"` + // 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 login links are displayed on both admin and client login page. This is the default + // - 1 the login link to the web client login page is hidden on admin login page + // - 2 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. + HideLoginURL int `json:"hide_login_url" mapstructure:"hide_login_url"` allowHeadersFrom []func(net.IP) bool } @@ -213,6 +220,26 @@ func (b *Binding) IsValid() bool { return false } +func (b *Binding) showAdminLoginURL() bool { + if !b.EnableWebAdmin { + return false + } + if b.HideLoginURL&2 != 0 { + return false + } + return true +} + +func (b *Binding) showClientLoginURL() bool { + if !b.EnableWebClient { + return false + } + if b.HideLoginURL&1 != 0 { + return false + } + return true +} + type defenderStatus struct { IsActive bool `json:"is_active"` } diff --git a/httpd/internal_test.go b/httpd/internal_test.go index bc959d63..7003d3d3 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -1668,3 +1668,31 @@ func TestSigningKey(t *testing.T) { _, err = server2.tokenAuth.Decode(accessToken) assert.NoError(t, err) } + +func TestLoginLinks(t *testing.T) { + b := Binding{ + EnableWebAdmin: true, + EnableWebClient: false, + } + assert.False(t, b.showClientLoginURL()) + b = Binding{ + EnableWebAdmin: false, + EnableWebClient: true, + } + assert.False(t, b.showAdminLoginURL()) + b = Binding{ + EnableWebAdmin: true, + EnableWebClient: true, + } + assert.True(t, b.showAdminLoginURL()) + assert.True(t, b.showClientLoginURL()) + b.HideLoginURL = 3 + assert.False(t, b.showAdminLoginURL()) + assert.False(t, b.showClientLoginURL()) + b.HideLoginURL = 1 + assert.True(t, b.showAdminLoginURL()) + assert.False(t, b.showClientLoginURL()) + b.HideLoginURL = 2 + assert.False(t, b.showAdminLoginURL()) + assert.True(t, b.showClientLoginURL()) +} diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 7d892d56..c7d6b949 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -2052,7 +2052,7 @@ paths: patch: tags: - users API - summary: Rename afile + summary: Rename a file description: Rename a file for the logged in user operationId: rename_user_file parameters: diff --git a/httpd/server.go b/httpd/server.go index d9975736..865cd1c7 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -116,11 +116,29 @@ func (s *httpdServer) refreshCookie(next http.Handler) http.Handler { }) } +func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, error string) { + data := loginPage{ + CurrentURL: webClientLoginPath, + Version: version.Get().Version, + Error: error, + CSRFToken: createCSRFToken(), + StaticURL: webStaticFilesPath, + } + if s.binding.showAdminLoginURL() { + data.AltLoginURL = webLoginPath + } + renderClientTemplate(w, templateClientLogin, data) +} + +func (s *httpdServer) handleClientWebLogin(w http.ResponseWriter, r *http.Request) { + s.renderClientLoginPage(w, "") +} + func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxLoginPostSize) if err := r.ParseForm(); err != nil { - renderClientLoginPage(w, err.Error()) + s.renderClientLoginPage(w, err.Error()) return } ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) @@ -128,30 +146,30 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re password := r.Form.Get("password") if username == "" || password == "" { updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, ipAddr, common.ErrNoCredentials) - renderClientLoginPage(w, "Invalid credentials") + s.renderClientLoginPage(w, "Invalid credentials") return } if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}}, ipAddr, err) - renderClientLoginPage(w, err.Error()) + s.renderClientLoginPage(w, err.Error()) return } if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolHTTP); err != nil { - renderClientLoginPage(w, fmt.Sprintf("access denied by post connect hook: %v", err)) + s.renderClientLoginPage(w, fmt.Sprintf("access denied by post connect hook: %v", err)) return } user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, common.ProtocolHTTP) if err != nil { updateLoginMetrics(&user, ipAddr, err) - renderClientLoginPage(w, dataprovider.ErrInvalidCredentials.Error()) + s.renderClientLoginPage(w, dataprovider.ErrInvalidCredentials.Error()) return } connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String()) if err := checkHTTPClientUser(&user, r, connectionID); err != nil { updateLoginMetrics(&user, ipAddr, err) - renderClientLoginPage(w, err.Error()) + s.renderClientLoginPage(w, err.Error()) return } @@ -160,7 +178,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re if err != nil { logger.Warn(logSender, connectionID, "unable to check fs root: %v", err) updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure) - renderClientLoginPage(w, err.Error()) + s.renderClientLoginPage(w, err.Error()) return } @@ -174,7 +192,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re if err != nil { logger.Warn(logSender, connectionID, "unable to set client login cookie %v", err) updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure) - renderClientLoginPage(w, err.Error()) + s.renderClientLoginPage(w, err.Error()) return } updateLoginMetrics(&user, ipAddr, err) @@ -185,27 +203,49 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re func (s *httpdServer) handleWebAdminLoginPost(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxLoginPostSize) if err := r.ParseForm(); err != nil { - renderLoginPage(w, err.Error()) + s.renderAdminLoginPage(w, err.Error()) return } username := r.Form.Get("username") password := r.Form.Get("password") if username == "" || password == "" { - renderLoginPage(w, "Invalid credentials") + s.renderAdminLoginPage(w, "Invalid credentials") return } if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { - renderLoginPage(w, err.Error()) + s.renderAdminLoginPage(w, err.Error()) return } admin, err := dataprovider.CheckAdminAndPass(username, password, util.GetIPFromRemoteAddress(r.RemoteAddr)) if err != nil { - renderLoginPage(w, err.Error()) + s.renderAdminLoginPage(w, err.Error()) return } s.loginAdmin(w, r, &admin) } +func (s *httpdServer) renderAdminLoginPage(w http.ResponseWriter, error string) { + data := loginPage{ + CurrentURL: webLoginPath, + Version: version.Get().Version, + Error: error, + CSRFToken: createCSRFToken(), + StaticURL: webStaticFilesPath, + } + if s.binding.showClientLoginURL() { + data.AltLoginURL = webClientLoginPath + } + renderAdminTemplate(w, templateLogin, data) +} + +func (s *httpdServer) handleWebAdminLogin(w http.ResponseWriter, r *http.Request) { + if !dataprovider.HasAdmin() { + http.Redirect(w, r, webAdminSetupPath, http.StatusFound) + return + } + s.renderAdminLoginPage(w, "") +} + func (s *httpdServer) handleWebAdminSetupPost(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxLoginPostSize) if dataprovider.HasAdmin() { @@ -260,7 +300,7 @@ func (s *httpdServer) loginAdmin(w http.ResponseWriter, r *http.Request, admin * err := c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebAdmin) if err != nil { logger.Warn(logSender, "", "unable to set admin login cookie %v", err) - renderLoginPage(w, err.Error()) + s.renderAdminLoginPage(w, err.Error()) return } @@ -672,7 +712,7 @@ func (s *httpdServer) initializeRouter() { s.router.Get(webBaseClientPath, func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, webClientLoginPath, http.StatusMovedPermanently) }) - s.router.Get(webClientLoginPath, handleClientWebLogin) + s.router.Get(webClientLoginPath, s.handleClientWebLogin) s.router.Post(webClientLoginPath, s.handleWebClientLoginPost) s.router.Group(func(router chi.Router) { @@ -706,7 +746,7 @@ func (s *httpdServer) initializeRouter() { s.router.Get(webBaseAdminPath, func(w http.ResponseWriter, r *http.Request) { s.redirectToWebPath(w, r, webLoginPath) }) - s.router.Get(webLoginPath, handleWebLogin) + s.router.Get(webLoginPath, s.handleWebAdminLogin) s.router.Post(webLoginPath, s.handleWebAdminLoginPost) s.router.Get(webAdminSetupPath, handleWebAdminSetupGet) s.router.Post(webAdminSetupPath, s.handleWebAdminSetupPost) diff --git a/httpd/web.go b/httpd/web.go index 2565a555..f53ea523 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -18,11 +18,12 @@ const ( ) type loginPage struct { - CurrentURL string - Version string - Error string - CSRFToken string - StaticURL string + CurrentURL string + Version string + Error string + CSRFToken string + StaticURL string + AltLoginURL string } func getSliceFromDelimitedValues(values, delimiter string) []string { diff --git a/httpd/webadmin.go b/httpd/webadmin.go index dc995a35..e47e763e 100644 --- a/httpd/webadmin.go +++ b/httpd/webadmin.go @@ -1018,17 +1018,6 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { return user, err } -func renderLoginPage(w http.ResponseWriter, error string) { - data := loginPage{ - CurrentURL: webLoginPath, - Version: version.Get().Version, - Error: error, - CSRFToken: createCSRFToken(), - StaticURL: webStaticFilesPath, - } - renderAdminTemplate(w, templateLogin, data) -} - func handleWebAdminChangePwd(w http.ResponseWriter, r *http.Request) { renderChangePwdPage(w, r, "") } @@ -1060,14 +1049,6 @@ func handleWebLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, webLoginPath, http.StatusFound) } -func handleWebLogin(w http.ResponseWriter, r *http.Request) { - if !dataprovider.HasAdmin() { - http.Redirect(w, r, webAdminSetupPath, http.StatusFound) - return - } - renderLoginPage(w, "") -} - func handleWebMaintenance(w http.ResponseWriter, r *http.Request) { renderMaintenancePage(w, r, "") } diff --git a/httpd/webclient.go b/httpd/webclient.go index b6426b4f..bdef63c3 100644 --- a/httpd/webclient.go +++ b/httpd/webclient.go @@ -167,17 +167,6 @@ func renderClientTemplate(w http.ResponseWriter, tmplName string, data interface } } -func renderClientLoginPage(w http.ResponseWriter, error string) { - data := loginPage{ - CurrentURL: webClientLoginPath, - Version: version.Get().Version, - Error: error, - CSRFToken: createCSRFToken(), - StaticURL: webStaticFilesPath, - } - renderClientTemplate(w, templateClientLogin, data) -} - func renderClientMessagePage(w http.ResponseWriter, r *http.Request, title, body string, statusCode int, err error, message string) { var errorString string if body != "" { @@ -260,10 +249,6 @@ func renderCredentialsPage(w http.ResponseWriter, r *http.Request, pwdError stri renderClientTemplate(w, templateClientCredentials, data) } -func handleClientWebLogin(w http.ResponseWriter, r *http.Request) { - renderClientLoginPage(w, "") -} - func handleWebClientLogout(w http.ResponseWriter, r *http.Request) { c := jwtTokenClaims{} c.removeCookie(w, r, webBaseClientPath) diff --git a/sftpgo.json b/sftpgo.json index 8d6e86db..0f2443df 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -191,7 +191,8 @@ "enable_https": false, "client_auth_type": 0, "tls_cipher_suites": [], - "proxy_allowed": [] + "proxy_allowed": [], + "hide_login_url": 0 } ], "templates_path": "templates", diff --git a/templates/webadmin/login.html b/templates/webadmin/login.html index 1efe1ca3..415dd311 100644 --- a/templates/webadmin/login.html +++ b/templates/webadmin/login.html @@ -9,7 +9,7 @@ - SFTPGo - Login + SFTPGo Admin - Login @@ -110,6 +110,12 @@ Login + {{if .AltLoginURL}} +
+
+ Web Client +
+ {{end}} diff --git a/templates/webclient/login.html b/templates/webclient/login.html index 2b2cb7d6..b6231df1 100644 --- a/templates/webclient/login.html +++ b/templates/webclient/login.html @@ -9,7 +9,7 @@ - SFTPGo - Login + SFTPGo WebClient - Login @@ -110,6 +110,12 @@ Login + {{if .AltLoginURL}} +
+
+ Web Admin +
+ {{end}}