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
This commit is contained in:
Nicola Murino 2021-07-27 18:43:00 +02:00
parent 3a22aae34f
commit 90b324d707
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
13 changed files with 152 additions and 62 deletions

View file

@ -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
}

View file

@ -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) {

View file

@ -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

View file

@ -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"`
}

View file

@ -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())
}

View file

@ -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:

View file

@ -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)

View file

@ -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 {

View file

@ -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, "")
}

View file

@ -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)

View file

@ -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",

View file

@ -9,7 +9,7 @@
<meta name="description" content="">
<meta name="author" content="">
<title>SFTPGo - Login</title>
<title>SFTPGo Admin - Login</title>
<link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
@ -110,6 +110,12 @@
Login
</button>
</form>
{{if .AltLoginURL}}
<hr>
<div class="text-center">
<a class="small" href="{{.AltLoginURL}}">Web Client</a>
</div>
{{end}}
</div>
</div>
</div>

View file

@ -9,7 +9,7 @@
<meta name="description" content="">
<meta name="author" content="">
<title>SFTPGo - Login</title>
<title>SFTPGo WebClient - Login</title>
<link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
@ -110,6 +110,12 @@
Login
</button>
</form>
{{if .AltLoginURL}}
<hr>
<div class="text-center">
<a class="small" href="{{.AltLoginURL}}">Web Admin</a>
</div>
{{end}}
</div>
</div>
</div>