parent
5c99f4fb60
commit
23d9ebfc91
64 changed files with 4961 additions and 1858 deletions
|
@ -47,6 +47,7 @@ Several storage backends are supported: local filesystem, encrypted local filesy
|
|||
- Support for HAProxy PROXY protocol: you can proxy and/or load balance the SFTP/SCP/FTP/WebDAV service without losing the information about the client's address.
|
||||
- [REST API](./docs/rest-api.md) for users and folders management, backup, restore and real time reports of the active connections with possibility of forcibly closing a connection.
|
||||
- [Web based administration interface](./docs/web-admin.md) to easily manage users, folders and connections.
|
||||
- [Web client interface](./docs/web-client.md) so that end users can change their credentials and browse their files.
|
||||
- Easy [migration](./examples/convertusers) from Linux system user accounts.
|
||||
- [Portable mode](./docs/portable-mode.md): a convenient way to share a single directory on demand.
|
||||
- [SFTP subsystem mode](./docs/sftp-subsystem.md): you can use SFTPGo as OpenSSH's SFTP subsystem.
|
||||
|
|
|
@ -104,7 +104,7 @@ var (
|
|||
QuotaScans ActiveScans
|
||||
idleTimeoutTicker *time.Ticker
|
||||
idleTimeoutTickerDone chan bool
|
||||
supportedProtocols = []string{ProtocolSFTP, ProtocolSCP, ProtocolSSH, ProtocolFTP, ProtocolWebDAV}
|
||||
supportedProtocols = []string{ProtocolSFTP, ProtocolSCP, ProtocolSSH, ProtocolFTP, ProtocolWebDAV, ProtocolHTTP}
|
||||
// the map key is the protocol, for each protocol we can have multiple rate limiters
|
||||
rateLimiters map[string][]*rateLimiter
|
||||
)
|
||||
|
|
|
@ -1029,7 +1029,7 @@ func (c *BaseConnection) GetPermissionDeniedError() error {
|
|||
switch c.protocol {
|
||||
case ProtocolSFTP:
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
case ProtocolWebDAV, ProtocolFTP:
|
||||
case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP:
|
||||
return os.ErrPermission
|
||||
default:
|
||||
return ErrPermissionDenied
|
||||
|
@ -1041,7 +1041,7 @@ func (c *BaseConnection) GetNotExistError() error {
|
|||
switch c.protocol {
|
||||
case ProtocolSFTP:
|
||||
return sftp.ErrSSHFxNoSuchFile
|
||||
case ProtocolWebDAV, ProtocolFTP:
|
||||
case ProtocolWebDAV, ProtocolFTP, ProtocolHTTP:
|
||||
return os.ErrNotExist
|
||||
default:
|
||||
return ErrNotExist
|
||||
|
|
|
@ -224,7 +224,7 @@ func TestErrorsMapping(t *testing.T) {
|
|||
err := conn.GetFsError(fs, os.ErrNotExist)
|
||||
if protocol == ProtocolSFTP {
|
||||
assert.EqualError(t, err, sftp.ErrSSHFxNoSuchFile.Error())
|
||||
} else if protocol == ProtocolWebDAV || protocol == ProtocolFTP {
|
||||
} else if protocol == ProtocolWebDAV || protocol == ProtocolFTP || protocol == ProtocolHTTP {
|
||||
assert.EqualError(t, err, os.ErrNotExist.Error())
|
||||
} else {
|
||||
assert.EqualError(t, err, ErrNotExist.Error())
|
||||
|
|
|
@ -65,6 +65,7 @@ var (
|
|||
Address: "127.0.0.1",
|
||||
Port: 8080,
|
||||
EnableWebAdmin: true,
|
||||
EnableWebClient: true,
|
||||
EnableHTTPS: false,
|
||||
ClientAuthType: 0,
|
||||
TLSCipherSuites: nil,
|
||||
|
@ -236,7 +237,7 @@ func Init() {
|
|||
TemplatesPath: "templates",
|
||||
StaticFilesPath: "static",
|
||||
BackupsPath: "backups",
|
||||
WebAdminRoot: "",
|
||||
WebRoot: "",
|
||||
CertificateFile: "",
|
||||
CertificateKeyFile: "",
|
||||
CACertificates: nil,
|
||||
|
@ -807,6 +808,12 @@ func getHTTPDBindingFromEnv(idx int) {
|
|||
isSet = true
|
||||
}
|
||||
|
||||
enableWebClient, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__ENABLE_WEB_CLIENT", idx))
|
||||
if ok {
|
||||
binding.EnableWebClient = enableWebClient
|
||||
isSet = true
|
||||
}
|
||||
|
||||
enableHTTPS, ok := lookupBoolFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__ENABLE_HTTPS", idx))
|
||||
if ok {
|
||||
binding.EnableHTTPS = enableHTTPS
|
||||
|
@ -952,7 +959,7 @@ func setViperDefaults() {
|
|||
viper.SetDefault("httpd.templates_path", globalConf.HTTPDConfig.TemplatesPath)
|
||||
viper.SetDefault("httpd.static_files_path", globalConf.HTTPDConfig.StaticFilesPath)
|
||||
viper.SetDefault("httpd.backups_path", globalConf.HTTPDConfig.BackupsPath)
|
||||
viper.SetDefault("httpd.web_admin_root", globalConf.HTTPDConfig.WebAdminRoot)
|
||||
viper.SetDefault("httpd.web_root", globalConf.HTTPDConfig.WebRoot)
|
||||
viper.SetDefault("httpd.certificate_file", globalConf.HTTPDConfig.CertificateFile)
|
||||
viper.SetDefault("httpd.certificate_key_file", globalConf.HTTPDConfig.CertificateKeyFile)
|
||||
viper.SetDefault("httpd.ca_certificates", globalConf.HTTPDConfig.CACertificates)
|
||||
|
|
|
@ -626,9 +626,11 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
|
|||
os.Setenv("SFTPGO_HTTPD__BINDINGS__1__PORT", "8000")
|
||||
os.Setenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_HTTPS", "0")
|
||||
os.Setenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_WEB_ADMIN", "1")
|
||||
os.Setenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_WEB_CLIENT", "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__CLIENT_AUTH_TYPE", "1")
|
||||
os.Setenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES", " TLS_AES_256_GCM_SHA384 , TLS_CHACHA20_POLY1305_SHA256")
|
||||
|
@ -640,10 +642,12 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
|
|||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__PORT")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_HTTPS")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_WEB_ADMIN")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__1__ENABLE_WEB_CLIENT")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ADDRESS")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__PORT")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_HTTPS")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_ADMIN")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__ENABLE_WEB_CLIENT")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__CLIENT_AUTH_TYPE")
|
||||
os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__TLS_CIPHER_SUITES")
|
||||
})
|
||||
|
@ -657,18 +661,21 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
|
|||
require.Equal(t, sockPath, bindings[0].Address)
|
||||
require.False(t, bindings[0].EnableHTTPS)
|
||||
require.True(t, bindings[0].EnableWebAdmin)
|
||||
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, 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, 9000, bindings[2].Port)
|
||||
require.Equal(t, "127.0.1.1", bindings[2].Address)
|
||||
require.True(t, bindings[2].EnableHTTPS)
|
||||
require.False(t, bindings[2].EnableWebAdmin)
|
||||
require.False(t, bindings[2].EnableWebClient)
|
||||
require.Equal(t, 1, bindings[2].ClientAuthType)
|
||||
require.Len(t, bindings[2].TLSCipherSuites, 2)
|
||||
require.Equal(t, "TLS_AES_256_GCM_SHA384", bindings[2].TLSCipherSuites[0])
|
||||
|
|
|
@ -111,7 +111,7 @@ func (p *BoltProvider) validateUserAndTLSCert(username, protocol string, tlsCert
|
|||
user, err := p.userExists(username)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)
|
||||
return user, ErrInvalidCredentials
|
||||
return user, err
|
||||
}
|
||||
return checkUserAndTLSCertificate(&user, protocol, tlsCert)
|
||||
}
|
||||
|
@ -124,7 +124,7 @@ func (p *BoltProvider) validateUserAndPass(username, password, ip, protocol stri
|
|||
user, err := p.userExists(username)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)
|
||||
return user, ErrInvalidCredentials
|
||||
return user, err
|
||||
}
|
||||
return checkUserAndPass(&user, password, ip, protocol)
|
||||
}
|
||||
|
@ -147,7 +147,7 @@ func (p *BoltProvider) validateUserAndPubKey(username string, pubKey []byte) (Us
|
|||
user, err := p.userExists(username)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)
|
||||
return user, "", ErrInvalidCredentials
|
||||
return user, "", err
|
||||
}
|
||||
return checkUserAndPubKey(&user, pubKey)
|
||||
}
|
||||
|
|
|
@ -112,7 +112,7 @@ var (
|
|||
// ErrNoAuthTryed defines the error for connection closed before authentication
|
||||
ErrNoAuthTryed = errors.New("no auth tryed")
|
||||
// ValidProtocols defines all the valid protcols
|
||||
ValidProtocols = []string{"SSH", "FTP", "DAV"}
|
||||
ValidProtocols = []string{"SSH", "FTP", "DAV", "HTTP"}
|
||||
// ErrNoInitRequired defines the error returned by InitProvider if no inizialization/update is required
|
||||
ErrNoInitRequired = errors.New("the data provider is up to date")
|
||||
// ErrInvalidCredentials defines the error to return if the supplied credentials are invalid
|
||||
|
@ -1387,6 +1387,11 @@ func validateFilters(user *User) error {
|
|||
return &ValidationError{err: fmt.Sprintf("invalid TLS username: %#v", user.Filters.TLSUsername)}
|
||||
}
|
||||
}
|
||||
for _, opts := range user.Filters.WebClient {
|
||||
if !utils.IsStringInSlice(opts, WebClientOptions) {
|
||||
return &ValidationError{err: fmt.Sprintf("invalid web client options %#v", opts)}
|
||||
}
|
||||
}
|
||||
return validateFileFilters(user)
|
||||
}
|
||||
|
||||
|
|
|
@ -96,7 +96,7 @@ func (p *MemoryProvider) validateUserAndTLSCert(username, protocol string, tlsCe
|
|||
user, err := p.userExists(username)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)
|
||||
return user, ErrInvalidCredentials
|
||||
return user, err
|
||||
}
|
||||
return checkUserAndTLSCertificate(&user, protocol, tlsCert)
|
||||
}
|
||||
|
@ -109,7 +109,7 @@ func (p *MemoryProvider) validateUserAndPass(username, password, ip, protocol st
|
|||
user, err := p.userExists(username)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)
|
||||
return user, ErrInvalidCredentials
|
||||
return user, err
|
||||
}
|
||||
return checkUserAndPass(&user, password, ip, protocol)
|
||||
}
|
||||
|
@ -122,7 +122,7 @@ func (p *MemoryProvider) validateUserAndPubKey(username string, pubKey []byte) (
|
|||
user, err := p.userExists(username)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)
|
||||
return user, "", ErrInvalidCredentials
|
||||
return user, "", err
|
||||
}
|
||||
return checkUserAndPubKey(&user, pubKey)
|
||||
}
|
||||
|
|
|
@ -224,7 +224,7 @@ func sqlCommonValidateUserAndPass(username, password, ip, protocol string, dbHan
|
|||
user, err := sqlCommonGetUserByUsername(username, dbHandle)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)
|
||||
return user, ErrInvalidCredentials
|
||||
return user, err
|
||||
}
|
||||
return checkUserAndPass(&user, password, ip, protocol)
|
||||
}
|
||||
|
@ -237,7 +237,7 @@ func sqlCommonValidateUserAndTLSCertificate(username, protocol string, tlsCert *
|
|||
user, err := sqlCommonGetUserByUsername(username, dbHandle)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)
|
||||
return user, ErrInvalidCredentials
|
||||
return user, err
|
||||
}
|
||||
return checkUserAndTLSCertificate(&user, protocol, tlsCert)
|
||||
}
|
||||
|
@ -250,7 +250,7 @@ func sqlCommonValidateUserAndPubKey(username string, pubKey []byte, dbHandle *sq
|
|||
user, err := sqlCommonGetUserByUsername(username, dbHandle)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelWarn, "error authenticating user %#v: %v", username, err)
|
||||
return user, "", ErrInvalidCredentials
|
||||
return user, "", err
|
||||
}
|
||||
return checkUserAndPubKey(&user, pubKey)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package dataprovider
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
@ -48,6 +50,16 @@ const (
|
|||
PermChtimes = "chtimes"
|
||||
)
|
||||
|
||||
// Web Client restrictions
|
||||
const (
|
||||
WebClientPubKeyChangeDisabled = "publickey-change-disabled"
|
||||
)
|
||||
|
||||
var (
|
||||
// WebClientOptions defines the available options for the web client interface
|
||||
WebClientOptions = []string{WebClientPubKeyChangeDisabled}
|
||||
)
|
||||
|
||||
// Available login methods
|
||||
const (
|
||||
LoginMethodNoAuthTryed = "no_auth_tryed"
|
||||
|
@ -160,6 +172,8 @@ type UserFilters struct {
|
|||
// these checks will speed up login.
|
||||
// You could, for example, disable these checks after the first login
|
||||
DisableFsChecks bool `json:"disable_fs_checks,omitempty"`
|
||||
// WebClient related configuration options
|
||||
WebClient []string `json:"web_client,omitempty"`
|
||||
}
|
||||
|
||||
// User defines a SFTPGo user
|
||||
|
@ -839,6 +853,21 @@ func (u *User) isFilePatternAllowed(virtualPath string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// CanManahePublicKeys return true if this user is allowed to manage public keys
|
||||
// from the web client
|
||||
func (u *User) CanManahePublicKeys() bool {
|
||||
return !utils.IsStringInSlice(WebClientPubKeyChangeDisabled, u.Filters.WebClient)
|
||||
}
|
||||
|
||||
// GetSignature returns a signature for this admin.
|
||||
// It could change after an update
|
||||
func (u *User) GetSignature() string {
|
||||
data := []byte(fmt.Sprintf("%v_%v_%v", u.Username, u.Status, u.ExpirationDate))
|
||||
data = append(data, []byte(u.Password)...)
|
||||
signature := sha256.Sum256(data)
|
||||
return base64.StdEncoding.EncodeToString(signature[:])
|
||||
}
|
||||
|
||||
// IsLoginFromAddrAllowed returns true if the login is allowed from the specified remoteAddr.
|
||||
// If AllowedIP is defined only the specified IP/Mask can login.
|
||||
// If DeniedIP is defined the specified IP/Mask cannot login.
|
||||
|
@ -1094,6 +1123,8 @@ func (u *User) getACopy() User {
|
|||
filters.Hooks.PreLoginDisabled = u.Filters.Hooks.PreLoginDisabled
|
||||
filters.Hooks.CheckPasswordDisabled = u.Filters.Hooks.CheckPasswordDisabled
|
||||
filters.DisableFsChecks = u.Filters.DisableFsChecks
|
||||
filters.WebClient = make([]string, len(u.Filters.WebClient))
|
||||
copy(filters.WebClient, u.Filters.WebClient)
|
||||
|
||||
return User{
|
||||
ID: u.ID,
|
||||
|
|
|
@ -16,7 +16,7 @@ If the hook defines an external program it can read the following environment va
|
|||
- `SFTPGO_AUTHD_USERNAME`
|
||||
- `SFTPGO_AUTHD_PASSWORD`
|
||||
- `SFTPGO_AUTHD_IP`
|
||||
- `SFTPGO_AUTHD_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`
|
||||
- `SFTPGO_AUTHD_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP`
|
||||
|
||||
Previous global environment variables aren't cleared when the script is called. The content of these variables is _not_ quoted. They may contain special characters. They are under the control of a possibly malicious remote user.
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ The external program can read the following environment variables to get info ab
|
|||
- `SFTPGO_LOGIND_USER`, it contains the user trying to login serialized as JSON. A JSON serialized user id equal to zero means the user does not exist inside SFTPGo
|
||||
- `SFTPGO_LOGIND_METHOD`, possible values are: `password`, `publickey`, `keyboard-interactive`, `TLSCertificate`
|
||||
- `SFTPGO_LOGIND_IP`, ip address of the user trying to login
|
||||
- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`
|
||||
- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP`
|
||||
|
||||
The program must write, on its standard output:
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ The external program can read the following environment variables to get info ab
|
|||
- `SFTPGO_AUTHD_USERNAME`
|
||||
- `SFTPGO_AUTHD_USER`, STPGo user serialized as JSON, empty if the user does not exist within the data provider
|
||||
- `SFTPGO_AUTHD_IP`
|
||||
- `SFTPGO_AUTHD_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`
|
||||
- `SFTPGO_AUTHD_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP`
|
||||
- `SFTPGO_AUTHD_PASSWORD`, not empty for password authentication
|
||||
- `SFTPGO_AUTHD_PUBLIC_KEY`, not empty for public key authentication
|
||||
- `SFTPGO_AUTHD_KEYBOARD_INTERACTIVE`, not empty for keyboard interactive authentication
|
||||
|
@ -26,7 +26,7 @@ If the hook is an HTTP URL then it will be invoked as HTTP POST. The request bod
|
|||
- `username`
|
||||
- `ip`
|
||||
- `user`, STPGo user serialized as JSON, omitted if the user does not exist within the data provider
|
||||
- `protocol`, possible values are `SSH`, `FTP`, `DAV`
|
||||
- `protocol`, possible values are `SSH`, `FTP`, `DAV`, `HTTP`
|
||||
- `password`, not empty for password authentication
|
||||
- `public_key`, not empty for public key authentication
|
||||
- `keyboard_interactive`, not empty for keyboard interactive authentication
|
||||
|
|
|
@ -210,7 +210,8 @@ The configuration file contains the following sections:
|
|||
- `bindings`, list of structs. Each struct has the following fields:
|
||||
- `port`, integer. The port used for serving HTTP requests. Default: 8080.
|
||||
- `address`, string. Leave blank to listen on all available network interfaces. On *NIX you can specify an absolute path to listen on a Unix-domain socket Default: "127.0.0.1".
|
||||
- `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 enable 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_https`, boolean. Set to `true` and provide both a certificate and a key file to enable HTTPS connection for this binding. Default `false`.
|
||||
- `client_auth_type`, integer. Set to `1` to require client certificate authentication in addition to JWT/Web authentication. You need to define at least a certificate authority for this to work. Default: 0.
|
||||
- `tls_cipher_suites`, list of strings. List of supported cipher suites for TLS version 1.2. If empty, a default list of secure cipher suites is used, with a preference order based on hardware performance. Note that TLS 1.3 ciphersuites are not configurable. The supported ciphersuites names are defined [here](https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L52). Any invalid name will be silently ignored. The order matters, the ciphers listed first will be the preferred ones. Default: empty.
|
||||
|
@ -219,7 +220,7 @@ The configuration file contains the following sections:
|
|||
- `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
|
||||
- `web_admin_root`, string. Defines a base URL for the web admin. If empty web admin resources will be available at the root ("/") URI. If defined it must be an absolute URI or it will be ignored
|
||||
- `web_root`, string. Defines a base URL for the web admin and client interfaces. If empty web admin and client resources will be available at the root ("/") URI. If defined it must be an absolute URI or it will be ignored
|
||||
- `certificate_file`, string. Certificate for HTTPS. This can be an absolute path or a path relative to the config dir.
|
||||
- `certificate_key_file`, string. Private key matching the above certificate. This can be an absolute path or a path relative to the config dir. If both the certificate and the private key are provided, the server will expect HTTPS connections. Certificate and key files can be reloaded on demand sending a `SIGHUP` signal on Unix based systems and a `paramchange` request to the running service on Windows.
|
||||
- `ca_certificates`, list of strings. Set of root certificate authorities to be used to verify client certificates.
|
||||
|
|
|
@ -200,7 +200,7 @@ sudo systemctl restart sftpgo
|
|||
|
||||
So now open the Web Admin URL.
|
||||
|
||||
[http://127.0.0.1:8080/web](http://127.0.0.1:8080/web)
|
||||
[http://127.0.0.1:8080/web/admin](http://127.0.0.1:8080/web/admin)
|
||||
|
||||
Click `Add` and fill the user details, the minimum required parameters are:
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ If the hook defines an external program it can reads the following environment v
|
|||
- `SFTPGO_LOGIND_IP`
|
||||
- `SFTPGO_LOGIND_METHOD`, possible values are `publickey`, `password`, `keyboard-interactive`, `publickey+password`, `publickey+keyboard-interactive`, `TLSCertificate`, `TLSCertificate+password` or `no_auth_tryed`
|
||||
- `SFTPGO_LOGIND_STATUS`, 1 means login OK, 0 login KO
|
||||
- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`
|
||||
- `SFTPGO_LOGIND_PROTOCOL`, possible values are `SSH`, `FTP`, `DAV`, `HTTP`
|
||||
|
||||
Previous global environment variables aren't cleared when the script is called.
|
||||
The program must finish within 20 seconds.
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
You can easily build your own interface using the exposed [REST API](./rest-api.md). Anyway, SFTPGo also provides a basic built-in web interface that allows you to manage users, virtual folders, admins and connections.
|
||||
With the default `httpd` configuration, the web admin is available at the following URL:
|
||||
|
||||
[http://127.0.0.1:8080/web](http://127.0.0.1:8080/web)
|
||||
[http://127.0.0.1:8080/web/admin](http://127.0.0.1:8080/web/admin)
|
||||
|
||||
The default credentials are:
|
||||
|
||||
|
|
10
docs/web-client.md
Normal file
10
docs/web-client.md
Normal file
|
@ -0,0 +1,10 @@
|
|||
# Web Client
|
||||
|
||||
SFTPGo provides a basic front-end web interface for your users. It allows end-users to browse and download their files and change their credentials.
|
||||
|
||||
The web interface can be globally disabled within the `httpd` configuration via the `enable_web_client` key or on a per-user basis by adding `HTTP` to the denied protocols.
|
||||
Public keys management can be disabled, per-user, using a specific permission.
|
||||
|
||||
With the default `httpd` configuration, the web admin is available at the following URL:
|
||||
|
||||
[http://127.0.0.1:8080/web/client](http://127.0.0.1:8080/web/client)
|
|
@ -1,6 +1,10 @@
|
|||
package ftpd_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
@ -200,12 +204,45 @@ func TestResumeCryptFs(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
assert.Equal(t, data, readed)
|
||||
}
|
||||
// now test a download resume using a bigger file
|
||||
testFileSize := int64(655352)
|
||||
err = createTestFile(testFilePath, testFileSize)
|
||||
assert.NoError(t, err)
|
||||
initialHash, err := computeHashForFile(sha256.New(), testFilePath)
|
||||
assert.NoError(t, err)
|
||||
err = ftpUploadFile(testFilePath, testFileName, testFileSize, client, 0)
|
||||
assert.NoError(t, err)
|
||||
err = ftpDownloadFile(testFileName, localDownloadPath, testFileSize, client, 0)
|
||||
assert.NoError(t, err)
|
||||
downloadHash, err := computeHashForFile(sha256.New(), localDownloadPath)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, initialHash, downloadHash)
|
||||
err = os.Truncate(localDownloadPath, 32767)
|
||||
assert.NoError(t, err)
|
||||
err = ftpDownloadFile(testFileName, localDownloadPath+"_partial", testFileSize-32767, client, 32767)
|
||||
assert.NoError(t, err)
|
||||
file, err := os.OpenFile(localDownloadPath, os.O_APPEND|os.O_WRONLY, os.ModePerm)
|
||||
assert.NoError(t, err)
|
||||
file1, err := os.Open(localDownloadPath + "_partial")
|
||||
assert.NoError(t, err)
|
||||
_, err = io.Copy(file, file1)
|
||||
assert.NoError(t, err)
|
||||
err = file.Close()
|
||||
assert.NoError(t, err)
|
||||
err = file1.Close()
|
||||
assert.NoError(t, err)
|
||||
downloadHash, err = computeHashForFile(sha256.New(), localDownloadPath)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, initialHash, downloadHash)
|
||||
|
||||
err = client.Quit()
|
||||
assert.NoError(t, err)
|
||||
err = os.Remove(testFilePath)
|
||||
assert.NoError(t, err)
|
||||
err = os.Remove(localDownloadPath)
|
||||
assert.NoError(t, err)
|
||||
err = os.Remove(localDownloadPath + "_partial")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
_, err = httpdtest.RemoveUser(user, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
|
@ -224,3 +261,17 @@ func getEncryptedFileSize(size int64) (int64, error) {
|
|||
encSize, err := sio.EncryptedSize(uint64(size))
|
||||
return int64(encSize) + 33, err
|
||||
}
|
||||
|
||||
func computeHashForFile(hasher hash.Hash, path string) (string, error) {
|
||||
hash := ""
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return hash, err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(hasher, f)
|
||||
if err == nil {
|
||||
hash = fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
}
|
||||
return hash, err
|
||||
}
|
||||
|
|
|
@ -181,7 +181,7 @@ func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string)
|
|||
if err != nil {
|
||||
user.Username = username
|
||||
updateLoginMetrics(&user, ipAddr, loginMethod, err)
|
||||
return nil, err
|
||||
return nil, dataprovider.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
connection, err := s.validateUser(user, cc, loginMethod)
|
||||
|
@ -211,7 +211,7 @@ func (s *Server) VerifyConnection(cc ftpserver.ClientContext, user string, tlsCo
|
|||
if err != nil {
|
||||
dbUser.Username = user
|
||||
updateLoginMetrics(&dbUser, ipAddr, dataprovider.LoginMethodTLSCertificate, err)
|
||||
return nil, err
|
||||
return nil, dataprovider.ErrInvalidCredentials
|
||||
}
|
||||
if dbUser.IsTLSUsernameVerificationEnabled() {
|
||||
dbUser, err = dataprovider.CheckUserAndTLSCert(user, ipAddr, common.ProtocolFTP, state.PeerCertificates[0])
|
||||
|
|
23
go.mod
23
go.mod
|
@ -8,18 +8,18 @@ require (
|
|||
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
|
||||
github.com/StackExchange/wmi v0.0.0-20210224194228-fe8f1750fd46 // indirect
|
||||
github.com/alexedwards/argon2id v0.0.0-20210326052512-e2135f7c9c77
|
||||
github.com/aws/aws-sdk-go v1.38.30
|
||||
github.com/aws/aws-sdk-go v1.38.35
|
||||
github.com/cockroachdb/cockroach-go/v2 v2.1.1
|
||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect
|
||||
github.com/eikenb/pipeat v0.0.0-20200430215831-470df5986b6d
|
||||
github.com/fclairamb/ftpserverlib v0.13.1
|
||||
github.com/frankban/quicktest v1.12.0 // indirect
|
||||
github.com/frankban/quicktest v1.12.1 // indirect
|
||||
github.com/go-chi/chi/v5 v5.0.3
|
||||
github.com/go-chi/jwtauth/v5 v5.0.1
|
||||
github.com/go-chi/render v1.0.1
|
||||
github.com/go-ole/go-ole v1.2.5 // indirect
|
||||
github.com/go-sql-driver/mysql v1.6.0
|
||||
github.com/goccy/go-json v0.4.13 // indirect
|
||||
github.com/goccy/go-json v0.4.14 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/snappy v0.0.3 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
|
@ -45,7 +45,7 @@ require (
|
|||
github.com/pires/go-proxyproto v0.5.0
|
||||
github.com/pkg/sftp v1.13.0
|
||||
github.com/prometheus/client_golang v1.10.0
|
||||
github.com/prometheus/common v0.21.0 // indirect
|
||||
github.com/prometheus/common v0.23.0 // indirect
|
||||
github.com/rs/cors v1.7.1-0.20200626170627-8b4a00bd362b
|
||||
github.com/rs/xid v1.3.0
|
||||
github.com/rs/zerolog v1.21.0
|
||||
|
@ -57,18 +57,19 @@ require (
|
|||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/viper v1.7.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/studio-b12/gowebdav v0.0.0-20210203212356-8244b5a5f51a
|
||||
github.com/studio-b12/gowebdav v0.0.0-20210427212133-86f8378cf140
|
||||
github.com/yl2chen/cidranger v1.0.2
|
||||
go.etcd.io/bbolt v1.3.5
|
||||
go.uber.org/automaxprocs v1.4.0
|
||||
gocloud.dev v0.22.0
|
||||
gocloud.dev/secrets/hashivault v0.22.0
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b
|
||||
golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf
|
||||
golang.org/x/mod v0.4.2 // indirect
|
||||
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6
|
||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7
|
||||
golang.org/x/net v0.0.0-20210505214959-0714010a04ed
|
||||
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6
|
||||
golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba
|
||||
google.golang.org/api v0.45.0
|
||||
google.golang.org/api v0.46.0
|
||||
google.golang.org/genproto v0.0.0-20210506142907-4a47615972c2 // indirect
|
||||
gopkg.in/ini.v1 v1.62.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
|
@ -76,6 +77,6 @@ require (
|
|||
|
||||
replace (
|
||||
github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
|
||||
golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20210425150317-089e67b931c9
|
||||
golang.org/x/net => github.com/drakkan/net v0.0.0-20210425150243-76901d0d25a8
|
||||
golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20210506192352-d548717d8e01
|
||||
golang.org/x/net => github.com/drakkan/net v0.0.0-20210506192712-36fee9de0f7d
|
||||
)
|
||||
|
|
43
go.sum
43
go.sum
|
@ -127,8 +127,8 @@ github.com/aws/aws-sdk-go v1.25.37/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpi
|
|||
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
|
||||
github.com/aws/aws-sdk-go v1.30.27/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
|
||||
github.com/aws/aws-sdk-go v1.36.1/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/aws/aws-sdk-go v1.38.30 h1:X+JDSwkpSQfoLqH4fBLmS0rou8W/cdCCCD5lntTk9Vs=
|
||||
github.com/aws/aws-sdk-go v1.38.30/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/aws/aws-sdk-go v1.38.35 h1:7AlAO0FC+8nFjxiGKEmq0QLpiA8/XFr6eIxgRTwkdTg=
|
||||
github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
|
@ -203,12 +203,12 @@ github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4Kfc
|
|||
github.com/docker/docker v1.4.2-0.20200319182547-c7ad2b866182/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
||||
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/drakkan/crypto v0.0.0-20210425150317-089e67b931c9 h1:YbA8/6NJyj8440KNCDlEKEWfzjqjPcJj3Pwb5NGen0Q=
|
||||
github.com/drakkan/crypto v0.0.0-20210425150317-089e67b931c9/go.mod h1:L66LXwR5jyrcyKbWcx1/J/356ka7Lqn6zsxuVf6JhiI=
|
||||
github.com/drakkan/crypto v0.0.0-20210506192352-d548717d8e01 h1:OBfr3EGq6DjnxhskpY5Q5TYoMUyJexusO9153DaFad0=
|
||||
github.com/drakkan/crypto v0.0.0-20210506192352-d548717d8e01/go.mod h1:M1JpE4lvRI5LLrE7yTCWfhbsy5rx3oZVjGZad4XMwWc=
|
||||
github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA=
|
||||
github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
|
||||
github.com/drakkan/net v0.0.0-20210425150243-76901d0d25a8 h1:ENgPnm7K0a6nQbS/i+o/CvrTr2VRxN/r/he71GkT1+8=
|
||||
github.com/drakkan/net v0.0.0-20210425150243-76901d0d25a8/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
github.com/drakkan/net v0.0.0-20210506192712-36fee9de0f7d h1:DUoPZEMoXDONtqVGBv8sGpYqZ0HgpWtdM3v9g2+dZAI=
|
||||
github.com/drakkan/net v0.0.0-20210506192712-36fee9de0f7d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
|
||||
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
|
||||
|
@ -235,8 +235,8 @@ github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHqu
|
|||
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
|
||||
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
|
||||
github.com/frankban/quicktest v1.10.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
|
||||
github.com/frankban/quicktest v1.12.0 h1:qlT496aO6ryji0l8Of/hVQCRkqNVyIbJtUZ1zDp6xyw=
|
||||
github.com/frankban/quicktest v1.12.0/go.mod h1:qLE0fzW0VuyUAJgPU19zByoIr0HtCHN/r/VLSOOIySU=
|
||||
github.com/frankban/quicktest v1.12.1 h1:P6vQcHwZYgVGIpUzKB5DXzkEeYJppJOStPLuh9aB89c=
|
||||
github.com/frankban/quicktest v1.12.1/go.mod h1:qLE0fzW0VuyUAJgPU19zByoIr0HtCHN/r/VLSOOIySU=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
|
@ -288,8 +288,8 @@ github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22
|
|||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||
github.com/goccy/go-json v0.4.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.4.13 h1:ZclJ9a9KEbGUxWkqPkh7ZscnnPAsGIinyg/dJlIph1Y=
|
||||
github.com/goccy/go-json v0.4.13/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.4.14 h1:RR3AVWMEfVW0Z/DbfhxiLrv5mYlwlUmCK8jMtyCcSls=
|
||||
github.com/goccy/go-json v0.4.14/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
|
||||
|
@ -751,8 +751,8 @@ github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt2
|
|||
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
|
||||
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
|
||||
github.com/prometheus/common v0.18.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s=
|
||||
github.com/prometheus/common v0.21.0 h1:SMvI2JVldvfUvRVlP64jkIJEC6WiGHJcN2e5tB+ztF8=
|
||||
github.com/prometheus/common v0.21.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s=
|
||||
github.com/prometheus/common v0.23.0 h1:GXWvPYuTUenIa+BhOq/x+L/QZzCqASkVRny5KTlPDGM=
|
||||
github.com/prometheus/common v0.23.0/go.mod h1:H6QK/N6XVT42whUeIdI3dp36w49c+/iMDk7UAI2qm7Q=
|
||||
github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
|
@ -840,8 +840,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
|
|||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/studio-b12/gowebdav v0.0.0-20210203212356-8244b5a5f51a h1:Zq18I/ONL/ynTg+mhn78lyh14vNjOqaWfLKVZDwFUk4=
|
||||
github.com/studio-b12/gowebdav v0.0.0-20210203212356-8244b5a5f51a/go.mod h1:gCcfDlA1Y7GqOaeEKw5l9dOGx1VLdc/HuQSlQAaZ30s=
|
||||
github.com/studio-b12/gowebdav v0.0.0-20210427212133-86f8378cf140 h1:JCSn/2k3AQ0aJGs5Yx2xv6qrW0CAULc1E+xtSxeeQ/E=
|
||||
github.com/studio-b12/gowebdav v0.0.0-20210427212133-86f8378cf140/go.mod h1:gCcfDlA1Y7GqOaeEKw5l9dOGx1VLdc/HuQSlQAaZ30s=
|
||||
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/tklauser/go-sysconf v0.3.4/go.mod h1:Cl2c8ZRWfHD5IrfHo9VN+FX9kCFjIOyVklgXycLB6ek=
|
||||
|
@ -943,8 +943,9 @@ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ
|
|||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78 h1:rPRtHfUb0UKZeZ6GH4K4Nt4YRbE9V1u+QZX5upZXqJQ=
|
||||
golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c h1:SgVl/sCtkicsS7psKkje4H9YtjdEl3xsYh7N+5TDHqY=
|
||||
golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
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=
|
||||
|
@ -1023,8 +1024,9 @@ golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7 h1:iGu644GcxtEcrInvDsQRCwJjtCIOlT2V7IRt6ah2Whw=
|
||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6 h1:cdsMqa2nXzqlgs183pHxtvoVwU7CyzaCTAUOg94af4c=
|
||||
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
@ -1145,8 +1147,9 @@ google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34q
|
|||
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
|
||||
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
|
||||
google.golang.org/api v0.45.0 h1:pqMffJFLBVUDIoYsHcqtxgQVTsmxMDpYLOc5MT4Jrww=
|
||||
google.golang.org/api v0.45.0/go.mod h1:ISLIJCedJolbZvDfAk+Ctuq5hf+aJ33WgtUsfyFoLXA=
|
||||
google.golang.org/api v0.46.0 h1:jkDWHOBIoNSD0OQpq4rtBVu+Rh325MPjXG1rakAp8JU=
|
||||
google.golang.org/api v0.46.0/go.mod h1:ceL4oozhkAiTID8XMmJBsIxID/9wMXJVVFXPg4ylg3I=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
|
@ -1204,8 +1207,10 @@ google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6D
|
|||
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
||||
google.golang.org/genproto v0.0.0-20210413151531-c14fb6ef47c3/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
||||
google.golang.org/genproto v0.0.0-20210420162539-3c870d7478d2 h1:g2sJMUGCpeHZqTx8p3wsAWRS64nFq20i4dvJWcKGqvY=
|
||||
google.golang.org/genproto v0.0.0-20210420162539-3c870d7478d2/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
||||
google.golang.org/genproto v0.0.0-20210429181445-86c259c2b4ab/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
||||
google.golang.org/genproto v0.0.0-20210506142907-4a47615972c2 h1:pl8qT5D+48655f14yDURpIZwSPvMWuuekfAP+gxtjvk=
|
||||
google.golang.org/genproto v0.0.0-20210506142907-4a47615972c2/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
|
||||
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
|
|
|
@ -18,9 +18,10 @@ import (
|
|||
type tokenAudience = string
|
||||
|
||||
const (
|
||||
tokenAudienceWeb tokenAudience = "Web"
|
||||
tokenAudienceAPI tokenAudience = "API"
|
||||
tokenAudienceCSRF tokenAudience = "CSRF"
|
||||
tokenAudienceWebAdmin tokenAudience = "WebAdmin"
|
||||
tokenAudienceWebClient tokenAudience = "WebClient"
|
||||
tokenAudienceAPI tokenAudience = "API"
|
||||
tokenAudienceCSRF tokenAudience = "CSRF"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -119,15 +120,21 @@ func (c *jwtTokenClaims) createTokenResponse(tokenAuth *jwtauth.JWTAuth, audienc
|
|||
return response, nil
|
||||
}
|
||||
|
||||
func (c *jwtTokenClaims) createAndSetCookie(w http.ResponseWriter, r *http.Request, tokenAuth *jwtauth.JWTAuth) error {
|
||||
resp, err := c.createTokenResponse(tokenAuth, tokenAudienceWeb)
|
||||
func (c *jwtTokenClaims) createAndSetCookie(w http.ResponseWriter, r *http.Request, tokenAuth *jwtauth.JWTAuth, audience tokenAudience) error {
|
||||
resp, err := c.createTokenResponse(tokenAuth, audience)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var basePath string
|
||||
if audience == tokenAudienceWebAdmin {
|
||||
basePath = webBaseAdminPath
|
||||
} else {
|
||||
basePath = webBaseClientPath
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "jwt",
|
||||
Value: resp["access_token"].(string),
|
||||
Path: webBasePath,
|
||||
Path: basePath,
|
||||
Expires: time.Now().Add(tokenDuration),
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
|
@ -178,6 +185,19 @@ func invalidateToken(r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func getUserFromToken(r *http.Request) *dataprovider.User {
|
||||
user := &dataprovider.User{}
|
||||
_, claims, err := jwtauth.FromContext(r.Context())
|
||||
if err != nil {
|
||||
return user
|
||||
}
|
||||
tokenClaims := jwtTokenClaims{}
|
||||
tokenClaims.Decode(claims)
|
||||
user.Username = tokenClaims.Username
|
||||
user.Filters.WebClient = tokenClaims.Permissions
|
||||
return user
|
||||
}
|
||||
|
||||
func getAdminFromToken(r *http.Request) *dataprovider.Admin {
|
||||
admin := &dataprovider.Admin{}
|
||||
_, claims, err := jwtauth.FromContext(r.Context())
|
||||
|
|
87
httpd/file.go
Normal file
87
httpd/file.go
Normal file
|
@ -0,0 +1,87 @@
|
|||
package httpd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/eikenb/pipeat"
|
||||
|
||||
"github.com/drakkan/sftpgo/common"
|
||||
)
|
||||
|
||||
var errTransferAborted = errors.New("transfer aborted")
|
||||
|
||||
type httpdFile struct {
|
||||
*common.BaseTransfer
|
||||
reader io.ReadCloser
|
||||
isFinished bool
|
||||
}
|
||||
|
||||
func newHTTPDFile(baseTransfer *common.BaseTransfer, pipeReader *pipeat.PipeReaderAt) *httpdFile {
|
||||
var reader io.ReadCloser
|
||||
if baseTransfer.File != nil {
|
||||
reader = baseTransfer.File
|
||||
} else if pipeReader != nil {
|
||||
reader = pipeReader
|
||||
}
|
||||
return &httpdFile{
|
||||
BaseTransfer: baseTransfer,
|
||||
reader: reader,
|
||||
isFinished: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Read reads the contents to downloads.
|
||||
func (f *httpdFile) Read(p []byte) (n int, err error) {
|
||||
if atomic.LoadInt32(&f.AbortTransfer) == 1 {
|
||||
return 0, errTransferAborted
|
||||
}
|
||||
|
||||
f.Connection.UpdateLastActivity()
|
||||
|
||||
n, err = f.reader.Read(p)
|
||||
atomic.AddInt64(&f.BytesSent, int64(n))
|
||||
|
||||
if err != nil && err != io.EOF {
|
||||
f.TransferError(err)
|
||||
return
|
||||
}
|
||||
f.HandleThrottle()
|
||||
return
|
||||
}
|
||||
|
||||
// Close closes the current transfer
|
||||
func (f *httpdFile) Close() error {
|
||||
if err := f.setFinished(); err != nil {
|
||||
return err
|
||||
}
|
||||
err := f.closeIO()
|
||||
errBaseClose := f.BaseTransfer.Close()
|
||||
if errBaseClose != nil {
|
||||
err = errBaseClose
|
||||
}
|
||||
|
||||
return f.Connection.GetFsError(f.Fs, err)
|
||||
}
|
||||
|
||||
func (f *httpdFile) closeIO() error {
|
||||
var err error
|
||||
if f.File != nil {
|
||||
err = f.File.Close()
|
||||
} else if f.reader != nil {
|
||||
err = f.reader.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *httpdFile) setFinished() error {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
|
||||
if f.isFinished {
|
||||
return common.ErrTransferClosed
|
||||
}
|
||||
f.isFinished = true
|
||||
return nil
|
||||
}
|
104
httpd/handler.go
Normal file
104
httpd/handler.go
Normal file
|
@ -0,0 +1,104 @@
|
|||
package httpd
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/drakkan/sftpgo/common"
|
||||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
)
|
||||
|
||||
// Connection details for a HTTP connection used to inteact with an SFTPGo filesystem
|
||||
type Connection struct {
|
||||
*common.BaseConnection
|
||||
request *http.Request
|
||||
}
|
||||
|
||||
// GetClientVersion returns the connected client's version.
|
||||
func (c *Connection) GetClientVersion() string {
|
||||
if c.request != nil {
|
||||
return c.request.UserAgent()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetRemoteAddress return the connected client's address
|
||||
func (c *Connection) GetRemoteAddress() string {
|
||||
if c.request != nil {
|
||||
return c.request.RemoteAddr
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Disconnect closes the active transfer
|
||||
func (c *Connection) Disconnect() (err error) {
|
||||
return c.SignalTransfersAbort()
|
||||
}
|
||||
|
||||
// GetCommand returns the request method
|
||||
func (c *Connection) GetCommand() string {
|
||||
if c.request != nil {
|
||||
return strings.ToUpper(c.request.Method)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Stat returns a FileInfo describing the named file/directory, or an error,
|
||||
// if any happens
|
||||
func (c *Connection) Stat(name string, mode int) (os.FileInfo, error) {
|
||||
c.UpdateLastActivity()
|
||||
|
||||
name = utils.CleanPath(name)
|
||||
if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(name)) {
|
||||
return nil, c.GetPermissionDeniedError()
|
||||
}
|
||||
|
||||
fi, err := c.DoStat(name, mode)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelDebug, "error running stat on path %#v: %+v", name, err)
|
||||
return nil, err
|
||||
}
|
||||
return fi, err
|
||||
}
|
||||
|
||||
// ReadDir returns a list of directory entries
|
||||
func (c *Connection) ReadDir(name string) ([]os.FileInfo, error) {
|
||||
c.UpdateLastActivity()
|
||||
|
||||
name = utils.CleanPath(name)
|
||||
return c.ListDir(name)
|
||||
}
|
||||
|
||||
func (c *Connection) getFileReader(name string, offset int64) (io.ReadCloser, error) {
|
||||
c.UpdateLastActivity()
|
||||
|
||||
name = utils.CleanPath(name)
|
||||
if !c.User.HasPerm(dataprovider.PermDownload, path.Dir(name)) {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
if !c.User.IsFileAllowed(name) {
|
||||
c.Log(logger.LevelWarn, "reading file %#v is not allowed", name)
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
fs, p, err := c.GetFsAndResolvedPath(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, r, cancelFn, err := fs.Open(p, offset)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelWarn, "could not open file %#v for reading: %+v", p, err)
|
||||
return nil, c.GetFsError(fs, err)
|
||||
}
|
||||
|
||||
baseTransfer := common.NewBaseTransfer(file, c.BaseConnection, cancelFn, p, name, common.TransferDownload,
|
||||
0, 0, 0, false, fs)
|
||||
return newHTTPDFile(baseTransfer, r), nil
|
||||
}
|
230
httpd/httpd.go
230
httpd/httpd.go
|
@ -29,47 +29,55 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
logSender = "httpd"
|
||||
tokenPath = "/api/v2/token"
|
||||
logoutPath = "/api/v2/logout"
|
||||
activeConnectionsPath = "/api/v2/connections"
|
||||
quotaScanPath = "/api/v2/quota-scans"
|
||||
quotaScanVFolderPath = "/api/v2/folder-quota-scans"
|
||||
userPath = "/api/v2/users"
|
||||
versionPath = "/api/v2/version"
|
||||
folderPath = "/api/v2/folders"
|
||||
serverStatusPath = "/api/v2/status"
|
||||
dumpDataPath = "/api/v2/dumpdata"
|
||||
loadDataPath = "/api/v2/loaddata"
|
||||
updateUsedQuotaPath = "/api/v2/quota-update"
|
||||
updateFolderUsedQuotaPath = "/api/v2/folder-quota-update"
|
||||
defenderBanTime = "/api/v2/defender/bantime"
|
||||
defenderUnban = "/api/v2/defender/unban"
|
||||
defenderScore = "/api/v2/defender/score"
|
||||
adminPath = "/api/v2/admins"
|
||||
adminPwdPath = "/api/v2/changepwd/admin"
|
||||
healthzPath = "/healthz"
|
||||
webRootPathDefault = "/"
|
||||
webBasePathDefault = "/web"
|
||||
webLoginPathDefault = "/web/login"
|
||||
webLogoutPathDefault = "/web/logout"
|
||||
webUsersPathDefault = "/web/users"
|
||||
webUserPathDefault = "/web/user"
|
||||
webConnectionsPathDefault = "/web/connections"
|
||||
webFoldersPathDefault = "/web/folders"
|
||||
webFolderPathDefault = "/web/folder"
|
||||
webStatusPathDefault = "/web/status"
|
||||
webAdminsPathDefault = "/web/admins"
|
||||
webAdminPathDefault = "/web/admin"
|
||||
webMaintenancePathDefault = "/web/maintenance"
|
||||
webBackupPathDefault = "/web/backup"
|
||||
webRestorePathDefault = "/web/restore"
|
||||
webScanVFolderPathDefault = "/web/folder-quota-scans"
|
||||
webQuotaScanPathDefault = "/web/quota-scans"
|
||||
webChangeAdminPwdPathDefault = "/web/changepwd/admin"
|
||||
webTemplateUserDefault = "/web/template/user"
|
||||
webTemplateFolderDefault = "/web/template/folder"
|
||||
webStaticFilesPathDefault = "/static"
|
||||
logSender = "httpd"
|
||||
tokenPath = "/api/v2/token"
|
||||
logoutPath = "/api/v2/logout"
|
||||
activeConnectionsPath = "/api/v2/connections"
|
||||
quotaScanPath = "/api/v2/quota-scans"
|
||||
quotaScanVFolderPath = "/api/v2/folder-quota-scans"
|
||||
userPath = "/api/v2/users"
|
||||
versionPath = "/api/v2/version"
|
||||
folderPath = "/api/v2/folders"
|
||||
serverStatusPath = "/api/v2/status"
|
||||
dumpDataPath = "/api/v2/dumpdata"
|
||||
loadDataPath = "/api/v2/loaddata"
|
||||
updateUsedQuotaPath = "/api/v2/quota-update"
|
||||
updateFolderUsedQuotaPath = "/api/v2/folder-quota-update"
|
||||
defenderBanTime = "/api/v2/defender/bantime"
|
||||
defenderUnban = "/api/v2/defender/unban"
|
||||
defenderScore = "/api/v2/defender/score"
|
||||
adminPath = "/api/v2/admins"
|
||||
adminPwdPath = "/api/v2/changepwd/admin"
|
||||
healthzPath = "/healthz"
|
||||
webRootPathDefault = "/"
|
||||
webBasePathDefault = "/web"
|
||||
webBasePathAdminDefault = "/web/admin"
|
||||
webBasePathClientDefault = "/web/client"
|
||||
webLoginPathDefault = "/web/admin/login"
|
||||
webLogoutPathDefault = "/web/admin/logout"
|
||||
webUsersPathDefault = "/web/admin/users"
|
||||
webUserPathDefault = "/web/admin/user"
|
||||
webConnectionsPathDefault = "/web/admin/connections"
|
||||
webFoldersPathDefault = "/web/admin/folders"
|
||||
webFolderPathDefault = "/web/admin/folder"
|
||||
webStatusPathDefault = "/web/admin/status"
|
||||
webAdminsPathDefault = "/web/admin/managers"
|
||||
webAdminPathDefault = "/web/admin/manager"
|
||||
webMaintenancePathDefault = "/web/admin/maintenance"
|
||||
webBackupPathDefault = "/web/admin/backup"
|
||||
webRestorePathDefault = "/web/admin/restore"
|
||||
webScanVFolderPathDefault = "/web/admin/folder-quota-scans"
|
||||
webQuotaScanPathDefault = "/web/admin/quota-scans"
|
||||
webChangeAdminPwdPathDefault = "/web/admin/changepwd"
|
||||
webTemplateUserDefault = "/web/admin/template/user"
|
||||
webTemplateFolderDefault = "/web/admin/template/folder"
|
||||
webClientLoginPathDefault = "/web/client/login"
|
||||
webClientFilesPathDefault = "/web/client/files"
|
||||
webClientCredentialsPathDefault = "/web/client/credentials"
|
||||
webChangeClientPwdPathDefault = "/web/client/changepwd"
|
||||
webChangeClientKeysPathDefault = "/web/client/managekeys"
|
||||
webClientLogoutPathDefault = "/web/client/logout"
|
||||
webStaticFilesPathDefault = "/static"
|
||||
// MaxRestoreSize defines the max size for the loaddata input file
|
||||
MaxRestoreSize = 10485760 // 10 MB
|
||||
maxRequestSize = 1048576 // 1MB
|
||||
|
@ -77,37 +85,46 @@ const (
|
|||
)
|
||||
|
||||
var (
|
||||
backupsPath string
|
||||
certMgr *common.CertManager
|
||||
jwtTokensCleanupTicker *time.Ticker
|
||||
jwtTokensCleanupDone chan bool
|
||||
invalidatedJWTTokens sync.Map
|
||||
csrfTokenAuth *jwtauth.JWTAuth
|
||||
webRootPath string
|
||||
webBasePath string
|
||||
webLoginPath string
|
||||
webLogoutPath string
|
||||
webUsersPath string
|
||||
webUserPath string
|
||||
webConnectionsPath string
|
||||
webFoldersPath string
|
||||
webFolderPath string
|
||||
webStatusPath string
|
||||
webAdminsPath string
|
||||
webAdminPath string
|
||||
webMaintenancePath string
|
||||
webBackupPath string
|
||||
webRestorePath string
|
||||
webScanVFolderPath string
|
||||
webQuotaScanPath string
|
||||
webChangeAdminPwdPath string
|
||||
webTemplateUser string
|
||||
webTemplateFolder string
|
||||
webStaticFilesPath string
|
||||
backupsPath string
|
||||
certMgr *common.CertManager
|
||||
jwtTokensCleanupTicker *time.Ticker
|
||||
jwtTokensCleanupDone chan bool
|
||||
invalidatedJWTTokens sync.Map
|
||||
csrfTokenAuth *jwtauth.JWTAuth
|
||||
webRootPath string
|
||||
webBasePath string
|
||||
webBaseAdminPath string
|
||||
webBaseClientPath string
|
||||
webLoginPath string
|
||||
webLogoutPath string
|
||||
webUsersPath string
|
||||
webUserPath string
|
||||
webConnectionsPath string
|
||||
webFoldersPath string
|
||||
webFolderPath string
|
||||
webStatusPath string
|
||||
webAdminsPath string
|
||||
webAdminPath string
|
||||
webMaintenancePath string
|
||||
webBackupPath string
|
||||
webRestorePath string
|
||||
webScanVFolderPath string
|
||||
webQuotaScanPath string
|
||||
webChangeAdminPwdPath string
|
||||
webTemplateUser string
|
||||
webTemplateFolder string
|
||||
webClientLoginPath string
|
||||
webClientFilesPath string
|
||||
webClientCredentialsPath string
|
||||
webChangeClientPwdPath string
|
||||
webChangeClientKeysPath string
|
||||
webClientLogoutPath string
|
||||
webStaticFilesPath string
|
||||
)
|
||||
|
||||
func init() {
|
||||
updateWebAdminURLs("")
|
||||
updateWebClientURLs("")
|
||||
}
|
||||
|
||||
// Binding defines the configuration for a network listener
|
||||
|
@ -119,6 +136,9 @@ type Binding struct {
|
|||
// Enable the built-in admin interface.
|
||||
// You have to define TemplatesPath and StaticFilesPath for this to work
|
||||
EnableWebAdmin bool `json:"enable_web_admin" mapstructure:"enable_web_admin"`
|
||||
// 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"`
|
||||
// you also need to provide a certificate for enabling HTTPS
|
||||
EnableHTTPS bool `json:"enable_https" mapstructure:"enable_https"`
|
||||
// set to 1 to require client certificate authentication in addition to basic auth.
|
||||
|
@ -181,9 +201,9 @@ type Conf struct {
|
|||
StaticFilesPath string `json:"static_files_path" mapstructure:"static_files_path"`
|
||||
// Path to the backup directory. This can be an absolute path or a path relative to the config dir
|
||||
BackupsPath string `json:"backups_path" mapstructure:"backups_path"`
|
||||
// Defines a base URL for the web admin. If empty web admin resources will be available at the
|
||||
// root ("/") URI. If defined it must be an absolute URI or it will be ignored.
|
||||
WebAdminRoot string `json:"web_admin_root" mapstructure:"web_admin_root"`
|
||||
// Defines a base URL for the web admin and client interfaces. If empty web admin and client resources will
|
||||
// be available at the root ("/") URI. If defined it must be an absolute URI or it will be ignored.
|
||||
WebRoot string `json:"web_root" mapstructure:"web_root"`
|
||||
// If files containing a certificate and matching private key for the server are provided the server will expect
|
||||
// HTTPS connections.
|
||||
// Certificate and key files can be reloaded on demand sending a "SIGHUP" signal on Unix based systems and a
|
||||
|
@ -213,27 +233,50 @@ func (c *Conf) ShouldBind() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func (c *Conf) isWebAdminEnabled() bool {
|
||||
for _, binding := range c.Bindings {
|
||||
if binding.EnableWebAdmin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Conf) isWebClientEnabled() bool {
|
||||
for _, binding := range c.Bindings {
|
||||
if binding.EnableWebClient {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Initialize configures and starts the HTTP server
|
||||
func (c *Conf) Initialize(configDir string) error {
|
||||
logger.Debug(logSender, "", "initializing HTTP server with config %+v", c)
|
||||
backupsPath = getConfigPath(c.BackupsPath, configDir)
|
||||
staticFilesPath := getConfigPath(c.StaticFilesPath, configDir)
|
||||
templatesPath := getConfigPath(c.TemplatesPath, configDir)
|
||||
enableWebAdmin := staticFilesPath != "" || templatesPath != ""
|
||||
if backupsPath == "" {
|
||||
return fmt.Errorf("required directory is invalid, backup path %#v", backupsPath)
|
||||
}
|
||||
if enableWebAdmin && (staticFilesPath == "" || templatesPath == "") {
|
||||
if (c.isWebAdminEnabled() || c.isWebClientEnabled()) && (staticFilesPath == "" || templatesPath == "") {
|
||||
return fmt.Errorf("required directory is invalid, static file path: %#v template path: %#v",
|
||||
staticFilesPath, templatesPath)
|
||||
}
|
||||
certificateFile := getConfigPath(c.CertificateFile, configDir)
|
||||
certificateKeyFile := getConfigPath(c.CertificateKeyFile, configDir)
|
||||
if enableWebAdmin {
|
||||
updateWebAdminURLs(c.WebAdminRoot)
|
||||
loadTemplates(templatesPath)
|
||||
if c.isWebAdminEnabled() {
|
||||
updateWebAdminURLs(c.WebRoot)
|
||||
loadAdminTemplates(templatesPath)
|
||||
} else {
|
||||
logger.Info(logSender, "", "built-in web interface disabled, please set templates_path and static_files_path to enable it")
|
||||
logger.Info(logSender, "", "built-in web admin interface disabled")
|
||||
}
|
||||
if c.isWebClientEnabled() {
|
||||
updateWebClientURLs(c.WebRoot)
|
||||
loadClientTemplates(templatesPath)
|
||||
} else {
|
||||
logger.Info(logSender, "", "built-in web client interface disabled")
|
||||
}
|
||||
if certificateFile != "" && certificateKeyFile != "" {
|
||||
mgr, err := common.NewCertManager(certificateFile, certificateKeyFile, configDir, logSender)
|
||||
|
@ -261,7 +304,7 @@ func (c *Conf) Initialize(configDir string) error {
|
|||
}
|
||||
|
||||
go func(b Binding) {
|
||||
server := newHttpdServer(b, staticFilesPath, enableWebAdmin)
|
||||
server := newHttpdServer(b, staticFilesPath)
|
||||
|
||||
exitChannel <- server.listenAndServe()
|
||||
}(binding)
|
||||
|
@ -271,10 +314,14 @@ func (c *Conf) Initialize(configDir string) error {
|
|||
return <-exitChannel
|
||||
}
|
||||
|
||||
func isWebAdminRequest(r *http.Request) bool {
|
||||
func isWebRequest(r *http.Request) bool {
|
||||
return strings.HasPrefix(r.RequestURI, webBasePath+"/")
|
||||
}
|
||||
|
||||
func isWebClientRequest(r *http.Request) bool {
|
||||
return strings.HasPrefix(r.RequestURI, webBaseClientPath+"/")
|
||||
}
|
||||
|
||||
// ReloadCertificateMgr reloads the certificate manager
|
||||
func ReloadCertificateMgr() error {
|
||||
if certMgr != nil {
|
||||
|
@ -330,12 +377,28 @@ func fileServer(r chi.Router, path string, root http.FileSystem) {
|
|||
})
|
||||
}
|
||||
|
||||
func updateWebClientURLs(baseURL string) {
|
||||
if !path.IsAbs(baseURL) {
|
||||
baseURL = "/"
|
||||
}
|
||||
webRootPath = path.Join(baseURL, webRootPathDefault)
|
||||
webBasePath = path.Join(baseURL, webBasePathDefault)
|
||||
webBaseClientPath = path.Join(baseURL, webBasePathClientDefault)
|
||||
webClientLoginPath = path.Join(baseURL, webClientLoginPathDefault)
|
||||
webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault)
|
||||
webClientCredentialsPath = path.Join(baseURL, webClientCredentialsPathDefault)
|
||||
webChangeClientPwdPath = path.Join(baseURL, webChangeClientPwdPathDefault)
|
||||
webChangeClientKeysPath = path.Join(baseURL, webChangeClientKeysPathDefault)
|
||||
webClientLogoutPath = path.Join(baseURL, webClientLogoutPathDefault)
|
||||
}
|
||||
|
||||
func updateWebAdminURLs(baseURL string) {
|
||||
if !path.IsAbs(baseURL) {
|
||||
baseURL = "/"
|
||||
}
|
||||
webRootPath = path.Join(baseURL, webRootPathDefault)
|
||||
webBasePath = path.Join(baseURL, webBasePathDefault)
|
||||
webBaseAdminPath = path.Join(baseURL, webBasePathAdminDefault)
|
||||
webLoginPath = path.Join(baseURL, webLoginPathDefault)
|
||||
webLogoutPath = path.Join(baseURL, webLogoutPathDefault)
|
||||
webUsersPath = path.Join(baseURL, webUsersPathDefault)
|
||||
|
@ -360,11 +423,12 @@ func updateWebAdminURLs(baseURL string) {
|
|||
// GetHTTPRouter returns an HTTP handler suitable to use for test cases
|
||||
func GetHTTPRouter() http.Handler {
|
||||
b := Binding{
|
||||
Address: "",
|
||||
Port: 8080,
|
||||
EnableWebAdmin: true,
|
||||
Address: "",
|
||||
Port: 8080,
|
||||
EnableWebAdmin: true,
|
||||
EnableWebClient: true,
|
||||
}
|
||||
server := newHttpdServer(b, "../static", true)
|
||||
server := newHttpdServer(b, "../static")
|
||||
server.initializeRouter()
|
||||
return server.router
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -353,7 +353,7 @@ func TestUpdateWebAdminInvalidClaims(t *testing.T) {
|
|||
Permissions: admin.Permissions,
|
||||
Signature: admin.GetSignature(),
|
||||
}
|
||||
token, err := c.createTokenResponse(server.tokenAuth, tokenAudienceWeb)
|
||||
token, err := c.createTokenResponse(server.tokenAuth, tokenAudienceWebAdmin)
|
||||
assert.NoError(t, err)
|
||||
|
||||
form := make(url.Values)
|
||||
|
@ -435,18 +435,18 @@ func TestCreateTokenError(t *testing.T) {
|
|||
req, _ = http.NewRequest(http.MethodPost, webLoginPath, bytes.NewBuffer([]byte(form.Encode())))
|
||||
req.RemoteAddr = "127.0.0.1:1234"
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
server.handleWebLoginPost(rr, req)
|
||||
server.handleWebAdminLoginPost(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
||||
// req with no content type
|
||||
req, _ = http.NewRequest(http.MethodPost, webLoginPath, nil)
|
||||
rr = httptest.NewRecorder()
|
||||
server.handleWebLoginPost(rr, req)
|
||||
server.handleWebAdminLoginPost(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
||||
// req with no POST body
|
||||
req, _ = http.NewRequest(http.MethodGet, webLoginPath+"?a=a%C3%AO%GG", nil)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr = httptest.NewRecorder()
|
||||
server.handleWebLoginPost(rr, req)
|
||||
server.handleWebAdminLoginPost(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
||||
req, _ = http.NewRequest(http.MethodGet, webLoginPath+"?a=a%C3%A1%G2", nil)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
@ -458,6 +458,51 @@ func TestCreateTokenError(t *testing.T) {
|
|||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
_, err := getAdminFromPostFields(req)
|
||||
assert.Error(t, err)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodPost, webClientLoginPath+"?a=a%C3%AO%GG", bytes.NewBuffer([]byte(form.Encode())))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr = httptest.NewRecorder()
|
||||
server.handleWebClientLoginPost(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
||||
|
||||
req, _ = http.NewRequest(http.MethodPost, webChangeClientPwdPath+"?a=a%C3%AO%GA", bytes.NewBuffer([]byte(form.Encode())))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr = httptest.NewRecorder()
|
||||
handleWebClientChangePwdPost(rr, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String())
|
||||
|
||||
req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath+"?a=a%C3%AO%GB", bytes.NewBuffer([]byte(form.Encode())))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr = httptest.NewRecorder()
|
||||
handleWebClientManageKeysPost(rr, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String())
|
||||
|
||||
username := "webclientuser"
|
||||
user := dataprovider.User{
|
||||
Username: username,
|
||||
Password: "clientpwd",
|
||||
HomeDir: filepath.Join(os.TempDir(), username),
|
||||
Status: 1,
|
||||
Description: "test user",
|
||||
}
|
||||
user.Permissions = make(map[string][]string)
|
||||
user.Permissions["/"] = []string{"*"}
|
||||
err = dataprovider.AddUser(&user)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
form = make(url.Values)
|
||||
form.Set("username", user.Username)
|
||||
form.Set("password", "clientpwd")
|
||||
form.Set(csrfFormToken, createCSRFToken())
|
||||
req, _ = http.NewRequest(http.MethodPost, webClientLoginPath, bytes.NewBuffer([]byte(form.Encode())))
|
||||
req.RemoteAddr = "127.0.0.1:4567"
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
server.handleWebClientLoginPost(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
|
||||
|
||||
err = dataprovider.DeleteUser(username)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestJWTTokenValidation(t *testing.T) {
|
||||
|
@ -469,20 +514,28 @@ func TestJWTTokenValidation(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
|
||||
r := GetHTTPRouter()
|
||||
fn := jwtAuthenticator(r)
|
||||
fn := jwtAuthenticatorAPI(r)
|
||||
rr := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, userPath, nil)
|
||||
ctx := jwtauth.NewContext(req.Context(), token, nil)
|
||||
fn.ServeHTTP(rr, req.WithContext(ctx))
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
|
||||
fn = jwtAuthenticatorWeb(r)
|
||||
fn = jwtAuthenticatorWebAdmin(r)
|
||||
rr = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(http.MethodGet, webUserPath, nil)
|
||||
ctx = jwtauth.NewContext(req.Context(), token, nil)
|
||||
|
||||
fn.ServeHTTP(rr, req.WithContext(ctx))
|
||||
assert.Equal(t, http.StatusFound, rr.Code)
|
||||
assert.Equal(t, webLoginPath, rr.Header().Get("Location"))
|
||||
|
||||
fn = jwtAuthenticatorWebClient(r)
|
||||
rr = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
|
||||
ctx = jwtauth.NewContext(req.Context(), token, nil)
|
||||
fn.ServeHTTP(rr, req.WithContext(ctx))
|
||||
assert.Equal(t, http.StatusFound, rr.Code)
|
||||
assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
|
||||
|
||||
errTest := errors.New("test error")
|
||||
permFn := checkPerm(dataprovider.PermAdminAny)
|
||||
|
@ -501,6 +554,15 @@ func TestJWTTokenValidation(t *testing.T) {
|
|||
ctx = jwtauth.NewContext(req.Context(), token, errTest)
|
||||
fn.ServeHTTP(rr, req.WithContext(ctx))
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
|
||||
permClientFn := checkClientPerm(dataprovider.WebClientPubKeyChangeDisabled)
|
||||
fn = permClientFn(r)
|
||||
rr = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, nil)
|
||||
req.RequestURI = webChangeClientKeysPath
|
||||
ctx = jwtauth.NewContext(req.Context(), token, errTest)
|
||||
fn.ServeHTTP(rr, req.WithContext(ctx))
|
||||
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||
}
|
||||
|
||||
func TestAdminAllowListConnAddr(t *testing.T) {
|
||||
|
@ -565,6 +627,7 @@ func TestCookieExpiration(t *testing.T) {
|
|||
claims[claimPermissionsKey] = admin.Permissions
|
||||
claims[jwt.SubjectKey] = admin.GetSignature()
|
||||
claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute)
|
||||
claims[jwt.AudienceKey] = tokenAudienceAPI
|
||||
token, _, err = server.tokenAuth.Encode(claims)
|
||||
assert.NoError(t, err)
|
||||
req, _ = http.NewRequest(http.MethodGet, tokenPath, nil)
|
||||
|
@ -599,6 +662,7 @@ func TestCookieExpiration(t *testing.T) {
|
|||
claims[claimPermissionsKey] = admin.Permissions
|
||||
claims[jwt.SubjectKey] = admin.GetSignature()
|
||||
claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute)
|
||||
claims[jwt.AudienceKey] = tokenAudienceAPI
|
||||
token, _, err = server.tokenAuth.Encode(claims)
|
||||
assert.NoError(t, err)
|
||||
req, _ = http.NewRequest(http.MethodGet, tokenPath, nil)
|
||||
|
@ -625,6 +689,82 @@ func TestCookieExpiration(t *testing.T) {
|
|||
|
||||
err = dataprovider.DeleteAdmin(admin.Username)
|
||||
assert.NoError(t, err)
|
||||
// now check client cookie expiration
|
||||
username := "client"
|
||||
user := dataprovider.User{
|
||||
Username: username,
|
||||
Password: "clientpwd",
|
||||
HomeDir: filepath.Join(os.TempDir(), username),
|
||||
Status: 1,
|
||||
Description: "test user",
|
||||
}
|
||||
user.Permissions = make(map[string][]string)
|
||||
user.Permissions["/"] = []string{"*"}
|
||||
|
||||
claims = make(map[string]interface{})
|
||||
claims[claimUsernameKey] = user.Username
|
||||
claims[claimPermissionsKey] = user.Filters.WebClient
|
||||
claims[jwt.SubjectKey] = user.GetSignature()
|
||||
claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute)
|
||||
claims[jwt.AudienceKey] = tokenAudienceWebClient
|
||||
token, _, err = server.tokenAuth.Encode(claims)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
|
||||
ctx = jwtauth.NewContext(req.Context(), token, nil)
|
||||
server.checkCookieExpiration(rr, req.WithContext(ctx))
|
||||
cookie = rr.Header().Get("Set-Cookie")
|
||||
assert.Empty(t, cookie)
|
||||
// the password will be hashed and so the signature will change
|
||||
err = dataprovider.AddUser(&user)
|
||||
assert.NoError(t, err)
|
||||
req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
|
||||
ctx = jwtauth.NewContext(req.Context(), token, nil)
|
||||
server.checkCookieExpiration(rr, req.WithContext(ctx))
|
||||
cookie = rr.Header().Get("Set-Cookie")
|
||||
assert.Empty(t, cookie)
|
||||
|
||||
user, err = dataprovider.UserExists(user.Username)
|
||||
assert.NoError(t, err)
|
||||
user.Filters.AllowedIP = []string{"172.16.4.0/24"}
|
||||
err = dataprovider.UpdateUser(&user)
|
||||
assert.NoError(t, err)
|
||||
|
||||
user, err = dataprovider.UserExists(user.Username)
|
||||
assert.NoError(t, err)
|
||||
claims = make(map[string]interface{})
|
||||
claims[claimUsernameKey] = user.Username
|
||||
claims[claimPermissionsKey] = user.Filters.WebClient
|
||||
claims[jwt.SubjectKey] = user.GetSignature()
|
||||
claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute)
|
||||
claims[jwt.AudienceKey] = tokenAudienceWebClient
|
||||
token, _, err = server.tokenAuth.Encode(claims)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
|
||||
req.RemoteAddr = "172.16.3.12:4567"
|
||||
ctx = jwtauth.NewContext(req.Context(), token, nil)
|
||||
server.checkCookieExpiration(rr, req.WithContext(ctx))
|
||||
cookie = rr.Header().Get("Set-Cookie")
|
||||
assert.Empty(t, cookie)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
|
||||
req.RemoteAddr = "172.16.4.12:4567"
|
||||
ctx = jwtauth.NewContext(req.Context(), token, nil)
|
||||
server.checkCookieExpiration(rr, req.WithContext(context.WithValue(ctx, connAddrKey, "172.16.0.1:4567")))
|
||||
cookie = rr.Header().Get("Set-Cookie")
|
||||
assert.Empty(t, cookie)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
|
||||
req.RemoteAddr = "172.16.4.16:4567"
|
||||
ctx = jwtauth.NewContext(req.Context(), token, nil)
|
||||
server.checkCookieExpiration(rr, req.WithContext(context.WithValue(ctx, connAddrKey, "172.16.4.18:4567")))
|
||||
cookie = rr.Header().Get("Set-Cookie")
|
||||
assert.NotEmpty(t, cookie)
|
||||
|
||||
err = dataprovider.DeleteUser(user.Username)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestGetURLParam(t *testing.T) {
|
||||
|
@ -672,9 +812,13 @@ func TestCloseConnectionHandler(t *testing.T) {
|
|||
func TestRenderInvalidTemplate(t *testing.T) {
|
||||
tmpl, err := template.New("test").Parse("{{.Count}}")
|
||||
if assert.NoError(t, err) {
|
||||
templates["no_match"] = tmpl
|
||||
noMatchTmpl := "no_match"
|
||||
adminTemplates[noMatchTmpl] = tmpl
|
||||
rw := httptest.NewRecorder()
|
||||
renderTemplate(rw, "no_match", map[string]string{})
|
||||
renderAdminTemplate(rw, noMatchTmpl, map[string]string{})
|
||||
assert.Equal(t, http.StatusInternalServerError, rw.Code)
|
||||
clientTemplates[noMatchTmpl] = tmpl
|
||||
renderClientTemplate(rw, noMatchTmpl, map[string]string{})
|
||||
assert.Equal(t, http.StatusInternalServerError, rw.Code)
|
||||
}
|
||||
}
|
||||
|
@ -869,3 +1013,296 @@ func TestJWTTokenCleanup(t *testing.T) {
|
|||
assert.Eventually(t, func() bool { return !isTokenInvalidated(req) }, 1*time.Second, 200*time.Millisecond)
|
||||
stopJWTTokensCleanupTicker()
|
||||
}
|
||||
|
||||
func TestWebAdminRedirect(t *testing.T) {
|
||||
b := Binding{
|
||||
Address: "",
|
||||
Port: 8080,
|
||||
EnableWebAdmin: true,
|
||||
EnableWebClient: false,
|
||||
}
|
||||
server := newHttpdServer(b, "../static")
|
||||
server.initializeRouter()
|
||||
testServer := httptest.NewServer(server.router)
|
||||
defer testServer.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, webRootPath, nil)
|
||||
assert.NoError(t, err)
|
||||
rr := httptest.NewRecorder()
|
||||
testServer.Config.Handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusMovedPermanently, rr.Code, rr.Body.String())
|
||||
assert.Equal(t, webLoginPath, rr.Header().Get("Location"))
|
||||
|
||||
req, err = http.NewRequest(http.MethodGet, webBasePath, nil)
|
||||
assert.NoError(t, err)
|
||||
rr = httptest.NewRecorder()
|
||||
testServer.Config.Handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusMovedPermanently, rr.Code, rr.Body.String())
|
||||
assert.Equal(t, webLoginPath, rr.Header().Get("Location"))
|
||||
}
|
||||
|
||||
func TestParseRangeRequests(t *testing.T) {
|
||||
// curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=24-24"
|
||||
fileSize := int64(169740)
|
||||
rangeHeader := "bytes=24-24"
|
||||
offset, size, err := parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.NoError(t, err)
|
||||
resp := fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize)
|
||||
assert.Equal(t, "bytes 24-24/169740", resp)
|
||||
require.Equal(t, int64(1), size)
|
||||
// curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=24-"
|
||||
rangeHeader = "bytes=24-"
|
||||
offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.NoError(t, err)
|
||||
resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize)
|
||||
assert.Equal(t, "bytes 24-169739/169740", resp)
|
||||
require.Equal(t, int64(169716), size)
|
||||
// curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=-1"
|
||||
rangeHeader = "bytes=-1"
|
||||
offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.NoError(t, err)
|
||||
resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize)
|
||||
assert.Equal(t, "bytes 169739-169739/169740", resp)
|
||||
require.Equal(t, int64(1), size)
|
||||
// curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=-100"
|
||||
rangeHeader = "bytes=-100"
|
||||
offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.NoError(t, err)
|
||||
resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize)
|
||||
assert.Equal(t, "bytes 169640-169739/169740", resp)
|
||||
require.Equal(t, int64(100), size)
|
||||
// curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=20-30"
|
||||
rangeHeader = "bytes=20-30"
|
||||
offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.NoError(t, err)
|
||||
resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize)
|
||||
assert.Equal(t, "bytes 20-30/169740", resp)
|
||||
require.Equal(t, int64(11), size)
|
||||
// curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=20-169739"
|
||||
rangeHeader = "bytes=20-169739"
|
||||
offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.NoError(t, err)
|
||||
resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize)
|
||||
assert.Equal(t, "bytes 20-169739/169740", resp)
|
||||
require.Equal(t, int64(169720), size)
|
||||
// curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=20-169740"
|
||||
rangeHeader = "bytes=20-169740"
|
||||
offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.NoError(t, err)
|
||||
resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize)
|
||||
assert.Equal(t, "bytes 20-169739/169740", resp)
|
||||
require.Equal(t, int64(169720), size)
|
||||
// curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=20-169741"
|
||||
rangeHeader = "bytes=20-169741"
|
||||
offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.NoError(t, err)
|
||||
resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize)
|
||||
assert.Equal(t, "bytes 20-169739/169740", resp)
|
||||
require.Equal(t, int64(169720), size)
|
||||
//curl --verbose "http://127.0.0.1:8080/static/css/sb-admin-2.min.css" -H "Range: bytes=0-" > /dev/null
|
||||
rangeHeader = "bytes=0-"
|
||||
offset, size, err = parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.NoError(t, err)
|
||||
resp = fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, fileSize)
|
||||
assert.Equal(t, "bytes 0-169739/169740", resp)
|
||||
require.Equal(t, int64(169740), size)
|
||||
// now test errors
|
||||
rangeHeader = "bytes=0-a"
|
||||
_, _, err = parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.Error(t, err)
|
||||
rangeHeader = "bytes="
|
||||
_, _, err = parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.Error(t, err)
|
||||
rangeHeader = "bytes=-"
|
||||
_, _, err = parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.Error(t, err)
|
||||
rangeHeader = "bytes=500-300"
|
||||
_, _, err = parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.Error(t, err)
|
||||
rangeHeader = "bytes=5000000"
|
||||
_, _, err = parseRangeRequest(rangeHeader[6:], fileSize)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRequestHeaderErrors(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil)
|
||||
req.Header.Set("If-Unmodified-Since", "not a date")
|
||||
res := checkIfUnmodifiedSince(req, time.Now())
|
||||
assert.Equal(t, condNone, res)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodPost, webClientFilesPath, nil)
|
||||
res = checkIfModifiedSince(req, time.Now())
|
||||
assert.Equal(t, condNone, res)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodPost, webClientFilesPath, nil)
|
||||
res = checkIfRange(req, time.Now())
|
||||
assert.Equal(t, condNone, res)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
|
||||
req.Header.Set("If-Modified-Since", "not a date")
|
||||
res = checkIfModifiedSince(req, time.Now())
|
||||
assert.Equal(t, condNone, res)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil)
|
||||
req.Header.Set("If-Range", time.Now().Format(http.TimeFormat))
|
||||
res = checkIfRange(req, time.Time{})
|
||||
assert.Equal(t, condFalse, res)
|
||||
|
||||
req.Header.Set("If-Range", "invalid if range date")
|
||||
res = checkIfRange(req, time.Now())
|
||||
assert.Equal(t, condFalse, res)
|
||||
modTime := getFileObjectModTime(time.Time{})
|
||||
assert.Empty(t, modTime)
|
||||
}
|
||||
|
||||
func TestConnection(t *testing.T) {
|
||||
user := dataprovider.User{
|
||||
Username: "test_httpd_user",
|
||||
HomeDir: filepath.Clean(os.TempDir()),
|
||||
FsConfig: vfs.Filesystem{
|
||||
Provider: vfs.GCSFilesystemProvider,
|
||||
GCSConfig: vfs.GCSFsConfig{
|
||||
Bucket: "test_bucket_name",
|
||||
Credentials: kms.NewPlainSecret("invalid JSON payload"),
|
||||
},
|
||||
},
|
||||
}
|
||||
user.Permissions = make(map[string][]string)
|
||||
user.Permissions["/"] = []string{dataprovider.PermAny}
|
||||
connection := &Connection{
|
||||
BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, user),
|
||||
request: nil,
|
||||
}
|
||||
assert.Empty(t, connection.GetClientVersion())
|
||||
assert.Empty(t, connection.GetRemoteAddress())
|
||||
assert.Empty(t, connection.GetCommand())
|
||||
name := "missing file name"
|
||||
_, err := connection.getFileReader(name, 0)
|
||||
assert.Error(t, err)
|
||||
connection.User.FsConfig.Provider = vfs.LocalFilesystemProvider
|
||||
_, err = connection.getFileReader(name, 0)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestRenderDirError(t *testing.T) {
|
||||
user := dataprovider.User{
|
||||
Username: "test_httpd_user",
|
||||
HomeDir: filepath.Clean(os.TempDir()),
|
||||
}
|
||||
user.Permissions = make(map[string][]string)
|
||||
user.Permissions["/"] = []string{dataprovider.PermAny}
|
||||
connection := &Connection{
|
||||
BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, user),
|
||||
request: nil,
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil)
|
||||
renderDirContents(rr, req, connection, "missing dir")
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Contains(t, rr.Body.String(), "text-form-error")
|
||||
}
|
||||
|
||||
func TestHTTPDFile(t *testing.T) {
|
||||
user := dataprovider.User{
|
||||
Username: "test_httpd_user",
|
||||
HomeDir: filepath.Clean(os.TempDir()),
|
||||
}
|
||||
user.Permissions = make(map[string][]string)
|
||||
user.Permissions["/"] = []string{dataprovider.PermAny}
|
||||
connection := &Connection{
|
||||
BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, user),
|
||||
request: nil,
|
||||
}
|
||||
|
||||
fs, err := user.GetFilesystem("")
|
||||
assert.NoError(t, err)
|
||||
|
||||
name := "fileName"
|
||||
p := filepath.Join(os.TempDir(), name)
|
||||
err = os.WriteFile(p, []byte("contents"), os.ModePerm)
|
||||
assert.NoError(t, err)
|
||||
file, err := os.Open(p)
|
||||
assert.NoError(t, err)
|
||||
err = file.Close()
|
||||
assert.NoError(t, err)
|
||||
|
||||
baseTransfer := common.NewBaseTransfer(file, connection.BaseConnection, nil, p, name, common.TransferDownload,
|
||||
0, 0, 0, false, fs)
|
||||
httpdFile := newHTTPDFile(baseTransfer, nil)
|
||||
// the file is closed, read should fail
|
||||
buf := make([]byte, 100)
|
||||
_, err = httpdFile.Read(buf)
|
||||
assert.Error(t, err)
|
||||
err = httpdFile.Close()
|
||||
assert.Error(t, err)
|
||||
err = httpdFile.Close()
|
||||
assert.ErrorIs(t, err, common.ErrTransferClosed)
|
||||
}
|
||||
|
||||
func TestChangeUserPwd(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodPost, webChangeClientPwdPath, nil)
|
||||
err := doChangeUserPassword(req, "", "", "")
|
||||
if assert.Error(t, err) {
|
||||
assert.Contains(t, err.Error(), "please provide the current password and the new one two times")
|
||||
}
|
||||
err = doChangeUserPassword(req, "a", "b", "c")
|
||||
if assert.Error(t, err) {
|
||||
assert.Contains(t, err.Error(), "the two password fields do not match")
|
||||
}
|
||||
err = doChangeUserPassword(req, "a", "b", "b")
|
||||
if assert.Error(t, err) {
|
||||
assert.Contains(t, err.Error(), "invalid token claims")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilesInvalidClaims(t *testing.T) {
|
||||
server := httpdServer{}
|
||||
server.initializeRouter()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
user := dataprovider.User{
|
||||
Username: "",
|
||||
Password: "pwd",
|
||||
}
|
||||
c := jwtTokenClaims{
|
||||
Username: user.Username,
|
||||
Permissions: nil,
|
||||
Signature: user.GetSignature(),
|
||||
}
|
||||
token, err := c.createTokenResponse(server.tokenAuth, tokenAudienceWebClient)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil)
|
||||
req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
|
||||
handleClientGetFiles(rr, req)
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
assert.Contains(t, rr.Body.String(), "Invalid token claims")
|
||||
}
|
||||
|
||||
func TestManageKeysInvalidClaims(t *testing.T) {
|
||||
server := httpdServer{}
|
||||
server.initializeRouter()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
user := dataprovider.User{
|
||||
Username: "",
|
||||
Password: "pwd",
|
||||
}
|
||||
c := jwtTokenClaims{
|
||||
Username: user.Username,
|
||||
Permissions: nil,
|
||||
Signature: user.GetSignature(),
|
||||
}
|
||||
token, err := c.createTokenResponse(server.tokenAuth, tokenAudienceWebClient)
|
||||
assert.NoError(t, err)
|
||||
form := make(url.Values)
|
||||
form.Set(csrfFormToken, createCSRFToken())
|
||||
form.Set("public_keys", "")
|
||||
req, _ := http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode())))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
|
||||
handleWebClientManageKeysPost(rr, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, rr.Code)
|
||||
assert.Contains(t, rr.Body.String(), "Invalid token claims")
|
||||
}
|
||||
|
|
|
@ -15,7 +15,10 @@ import (
|
|||
"github.com/drakkan/sftpgo/utils"
|
||||
)
|
||||
|
||||
var connAddrKey = &contextKey{"connection address"}
|
||||
var (
|
||||
connAddrKey = &contextKey{"connection address"}
|
||||
errInvalidToken = errors.New("invalid JWT token")
|
||||
)
|
||||
|
||||
type contextKey struct {
|
||||
name string
|
||||
|
@ -32,30 +35,60 @@ func saveConnectionAddress(next http.Handler) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func jwtAuthenticator(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, _, err := jwtauth.FromContext(r.Context())
|
||||
func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudience) error {
|
||||
token, _, err := jwtauth.FromContext(r.Context())
|
||||
|
||||
if err != nil || token == nil {
|
||||
logger.Debug(logSender, "", "error getting jwt token: %v", err)
|
||||
sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var redirectPath string
|
||||
if audience == tokenAudienceWebAdmin {
|
||||
redirectPath = webLoginPath
|
||||
} else {
|
||||
redirectPath = webClientLoginPath
|
||||
}
|
||||
|
||||
err = jwt.Validate(token)
|
||||
if err != nil {
|
||||
logger.Debug(logSender, "", "error validating jwt token: %v", err)
|
||||
if err != nil || token == nil {
|
||||
logger.Debug(logSender, "", "error getting jwt token: %v", err)
|
||||
if audience == tokenAudienceAPI {
|
||||
sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
} else {
|
||||
http.Redirect(w, r, redirectPath, http.StatusFound)
|
||||
}
|
||||
if !utils.IsStringInSlice(tokenAudienceAPI, token.Audience()) {
|
||||
logger.Debug(logSender, "", "the token audience is not valid for API usage")
|
||||
return errInvalidToken
|
||||
}
|
||||
|
||||
err = jwt.Validate(token)
|
||||
if err != nil {
|
||||
logger.Debug(logSender, "", "error validating jwt token: %v", err)
|
||||
if audience == tokenAudienceAPI {
|
||||
sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Redirect(w, r, redirectPath, http.StatusFound)
|
||||
}
|
||||
return errInvalidToken
|
||||
}
|
||||
if !utils.IsStringInSlice(audience, token.Audience()) {
|
||||
logger.Debug(logSender, "", "the token is not valid for audience %#v", audience)
|
||||
if audience == tokenAudienceAPI {
|
||||
sendAPIResponse(w, r, nil, "Your token audience is not valid", http.StatusUnauthorized)
|
||||
return
|
||||
} else {
|
||||
http.Redirect(w, r, redirectPath, http.StatusFound)
|
||||
}
|
||||
if isTokenInvalidated(r) {
|
||||
logger.Debug(logSender, "", "the token has been invalidated")
|
||||
return errInvalidToken
|
||||
}
|
||||
if isTokenInvalidated(r) {
|
||||
logger.Debug(logSender, "", "the token has been invalidated")
|
||||
if audience == tokenAudienceAPI {
|
||||
sendAPIResponse(w, r, nil, "Your token is no longer valid", http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Redirect(w, r, redirectPath, http.StatusFound)
|
||||
}
|
||||
return errInvalidToken
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jwtAuthenticatorAPI(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := validateJWTToken(w, r, tokenAudienceAPI); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -64,30 +97,9 @@ func jwtAuthenticator(next http.Handler) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func jwtAuthenticatorWeb(next http.Handler) http.Handler {
|
||||
func jwtAuthenticatorWebAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, _, err := jwtauth.FromContext(r.Context())
|
||||
|
||||
if err != nil || token == nil {
|
||||
logger.Debug(logSender, "", "error getting web jwt token: %v", err)
|
||||
http.Redirect(w, r, webLoginPath, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
err = jwt.Validate(token)
|
||||
if err != nil {
|
||||
logger.Debug(logSender, "", "error validating web jwt token: %v", err)
|
||||
http.Redirect(w, r, webLoginPath, http.StatusFound)
|
||||
return
|
||||
}
|
||||
if !utils.IsStringInSlice(tokenAudienceWeb, token.Audience()) {
|
||||
logger.Debug(logSender, "", "the token audience is not valid for Web usage")
|
||||
http.Redirect(w, r, webLoginPath, http.StatusFound)
|
||||
return
|
||||
}
|
||||
if isTokenInvalidated(r) {
|
||||
logger.Debug(logSender, "", "the token has been invalidated")
|
||||
http.Redirect(w, r, webLoginPath, http.StatusFound)
|
||||
if err := validateJWTToken(w, r, tokenAudienceWebAdmin); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -96,12 +108,44 @@ func jwtAuthenticatorWeb(next http.Handler) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func jwtAuthenticatorWebClient(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := validateJWTToken(w, r, tokenAudienceWebClient); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Token is authenticated, pass it through
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func checkClientPerm(perm string) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, claims, err := jwtauth.FromContext(r.Context())
|
||||
if err != nil {
|
||||
renderClientBadRequestPage(w, r, err)
|
||||
return
|
||||
}
|
||||
tokenClaims := jwtTokenClaims{}
|
||||
tokenClaims.Decode(claims)
|
||||
// for web client perms are negated and not granted
|
||||
if tokenClaims.hasPerm(perm) {
|
||||
renderClientForbiddenPage(w, r, "You don't have permission for this action")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkPerm(perm string) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, claims, err := jwtauth.FromContext(r.Context())
|
||||
if err != nil {
|
||||
if isWebAdminRequest(r) {
|
||||
if isWebRequest(r) {
|
||||
renderBadRequestPage(w, r, err)
|
||||
} else {
|
||||
sendAPIResponse(w, r, err, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
|
@ -112,7 +156,7 @@ func checkPerm(perm string) func(next http.Handler) http.Handler {
|
|||
tokenClaims.Decode(claims)
|
||||
|
||||
if !tokenClaims.hasPerm(perm) {
|
||||
if isWebAdminRequest(r) {
|
||||
if isWebRequest(r) {
|
||||
renderForbiddenPage(w, r, "You don't have permission for this action")
|
||||
} else {
|
||||
sendAPIResponse(w, r, nil, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
|
|
|
@ -1375,11 +1375,20 @@ components:
|
|||
- SSH
|
||||
- FTP
|
||||
- DAV
|
||||
- HTTP
|
||||
description: |
|
||||
Protocols:
|
||||
* `SSH` - includes both SFTP and SSH commands
|
||||
* `FTP` - plain FTP and FTPES/FTPS
|
||||
* `DAV` - WebDAV over HTTP/HTTPS
|
||||
* `HTTP` - WebClient
|
||||
WebClientOptions:
|
||||
type: string
|
||||
enum:
|
||||
- publickey-change-disabled
|
||||
description: |
|
||||
Options:
|
||||
* `publickey-change-disabled` - changing SSH public keys is not allowed
|
||||
PatternsFilter:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -1492,7 +1501,12 @@ components:
|
|||
type: boolean
|
||||
example: false
|
||||
description: Disable checks for existence and automatic creation of home directory and virtual folders. SFTPGo requires that the user's home directory, virtual folder root, and intermediate paths to virtual folders exist to work properly. If you already know that the required directories exist, disabling these checks will speed up login. You could, for example, disable these checks after the first login
|
||||
description: Additional user restrictions
|
||||
web_client:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WebClientOptions'
|
||||
description: WebClient related configuration options
|
||||
description: Additional user options
|
||||
Secret:
|
||||
type: object
|
||||
properties:
|
||||
|
|
196
httpd/server.go
196
httpd/server.go
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/lestrrat-go/jwx/jwa"
|
||||
"github.com/rs/xid"
|
||||
|
||||
"github.com/drakkan/sftpgo/common"
|
||||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
|
@ -28,27 +29,32 @@ type httpdServer struct {
|
|||
binding Binding
|
||||
staticFilesPath string
|
||||
enableWebAdmin bool
|
||||
enableWebClient bool
|
||||
router *chi.Mux
|
||||
tokenAuth *jwtauth.JWTAuth
|
||||
}
|
||||
|
||||
func newHttpdServer(b Binding, staticFilesPath string, enableWebAdmin bool) *httpdServer {
|
||||
func newHttpdServer(b Binding, staticFilesPath string) *httpdServer {
|
||||
return &httpdServer{
|
||||
binding: b,
|
||||
staticFilesPath: staticFilesPath,
|
||||
enableWebAdmin: enableWebAdmin && b.EnableWebAdmin,
|
||||
enableWebAdmin: b.EnableWebAdmin,
|
||||
enableWebClient: b.EnableWebClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *httpdServer) listenAndServe() error {
|
||||
s.initializeRouter()
|
||||
httpServer := &http.Server{
|
||||
Handler: s.router,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 1 << 16, // 64KB
|
||||
ErrorLog: log.New(&logger.StdLoggerWrapper{Sender: logSender}, "", 0),
|
||||
Handler: s.router,
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 1 << 16, // 64KB
|
||||
ErrorLog: log.New(&logger.StdLoggerWrapper{Sender: logSender}, "", 0),
|
||||
}
|
||||
if !s.binding.EnableWebClient {
|
||||
httpServer.ReadTimeout = 60 * time.Second
|
||||
httpServer.WriteTimeout = 90 * time.Second
|
||||
}
|
||||
if certMgr != nil && s.binding.EnableHTTPS {
|
||||
config := &tls.Config{
|
||||
|
@ -104,7 +110,81 @@ func (s *httpdServer) refreshCookie(next http.Handler) http.Handler {
|
|||
})
|
||||
}
|
||||
|
||||
func (s *httpdServer) handleWebLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
common.Connections.AddNetworkConnection()
|
||||
defer common.Connections.RemoveNetworkConnection()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
renderClientLoginPage(w, err.Error())
|
||||
return
|
||||
}
|
||||
username := r.Form.Get("username")
|
||||
password := r.Form.Get("password")
|
||||
if username == "" || password == "" {
|
||||
renderClientLoginPage(w, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
|
||||
renderClientLoginPage(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ipAddr := utils.GetIPFromRemoteAddress(r.RemoteAddr)
|
||||
if !common.Connections.IsNewConnectionAllowed() {
|
||||
logger.Log(logger.LevelDebug, common.ProtocolHTTP, "", "connection refused, configured limit reached")
|
||||
renderClientLoginPage(w, "configured connections limit reached")
|
||||
return
|
||||
}
|
||||
if common.IsBanned(ipAddr) {
|
||||
renderClientLoginPage(w, "your IP address is banned")
|
||||
return
|
||||
}
|
||||
|
||||
if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolHTTP); err != nil {
|
||||
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())
|
||||
return
|
||||
}
|
||||
connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String())
|
||||
if err := checkWebClientUser(&user, r, connectionID); err != nil {
|
||||
updateLoginMetrics(&user, ipAddr, err)
|
||||
renderClientLoginPage(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
defer user.CloseFs() //nolint:errcheck
|
||||
err = user.CheckFsRoot(connectionID)
|
||||
if err != nil {
|
||||
logger.Warn(logSender, connectionID, "unable to check fs root: %v", err)
|
||||
updateLoginMetrics(&user, ipAddr, err)
|
||||
renderClientLoginPage(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c := jwtTokenClaims{
|
||||
Username: user.Username,
|
||||
Permissions: user.Filters.WebClient,
|
||||
Signature: user.GetSignature(),
|
||||
}
|
||||
|
||||
err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebClient)
|
||||
if err != nil {
|
||||
updateLoginMetrics(&user, ipAddr, err)
|
||||
renderLoginPage(w, err.Error())
|
||||
return
|
||||
}
|
||||
updateLoginMetrics(&user, ipAddr, err)
|
||||
http.Redirect(w, r, webClientFilesPath, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *httpdServer) handleWebAdminLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
renderLoginPage(w, err.Error())
|
||||
|
@ -139,7 +219,7 @@ func (s *httpdServer) handleWebLoginPost(w http.ResponseWriter, r *http.Request)
|
|||
Signature: admin.GetSignature(),
|
||||
}
|
||||
|
||||
err = c.createAndSetCookie(w, r, s.tokenAuth)
|
||||
err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebAdmin)
|
||||
if err != nil {
|
||||
renderLoginPage(w, err.Error())
|
||||
return
|
||||
|
@ -209,6 +289,32 @@ func (s *httpdServer) checkCookieExpiration(w http.ResponseWriter, r *http.Reque
|
|||
if time.Until(token.Expiration()) > tokenRefreshMin {
|
||||
return
|
||||
}
|
||||
if utils.IsStringInSlice(tokenAudienceWebClient, token.Audience()) {
|
||||
s.refreshClientToken(w, r, tokenClaims)
|
||||
} else {
|
||||
s.refreshAdminToken(w, r, tokenClaims)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *httpdServer) refreshClientToken(w http.ResponseWriter, r *http.Request, tokenClaims jwtTokenClaims) {
|
||||
user, err := dataprovider.UserExists(tokenClaims.Username)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if user.GetSignature() != tokenClaims.Signature {
|
||||
logger.Debug(logSender, "", "signature mismatch for user %#v, unable to refresh cookie", user.Username)
|
||||
return
|
||||
}
|
||||
if err := checkWebClientUser(&user, r, xid.New().String()); err != nil {
|
||||
logger.Debug(logSender, "", "unable to refresh cookie for user %#v: %v", user.Username, err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug(logSender, "", "cookie refreshed for user %#v", user.Username)
|
||||
tokenClaims.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebClient) //nolint:errcheck
|
||||
}
|
||||
|
||||
func (s *httpdServer) refreshAdminToken(w http.ResponseWriter, r *http.Request, tokenClaims jwtTokenClaims) {
|
||||
admin, err := dataprovider.AdminExists(tokenClaims.Username)
|
||||
if err != nil {
|
||||
return
|
||||
|
@ -235,7 +341,7 @@ func (s *httpdServer) checkCookieExpiration(w http.ResponseWriter, r *http.Reque
|
|||
}
|
||||
}
|
||||
logger.Debug(logSender, "", "cookie refreshed for admin %#v", admin.Username)
|
||||
tokenClaims.createAndSetCookie(w, r, s.tokenAuth) //nolint:errcheck
|
||||
tokenClaims.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebAdmin) //nolint:errcheck
|
||||
}
|
||||
|
||||
func (s *httpdServer) updateContextFromCookie(r *http.Request) *http.Request {
|
||||
|
@ -274,8 +380,12 @@ func (s *httpdServer) initializeRouter() {
|
|||
router.Use(middleware.Recoverer)
|
||||
|
||||
router.NotFound(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.enableWebAdmin && isWebAdminRequest(r) {
|
||||
if (s.enableWebAdmin || s.enableWebClient) && isWebRequest(r) {
|
||||
r = s.updateContextFromCookie(r)
|
||||
if s.enableWebClient && (isWebClientRequest(r) || !s.enableWebAdmin) {
|
||||
renderClientNotFoundPage(w, r, nil)
|
||||
return
|
||||
}
|
||||
renderNotFoundPage(w, r, nil)
|
||||
return
|
||||
}
|
||||
|
@ -286,7 +396,7 @@ func (s *httpdServer) initializeRouter() {
|
|||
|
||||
router.Group(func(router chi.Router) {
|
||||
router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromHeader))
|
||||
router.Use(jwtAuthenticator)
|
||||
router.Use(jwtAuthenticatorAPI)
|
||||
|
||||
router.Get(versionPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
render.JSON(w, r, version.Get())
|
||||
|
@ -336,21 +446,58 @@ func (s *httpdServer) initializeRouter() {
|
|||
router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Delete(adminPath+"/{username}", deleteAdmin)
|
||||
})
|
||||
|
||||
if s.enableWebAdmin {
|
||||
router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, webLoginPath, http.StatusMovedPermanently)
|
||||
if s.enableWebAdmin || s.enableWebClient {
|
||||
router.Group(func(router chi.Router) {
|
||||
router.Use(compressor.Handler)
|
||||
fileServer(router, webStaticFilesPath, http.Dir(s.staticFilesPath))
|
||||
})
|
||||
if s.enableWebClient {
|
||||
router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, webClientLoginPath, http.StatusMovedPermanently)
|
||||
})
|
||||
router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, webClientLoginPath, http.StatusMovedPermanently)
|
||||
})
|
||||
} else {
|
||||
router.Get(webRootPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, webLoginPath, http.StatusMovedPermanently)
|
||||
})
|
||||
router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, webLoginPath, http.StatusMovedPermanently)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
router.Get(webBasePath, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, webLoginPath, http.StatusMovedPermanently)
|
||||
if s.enableWebClient {
|
||||
router.Get(webBaseClientPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, webClientLoginPath, http.StatusMovedPermanently)
|
||||
})
|
||||
|
||||
router.Get(webLoginPath, handleWebLogin)
|
||||
router.Post(webLoginPath, s.handleWebLoginPost)
|
||||
router.Get(webClientLoginPath, handleClientWebLogin)
|
||||
router.Post(webClientLoginPath, s.handleWebClientLoginPost)
|
||||
|
||||
router.Group(func(router chi.Router) {
|
||||
router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie))
|
||||
router.Use(jwtAuthenticatorWeb)
|
||||
router.Use(jwtAuthenticatorWebClient)
|
||||
|
||||
router.Get(webClientLogoutPath, handleWebClientLogout)
|
||||
router.With(s.refreshCookie).Get(webClientFilesPath, handleClientGetFiles)
|
||||
router.With(s.refreshCookie).Get(webClientCredentialsPath, handleClientGetCredentials)
|
||||
router.Post(webChangeClientPwdPath, handleWebClientChangePwdPost)
|
||||
router.With(checkClientPerm(dataprovider.WebClientPubKeyChangeDisabled)).
|
||||
Post(webChangeClientKeysPath, handleWebClientManageKeysPost)
|
||||
})
|
||||
}
|
||||
|
||||
if s.enableWebAdmin {
|
||||
router.Get(webBaseAdminPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, webLoginPath, http.StatusMovedPermanently)
|
||||
})
|
||||
router.Get(webLoginPath, handleWebLogin)
|
||||
router.Post(webLoginPath, s.handleWebAdminLoginPost)
|
||||
|
||||
router.Group(func(router chi.Router) {
|
||||
router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie))
|
||||
router.Use(jwtAuthenticatorWebAdmin)
|
||||
|
||||
router.Get(webLogoutPath, handleWebLogout)
|
||||
router.With(s.refreshCookie).Get(webChangeAdminPwdPath, handleWebAdminChangePwd)
|
||||
|
@ -405,11 +552,6 @@ func (s *httpdServer) initializeRouter() {
|
|||
Get(webTemplateFolder, handleWebTemplateFolderGet)
|
||||
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webTemplateFolder, handleWebTemplateFolderPost)
|
||||
})
|
||||
|
||||
router.Group(func(router chi.Router) {
|
||||
router.Use(compressor.Handler)
|
||||
fileServer(router, webStaticFilesPath, http.Dir(s.staticFilesPath))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
1565
httpd/web.go
1565
httpd/web.go
File diff suppressed because it is too large
Load diff
1559
httpd/webadmin.go
Normal file
1559
httpd/webadmin.go
Normal file
File diff suppressed because it is too large
Load diff
656
httpd/webclient.go
Normal file
656
httpd/webclient.go
Normal file
|
@ -0,0 +1,656 @@
|
|||
package httpd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/xid"
|
||||
|
||||
"github.com/drakkan/sftpgo/common"
|
||||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/metrics"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/drakkan/sftpgo/version"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
templateClientDir = "webclient"
|
||||
templateClientBase = "base.html"
|
||||
templateClientLogin = "login.html"
|
||||
templateClientFiles = "files.html"
|
||||
templateClientMessage = "message.html"
|
||||
templateClientCredentials = "credentials.html"
|
||||
pageClientFilesTitle = "My Files"
|
||||
pageClientCredentialsTitle = "Credentials"
|
||||
)
|
||||
|
||||
// condResult is the result of an HTTP request precondition check.
|
||||
// See https://tools.ietf.org/html/rfc7232 section 3.
|
||||
type condResult int
|
||||
|
||||
const (
|
||||
condNone condResult = iota
|
||||
condTrue
|
||||
condFalse
|
||||
)
|
||||
|
||||
var (
|
||||
clientTemplates = make(map[string]*template.Template)
|
||||
unixEpochTime = time.Unix(0, 0)
|
||||
)
|
||||
|
||||
// isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0).
|
||||
func isZeroTime(t time.Time) bool {
|
||||
return t.IsZero() || t.Equal(unixEpochTime)
|
||||
}
|
||||
|
||||
type baseClientPage struct {
|
||||
Title string
|
||||
CurrentURL string
|
||||
FilesURL string
|
||||
CredentialsURL string
|
||||
StaticURL string
|
||||
LogoutURL string
|
||||
FilesTitle string
|
||||
CredentialsTitle string
|
||||
Version string
|
||||
CSRFToken string
|
||||
LoggedUser *dataprovider.User
|
||||
}
|
||||
|
||||
type dirMapping struct {
|
||||
DirName string
|
||||
Href string
|
||||
}
|
||||
|
||||
type filesPage struct {
|
||||
baseClientPage
|
||||
CurrentDir string
|
||||
Files []os.FileInfo
|
||||
Error string
|
||||
Paths []dirMapping
|
||||
FormatTime func(time.Time) string
|
||||
GetObjectURL func(string, string) string
|
||||
GetSize func(int64) string
|
||||
IsLink func(os.FileInfo) bool
|
||||
}
|
||||
|
||||
type clientMessagePage struct {
|
||||
baseClientPage
|
||||
Error string
|
||||
Success string
|
||||
}
|
||||
|
||||
type credentialsPage struct {
|
||||
baseClientPage
|
||||
PublicKeys []string
|
||||
ChangePwdURL string
|
||||
ManageKeysURL string
|
||||
PwdError string
|
||||
KeyError string
|
||||
}
|
||||
|
||||
func getFileObjectURL(baseDir, name string) string {
|
||||
return fmt.Sprintf("%v?path=%v", webClientFilesPath, url.QueryEscape(path.Join(baseDir, name)))
|
||||
}
|
||||
|
||||
func getFileObjectModTime(t time.Time) string {
|
||||
if isZeroTime(t) {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func isFileObjectLink(info os.FileInfo) bool {
|
||||
return info.Mode()&os.ModeSymlink != 0
|
||||
}
|
||||
|
||||
func loadClientTemplates(templatesPath string) {
|
||||
filesPaths := []string{
|
||||
filepath.Join(templatesPath, templateClientDir, templateClientBase),
|
||||
filepath.Join(templatesPath, templateClientDir, templateClientFiles),
|
||||
}
|
||||
credentialsPaths := []string{
|
||||
filepath.Join(templatesPath, templateClientDir, templateClientBase),
|
||||
filepath.Join(templatesPath, templateClientDir, templateClientCredentials),
|
||||
}
|
||||
loginPath := []string{
|
||||
filepath.Join(templatesPath, templateClientDir, templateClientLogin),
|
||||
}
|
||||
messagePath := []string{
|
||||
filepath.Join(templatesPath, templateClientDir, templateClientBase),
|
||||
filepath.Join(templatesPath, templateClientDir, templateClientMessage),
|
||||
}
|
||||
|
||||
filesTmpl := utils.LoadTemplate(template.ParseFiles(filesPaths...))
|
||||
credentialsTmpl := utils.LoadTemplate(template.ParseFiles(credentialsPaths...))
|
||||
loginTmpl := utils.LoadTemplate(template.ParseFiles(loginPath...))
|
||||
messageTmpl := utils.LoadTemplate(template.ParseFiles(messagePath...))
|
||||
|
||||
clientTemplates[templateClientFiles] = filesTmpl
|
||||
clientTemplates[templateClientCredentials] = credentialsTmpl
|
||||
clientTemplates[templateClientLogin] = loginTmpl
|
||||
clientTemplates[templateClientMessage] = messageTmpl
|
||||
}
|
||||
|
||||
func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage {
|
||||
var csrfToken string
|
||||
if currentURL != "" {
|
||||
csrfToken = createCSRFToken()
|
||||
}
|
||||
v := version.Get()
|
||||
|
||||
return baseClientPage{
|
||||
Title: title,
|
||||
CurrentURL: currentURL,
|
||||
FilesURL: webClientFilesPath,
|
||||
CredentialsURL: webClientCredentialsPath,
|
||||
StaticURL: webStaticFilesPath,
|
||||
LogoutURL: webClientLogoutPath,
|
||||
FilesTitle: pageClientFilesTitle,
|
||||
CredentialsTitle: pageClientCredentialsTitle,
|
||||
Version: fmt.Sprintf("%v-%v", v.Version, v.CommitHash),
|
||||
CSRFToken: csrfToken,
|
||||
LoggedUser: getUserFromToken(r),
|
||||
}
|
||||
}
|
||||
|
||||
func renderClientTemplate(w http.ResponseWriter, tmplName string, data interface{}) {
|
||||
err := clientTemplates[tmplName].ExecuteTemplate(w, tmplName, data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
errorString = body + " "
|
||||
}
|
||||
if err != nil {
|
||||
errorString += err.Error()
|
||||
}
|
||||
data := clientMessagePage{
|
||||
baseClientPage: getBaseClientPageData(title, "", r),
|
||||
Error: errorString,
|
||||
Success: message,
|
||||
}
|
||||
w.WriteHeader(statusCode)
|
||||
renderClientTemplate(w, templateClientMessage, data)
|
||||
}
|
||||
|
||||
func renderClientInternalServerErrorPage(w http.ResponseWriter, r *http.Request, err error) {
|
||||
renderClientMessagePage(w, r, page500Title, page500Body, http.StatusInternalServerError, err, "")
|
||||
}
|
||||
|
||||
func renderClientBadRequestPage(w http.ResponseWriter, r *http.Request, err error) {
|
||||
renderClientMessagePage(w, r, page400Title, "", http.StatusBadRequest, err, "")
|
||||
}
|
||||
|
||||
func renderClientForbiddenPage(w http.ResponseWriter, r *http.Request, body string) {
|
||||
renderClientMessagePage(w, r, page403Title, "", http.StatusForbidden, nil, body)
|
||||
}
|
||||
|
||||
func renderClientNotFoundPage(w http.ResponseWriter, r *http.Request, err error) {
|
||||
renderClientMessagePage(w, r, page404Title, page404Body, http.StatusNotFound, err, "")
|
||||
}
|
||||
|
||||
func renderFilesPage(w http.ResponseWriter, r *http.Request, files []os.FileInfo, dirName, error string) {
|
||||
data := filesPage{
|
||||
baseClientPage: getBaseClientPageData(pageClientFilesTitle, webClientFilesPath, r),
|
||||
Files: files,
|
||||
Error: error,
|
||||
CurrentDir: dirName,
|
||||
FormatTime: getFileObjectModTime,
|
||||
GetObjectURL: getFileObjectURL,
|
||||
GetSize: utils.ByteCountIEC,
|
||||
IsLink: isFileObjectLink,
|
||||
}
|
||||
paths := []dirMapping{}
|
||||
if dirName != "/" {
|
||||
paths = append(paths, dirMapping{
|
||||
DirName: path.Base(dirName),
|
||||
Href: "",
|
||||
})
|
||||
for {
|
||||
dirName = path.Dir(dirName)
|
||||
if dirName == "/" || dirName == "." {
|
||||
break
|
||||
}
|
||||
paths = append([]dirMapping{{
|
||||
DirName: path.Base(dirName),
|
||||
Href: getFileObjectURL("/", dirName)},
|
||||
}, paths...)
|
||||
}
|
||||
}
|
||||
data.Paths = paths
|
||||
renderClientTemplate(w, templateClientFiles, data)
|
||||
}
|
||||
|
||||
func renderCredentialsPage(w http.ResponseWriter, r *http.Request, pwdError string, keyError string) {
|
||||
data := credentialsPage{
|
||||
baseClientPage: getBaseClientPageData(pageClientCredentialsTitle, webClientCredentialsPath, r),
|
||||
ChangePwdURL: webChangeClientPwdPath,
|
||||
ManageKeysURL: webChangeClientKeysPath,
|
||||
PwdError: pwdError,
|
||||
KeyError: keyError,
|
||||
}
|
||||
user, err := dataprovider.UserExists(data.LoggedUser.Username)
|
||||
if err != nil {
|
||||
renderClientInternalServerErrorPage(w, r, err)
|
||||
}
|
||||
data.PublicKeys = user.PublicKeys
|
||||
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)
|
||||
|
||||
http.Redirect(w, r, webClientLoginPath, http.StatusFound)
|
||||
}
|
||||
|
||||
func handleClientGetFiles(w http.ResponseWriter, r *http.Request) {
|
||||
common.Connections.AddNetworkConnection()
|
||||
defer common.Connections.RemoveNetworkConnection()
|
||||
|
||||
claims, err := getTokenClaims(r)
|
||||
if err != nil || claims.Username == "" {
|
||||
renderClientForbiddenPage(w, r, "Invalid token claims")
|
||||
return
|
||||
}
|
||||
if !common.Connections.IsNewConnectionAllowed() {
|
||||
logger.Log(logger.LevelDebug, common.ProtocolHTTP, "", "connection refused, configured limit reached")
|
||||
renderClientForbiddenPage(w, r, "configured connections limit reached")
|
||||
return
|
||||
}
|
||||
ipAddr := utils.GetIPFromRemoteAddress(r.RemoteAddr)
|
||||
if common.IsBanned(ipAddr) {
|
||||
renderClientForbiddenPage(w, r, "your IP address is banned")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := dataprovider.UserExists(claims.Username)
|
||||
if err != nil {
|
||||
renderClientInternalServerErrorPage(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
connID := xid.New().String()
|
||||
connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID)
|
||||
if err := checkWebClientUser(&user, r, connectionID); err != nil {
|
||||
renderClientForbiddenPage(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
connection := &Connection{
|
||||
BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, user),
|
||||
request: r,
|
||||
}
|
||||
common.Connections.Add(connection)
|
||||
defer common.Connections.Remove(connection.GetID())
|
||||
|
||||
name := "/"
|
||||
if _, ok := r.URL.Query()["path"]; ok {
|
||||
name = utils.CleanPath(r.URL.Query().Get("path"))
|
||||
}
|
||||
var info os.FileInfo
|
||||
if name == "/" {
|
||||
info = vfs.NewFileInfo(name, true, 0, time.Now(), false)
|
||||
} else {
|
||||
info, err = connection.Stat(name, 0)
|
||||
}
|
||||
if err != nil {
|
||||
renderFilesPage(w, r, nil, name, fmt.Sprintf("unable to stat file %#v: %v", name, err))
|
||||
return
|
||||
}
|
||||
if info.IsDir() {
|
||||
renderDirContents(w, r, connection, name)
|
||||
return
|
||||
}
|
||||
downloadFile(w, r, connection, name, info)
|
||||
}
|
||||
|
||||
func handleClientGetCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
renderCredentialsPage(w, r, "", "")
|
||||
}
|
||||
|
||||
func handleWebClientChangePwdPost(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
renderCredentialsPage(w, r, err.Error(), "")
|
||||
return
|
||||
}
|
||||
if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
|
||||
renderClientForbiddenPage(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
err = doChangeUserPassword(r, r.Form.Get("current_password"), r.Form.Get("new_password1"),
|
||||
r.Form.Get("new_password2"))
|
||||
if err != nil {
|
||||
renderCredentialsPage(w, r, err.Error(), "")
|
||||
return
|
||||
}
|
||||
handleWebClientLogout(w, r)
|
||||
}
|
||||
|
||||
func handleWebClientManageKeysPost(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
renderCredentialsPage(w, r, "", err.Error())
|
||||
return
|
||||
}
|
||||
if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
|
||||
renderClientForbiddenPage(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
claims, err := getTokenClaims(r)
|
||||
if err != nil || claims.Username == "" {
|
||||
renderCredentialsPage(w, r, "", "Invalid token claims")
|
||||
return
|
||||
}
|
||||
user, err := dataprovider.UserExists(claims.Username)
|
||||
if err != nil {
|
||||
renderCredentialsPage(w, r, "", err.Error())
|
||||
return
|
||||
}
|
||||
publicKeysFormValue := r.Form.Get("public_keys")
|
||||
publicKeys := getSliceFromDelimitedValues(publicKeysFormValue, "\n")
|
||||
user.PublicKeys = publicKeys
|
||||
err = dataprovider.UpdateUser(&user)
|
||||
if err != nil {
|
||||
renderCredentialsPage(w, r, "", err.Error())
|
||||
return
|
||||
}
|
||||
renderClientMessagePage(w, r, "Public keys updated", "", http.StatusOK, nil, "Your public keys has been successfully updated")
|
||||
}
|
||||
|
||||
func doChangeUserPassword(r *http.Request, currentPassword, newPassword, confirmNewPassword string) error {
|
||||
if currentPassword == "" || newPassword == "" || confirmNewPassword == "" {
|
||||
return dataprovider.NewValidationError("please provide the current password and the new one two times")
|
||||
}
|
||||
if newPassword != confirmNewPassword {
|
||||
return dataprovider.NewValidationError("the two password fields do not match")
|
||||
}
|
||||
if currentPassword == newPassword {
|
||||
return dataprovider.NewValidationError("the new password must be different from the current one")
|
||||
}
|
||||
claims, err := getTokenClaims(r)
|
||||
if err != nil || claims.Username == "" {
|
||||
return errors.New("invalid token claims")
|
||||
}
|
||||
user, err := dataprovider.CheckUserAndPass(claims.Username, currentPassword, utils.GetIPFromRemoteAddress(r.RemoteAddr),
|
||||
common.ProtocolHTTP)
|
||||
if err != nil {
|
||||
return dataprovider.NewValidationError("current password does not match")
|
||||
}
|
||||
user.Password = newPassword
|
||||
|
||||
return dataprovider.UpdateUser(&user)
|
||||
}
|
||||
|
||||
func renderDirContents(w http.ResponseWriter, r *http.Request, connection *Connection, name string) {
|
||||
contents, err := connection.ReadDir(name)
|
||||
if err != nil {
|
||||
renderFilesPage(w, r, nil, name, fmt.Sprintf("unable to get contents for directory %#v: %v", name, err))
|
||||
return
|
||||
}
|
||||
renderFilesPage(w, r, contents, name, "")
|
||||
}
|
||||
|
||||
func downloadFile(w http.ResponseWriter, r *http.Request, connection *Connection, name string, info os.FileInfo) {
|
||||
var err error
|
||||
rangeHeader := r.Header.Get("Range")
|
||||
if rangeHeader != "" && checkIfRange(r, info.ModTime()) == condFalse {
|
||||
rangeHeader = ""
|
||||
}
|
||||
offset := int64(0)
|
||||
size := info.Size()
|
||||
responseStatus := http.StatusOK
|
||||
if strings.HasPrefix(rangeHeader, "bytes=") {
|
||||
if strings.Contains(rangeHeader, ",") {
|
||||
http.Error(w, fmt.Sprintf("unsupported range %#v", rangeHeader), http.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
offset, size, err = parseRangeRequest(rangeHeader[6:], size)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
responseStatus = http.StatusPartialContent
|
||||
}
|
||||
reader, err := connection.getFileReader(name, offset)
|
||||
if err != nil {
|
||||
renderFilesPage(w, r, nil, name, fmt.Sprintf("unable to read file %#v: %v", name, err))
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
w.Header().Set("Last-Modified", info.ModTime().UTC().Format(http.TimeFormat))
|
||||
if checkPreconditions(w, r, info.ModTime()) {
|
||||
return
|
||||
}
|
||||
ctype := mime.TypeByExtension(path.Ext(name))
|
||||
if ctype == "" {
|
||||
ctype = "application/octet-stream"
|
||||
}
|
||||
if responseStatus == http.StatusPartialContent {
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, info.Size()))
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%#v", path.Base(name)))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(responseStatus)
|
||||
if r.Method != http.MethodHead {
|
||||
io.CopyN(w, reader, size) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
func checkPreconditions(w http.ResponseWriter, r *http.Request, modtime time.Time) bool {
|
||||
if checkIfUnmodifiedSince(r, modtime) == condFalse {
|
||||
w.WriteHeader(http.StatusPreconditionFailed)
|
||||
return true
|
||||
}
|
||||
if checkIfModifiedSince(r, modtime) == condFalse {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkIfUnmodifiedSince(r *http.Request, modtime time.Time) condResult {
|
||||
ius := r.Header.Get("If-Unmodified-Since")
|
||||
if ius == "" || isZeroTime(modtime) {
|
||||
return condNone
|
||||
}
|
||||
t, err := http.ParseTime(ius)
|
||||
if err != nil {
|
||||
return condNone
|
||||
}
|
||||
|
||||
// The Last-Modified header truncates sub-second precision so
|
||||
// the modtime needs to be truncated too.
|
||||
modtime = modtime.Truncate(time.Second)
|
||||
if modtime.Before(t) || modtime.Equal(t) {
|
||||
return condTrue
|
||||
}
|
||||
return condFalse
|
||||
}
|
||||
|
||||
func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
return condNone
|
||||
}
|
||||
ims := r.Header.Get("If-Modified-Since")
|
||||
if ims == "" || isZeroTime(modtime) {
|
||||
return condNone
|
||||
}
|
||||
t, err := http.ParseTime(ims)
|
||||
if err != nil {
|
||||
return condNone
|
||||
}
|
||||
// The Last-Modified header truncates sub-second precision so
|
||||
// the modtime needs to be truncated too.
|
||||
modtime = modtime.Truncate(time.Second)
|
||||
if modtime.Before(t) || modtime.Equal(t) {
|
||||
return condFalse
|
||||
}
|
||||
return condTrue
|
||||
}
|
||||
|
||||
func checkIfRange(r *http.Request, modtime time.Time) condResult {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
return condNone
|
||||
}
|
||||
ir := r.Header.Get("If-Range")
|
||||
if ir == "" {
|
||||
return condNone
|
||||
}
|
||||
if modtime.IsZero() {
|
||||
return condFalse
|
||||
}
|
||||
t, err := http.ParseTime(ir)
|
||||
if err != nil {
|
||||
return condFalse
|
||||
}
|
||||
if modtime.Add(60 * time.Second).Before(t) {
|
||||
return condTrue
|
||||
}
|
||||
return condFalse
|
||||
}
|
||||
|
||||
func parseRangeRequest(bytesRange string, size int64) (int64, int64, error) {
|
||||
var start, end int64
|
||||
var err error
|
||||
|
||||
values := strings.Split(bytesRange, "-")
|
||||
if values[0] == "" {
|
||||
start = -1
|
||||
} else {
|
||||
start, err = strconv.ParseInt(values[0], 10, 64)
|
||||
if err != nil {
|
||||
return start, size, err
|
||||
}
|
||||
}
|
||||
if len(values) >= 2 {
|
||||
if values[1] != "" {
|
||||
end, err = strconv.ParseInt(values[1], 10, 64)
|
||||
if err != nil {
|
||||
return start, size, err
|
||||
}
|
||||
if end >= size {
|
||||
end = size - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if start == -1 && end == 0 {
|
||||
return 0, 0, fmt.Errorf("unsupported range %#v", bytesRange)
|
||||
}
|
||||
|
||||
if end > 0 {
|
||||
if start == -1 {
|
||||
// we have something like -500
|
||||
start = size - end
|
||||
size = end
|
||||
// this can't happen, we did end = size -1 above
|
||||
/*if start < 0 {
|
||||
return 0, 0, fmt.Errorf("unacceptable range %#v", bytesRange)
|
||||
}*/
|
||||
} else {
|
||||
// we have something like 500-600
|
||||
size = end - start + 1
|
||||
if size < 0 {
|
||||
return 0, 0, fmt.Errorf("unacceptable range %#v", bytesRange)
|
||||
}
|
||||
}
|
||||
return start, size, nil
|
||||
}
|
||||
/*if start == -1 {
|
||||
return 0, 0, fmt.Errorf("unacceptable range %#v", bytesRange)
|
||||
}*/
|
||||
// we have something like 500-
|
||||
size -= start
|
||||
if size < 0 {
|
||||
return 0, 0, fmt.Errorf("unacceptable range %#v", bytesRange)
|
||||
}
|
||||
return start, size, err
|
||||
}
|
||||
|
||||
func updateLoginMetrics(user *dataprovider.User, ip string, err error) {
|
||||
metrics.AddLoginAttempt(dataprovider.LoginMethodPassword)
|
||||
if err != nil {
|
||||
logger.ConnectionFailedLog(user.Username, ip, dataprovider.LoginMethodPassword, common.ProtocolHTTP, err.Error())
|
||||
event := common.HostEventLoginFailed
|
||||
if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
|
||||
event = common.HostEventUserNotFound
|
||||
}
|
||||
common.AddDefenderEvent(ip, event)
|
||||
}
|
||||
metrics.AddLoginResult(dataprovider.LoginMethodPassword, err)
|
||||
dataprovider.ExecutePostLoginHook(user, dataprovider.LoginMethodPassword, ip, common.ProtocolHTTP, err)
|
||||
}
|
||||
|
||||
func checkWebClientUser(user *dataprovider.User, r *http.Request, connectionID string) error {
|
||||
if utils.IsStringInSlice(common.ProtocolHTTP, user.Filters.DeniedProtocols) {
|
||||
logger.Debug(logSender, connectionID, "cannot login user %#v, protocol HTTP is not allowed", user.Username)
|
||||
return fmt.Errorf("protocol HTTP is not allowed for user %#v", user.Username)
|
||||
}
|
||||
if !user.IsLoginMethodAllowed(dataprovider.LoginMethodPassword, nil) {
|
||||
logger.Debug(logSender, connectionID, "cannot login user %#v, password login method is not allowed", user.Username)
|
||||
return fmt.Errorf("login method password is not allowed for user %#v", user.Username)
|
||||
}
|
||||
if user.MaxSessions > 0 {
|
||||
activeSessions := common.Connections.GetActiveSessions(user.Username)
|
||||
if activeSessions >= user.MaxSessions {
|
||||
logger.Debug(logSender, connectionID, "authentication refused for user: %#v, too many open sessions: %v/%v", user.Username,
|
||||
activeSessions, user.MaxSessions)
|
||||
return fmt.Errorf("too many open sessions: %v", activeSessions)
|
||||
}
|
||||
}
|
||||
if !user.IsLoginFromAddrAllowed(r.RemoteAddr) {
|
||||
logger.Debug(logSender, connectionID, "cannot login user %#v, remote address is not allowed: %v", user.Username, r.RemoteAddr)
|
||||
return fmt.Errorf("login for user %#v is not allowed from this address: %v", user.Username, r.RemoteAddr)
|
||||
}
|
||||
if connAddr, ok := r.Context().Value(connAddrKey).(string); ok {
|
||||
if connAddr != r.RemoteAddr {
|
||||
connIPAddr := utils.GetIPFromRemoteAddress(connAddr)
|
||||
if common.IsBanned(connIPAddr) {
|
||||
return errors.New("your IP address is banned")
|
||||
}
|
||||
if !user.IsLoginFromAddrAllowed(connIPAddr) {
|
||||
return fmt.Errorf("login from IP %v is not allowed", connIPAddr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -1168,6 +1168,11 @@ func compareUserFilterSubStructs(expected *dataprovider.User, actual *dataprovid
|
|||
return errors.New("denied protocols contents mismatch")
|
||||
}
|
||||
}
|
||||
for _, options := range expected.Filters.WebClient {
|
||||
if !utils.IsStringInSlice(options, actual.Filters.WebClient) {
|
||||
return errors.New("web client options contents mismatch")
|
||||
}
|
||||
}
|
||||
if expected.Filters.Hooks.ExternalAuthDisabled != actual.Filters.Hooks.ExternalAuthDisabled {
|
||||
return errors.New("external_auth_disabled hook mismatch")
|
||||
}
|
||||
|
@ -1202,6 +1207,9 @@ func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User)
|
|||
if expected.Filters.TLSUsername != actual.Filters.TLSUsername {
|
||||
return errors.New("TLSUsername mismatch")
|
||||
}
|
||||
if len(expected.Filters.WebClient) != len(actual.Filters.WebClient) {
|
||||
return errors.New("WebClient filter mismatch")
|
||||
}
|
||||
if err := compareUserFilterSubStructs(expected, actual); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
#!/bin/bash
|
||||
|
||||
NFPM_VERSION=2.5.0
|
||||
NFPM_VERSION=2.5.1
|
||||
NFPM_ARCH=${NFPM_ARCH:-amd64}
|
||||
if [ -z ${SFTPGO_VERSION} ]
|
||||
then
|
||||
|
|
|
@ -181,6 +181,7 @@
|
|||
"port": 8080,
|
||||
"address": "127.0.0.1",
|
||||
"enable_web_admin": true,
|
||||
"enable_web_client": true,
|
||||
"enable_https": false,
|
||||
"client_auth_type": 0,
|
||||
"tls_cipher_suites": []
|
||||
|
@ -189,7 +190,7 @@
|
|||
"templates_path": "templates",
|
||||
"static_files_path": "static",
|
||||
"backups_path": "backups",
|
||||
"web_admin_root": "",
|
||||
"web_root": "",
|
||||
"certificate_file": "",
|
||||
"certificate_key_file": "",
|
||||
"ca_certificates": [],
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-bordered nowrap" id="dataTable" width="100%" cellspacing="0">
|
||||
<table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
|
@ -161,7 +161,10 @@
|
|||
};
|
||||
|
||||
var table = $('#dataTable').DataTable({
|
||||
"select": true,
|
||||
"select": {
|
||||
"style": "single",
|
||||
"blurable": true
|
||||
},
|
||||
"stateSave": true,
|
||||
"stateDuration": 3600,
|
||||
"buttons": [],
|
||||
|
@ -177,7 +180,7 @@
|
|||
}
|
||||
],
|
||||
"scrollX": false,
|
||||
"scrollY": "50vh",
|
||||
"scrollY": false,
|
||||
"responsive": true,
|
||||
"order": [[1, 'asc']]
|
||||
});
|
|
@ -21,7 +21,7 @@
|
|||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-bordered nowrap" id="dataTable" width="100%" cellspacing="0">
|
||||
<table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
|
@ -133,8 +133,12 @@
|
|||
};
|
||||
|
||||
var table = $('#dataTable').DataTable({
|
||||
"select": "true",
|
||||
"select": {
|
||||
"style": "single",
|
||||
"blurable": true
|
||||
},
|
||||
"buttons": [],
|
||||
"lengthChange": false,
|
||||
"columnDefs": [
|
||||
{
|
||||
"targets": [0],
|
||||
|
@ -156,13 +160,13 @@
|
|||
{{if .LoggedAdmin.HasPermission "close_conns"}}
|
||||
table.button().add(0,'disconnect');
|
||||
|
||||
table.buttons().container().appendTo('#dataTable_wrapper .col-md-6:eq(0)');
|
||||
|
||||
table.on('select deselect', function () {
|
||||
var selectedRows = table.rows({ selected: true }).count();
|
||||
table.button('disconnect:name').enable(selectedRows == 1);
|
||||
});
|
||||
{{end}}
|
||||
table.button().add(0,'pageLength');
|
||||
table.buttons().container().appendTo('#dataTable_wrapper .col-md-6:eq(0)');
|
||||
|
||||
});
|
||||
</script>
|
|
@ -26,7 +26,7 @@
|
|||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-bordered nowrap" id="dataTable" width="100%" cellspacing="0">
|
||||
<table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
|
@ -220,7 +220,10 @@ function deleteAction() {
|
|||
};
|
||||
|
||||
var table = $('#dataTable').DataTable({
|
||||
"select": true,
|
||||
"select": {
|
||||
"style": "single",
|
||||
"blurable": true
|
||||
},
|
||||
"stateSave": true,
|
||||
"stateDuration": 3600,
|
||||
"buttons": [],
|
|
@ -176,6 +176,18 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idWebClient" class="col-sm-2 col-form-label">Web client</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" id="idWebClient" name="web_client_options" multiple>
|
||||
{{range $option := .WebClientOptions}}
|
||||
<option value="{{$option}}" {{range $p :=$.User.Filters.WebClient }}{{if eq $p $option}}selected{{end}}{{end}}>{{$option}}
|
||||
</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idHomeDir" class="col-sm-2 col-form-label">Home Dir</label>
|
||||
<div class="col-sm-10">
|
|
@ -26,7 +26,7 @@
|
|||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-bordered nowrap" id="dataTable" width="100%" cellspacing="0">
|
||||
<table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
|
@ -240,7 +240,10 @@
|
|||
};
|
||||
|
||||
var table = $('#dataTable').DataTable({
|
||||
"select": true,
|
||||
"select": {
|
||||
"style": "single",
|
||||
"blurable": true
|
||||
},
|
||||
"stateSave": true,
|
||||
"stateDuration": 3600,
|
||||
"buttons": [],
|
217
templates/webclient/base.html
Normal file
217
templates/webclient/base.html
Normal file
|
@ -0,0 +1,217 @@
|
|||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="description" content="">
|
||||
<meta name="author" content="">
|
||||
|
||||
<title>SFTPGo WebClient - {{template "title" .}}</title>
|
||||
|
||||
<link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
|
||||
|
||||
<!-- Custom fonts for this template-->
|
||||
<link href="{{.StaticURL}}/vendor/fontawesome-free/css/fontawesome.min.css" rel="stylesheet" type="text/css">
|
||||
<link href="{{.StaticURL}}/vendor/fontawesome-free/css/solid.min.css" rel="stylesheet" type="text/css">
|
||||
|
||||
<!-- Custom styles for this template-->
|
||||
<link href="{{.StaticURL}}/css/sb-admin-2.min.css" rel="stylesheet">
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('{{.StaticURL}}/vendor/fonts/Roboto-Bold-webfont.woff');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('{{.StaticURL}}/vendor/fonts/Roboto-Regular-webfont.woff');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('{{.StaticURL}}/vendor/fonts/Roboto-Light-webfont.woff');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
div.dt-buttons {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.text-form-error {
|
||||
color: var(--red) !important;
|
||||
}
|
||||
</style>
|
||||
{{block "extra_css" .}}{{end}}
|
||||
|
||||
</head>
|
||||
|
||||
<body id="page-top">
|
||||
|
||||
<!-- Page Wrapper -->
|
||||
<div id="wrapper">
|
||||
|
||||
{{if .LoggedUser.Username}}
|
||||
<!-- Sidebar -->
|
||||
<ul class="navbar-nav bg-gradient-primary sidebar sidebar-dark accordion" id="accordionSidebar">
|
||||
|
||||
<!-- Sidebar - Brand -->
|
||||
<div class="sidebar-brand d-flex align-items-center justify-content-center">
|
||||
<div style="text-transform: none;">SFTPGo WebClient</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="sidebar-divider my-0">
|
||||
|
||||
<li class="nav-item {{if eq .CurrentURL .FilesURL}}active{{end}}">
|
||||
<a class="nav-link" href="{{.FilesURL}}">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<span>{{.FilesTitle}}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item {{if eq .CurrentURL .CredentialsURL}}active{{end}}">
|
||||
<a class="nav-link" href="{{.CredentialsURL}}">
|
||||
<i class="fas fa-key"></i>
|
||||
<span>{{.CredentialsTitle}}</span></a>
|
||||
</li>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="sidebar-divider d-none d-md-block">
|
||||
|
||||
<!-- Sidebar Toggler (Sidebar) -->
|
||||
<div class="text-center d-none d-md-inline">
|
||||
<button class="rounded-circle border-0" id="sidebarToggle"></button>
|
||||
</div>
|
||||
|
||||
</ul>
|
||||
<!-- End of Sidebar -->
|
||||
{{end}}
|
||||
|
||||
<!-- Content Wrapper -->
|
||||
<div id="content-wrapper" class="d-flex flex-column">
|
||||
|
||||
<!-- Main Content -->
|
||||
<div id="content">
|
||||
|
||||
{{if .LoggedUser.Username}}
|
||||
<!-- Topbar -->
|
||||
<nav class="navbar navbar-expand navbar-light bg-white topbar mb-4 static-top shadow">
|
||||
|
||||
<button id="sidebarToggleTop" class="btn btn-link d-md-none rounded-circle mr-3">
|
||||
<i class="fa fa-bars"></i>
|
||||
</button>
|
||||
|
||||
<!-- Topbar Navbar -->
|
||||
<ul class="navbar-nav ml-auto">
|
||||
|
||||
<!-- Nav Item - User Information -->
|
||||
<li class="nav-item dropdown no-arrow">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mr-2 d-none d-lg-inline text-gray-600 small">{{.LoggedUser.Username}}</span>
|
||||
<i class="fas fa-user fa-fw"></i>
|
||||
</a>
|
||||
<!-- Dropdown - User Information -->
|
||||
<div class="dropdown-menu dropdown-menu-right shadow animated--grow-in" aria-labelledby="userDropdown">
|
||||
<a class="dropdown-item" href="#" data-toggle="modal" data-target="#logoutModal">
|
||||
<i class="fas fa-sign-out-alt fa-sm fa-fw mr-2 text-gray-400"></i>
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
</nav>
|
||||
<!-- End of Topbar -->
|
||||
{{end}}
|
||||
|
||||
<!-- Begin Page Content -->
|
||||
<div class="container-fluid">
|
||||
|
||||
{{template "page_body" .}}
|
||||
|
||||
</div>
|
||||
<!-- /.container-fluid -->
|
||||
|
||||
</div>
|
||||
<!-- End of Main Content -->
|
||||
{{if .LoggedUser.Username}}
|
||||
<!-- Footer -->
|
||||
<footer class="sticky-footer bg-white">
|
||||
<div class="container my-auto">
|
||||
<div class="copyright text-center my-auto">
|
||||
<span>SFTPGo {{.Version}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- End of Footer -->
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
<!-- End of Content Wrapper -->
|
||||
|
||||
</div>
|
||||
<!-- End of Page Wrapper -->
|
||||
|
||||
<!-- Scroll to Top Button-->
|
||||
<a class="scroll-to-top rounded" href="#page-top">
|
||||
<i class="fas fa-angle-up"></i>
|
||||
</a>
|
||||
|
||||
<!-- Logout Modal-->
|
||||
<div class="modal fade" id="logoutModal" tabindex="-1" role="dialog" aria-labelledby="modalLabel"
|
||||
aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modalLabel">Ready to Leave?</h5>
|
||||
<button class="close" type="button" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">Select "Logout" below if you are ready to end your current session.</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
|
||||
<a class="btn btn-primary" href="{{.LogoutURL}}">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{block "dialog" .}}{{end}}
|
||||
|
||||
<!-- Bootstrap core JavaScript-->
|
||||
<script src="{{.StaticURL}}/vendor/jquery/jquery.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Core plugin JavaScript-->
|
||||
<script src="{{.StaticURL}}/vendor/jquery-easing/jquery.easing.min.js"></script>
|
||||
|
||||
<!-- Custom scripts for all pages-->
|
||||
<script src="{{.StaticURL}}/js/sb-admin-2.min.js"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
function fixedEncodeURIComponent(str) {
|
||||
return encodeURIComponent(str).replace(/[!'()*]/g, function (c) {
|
||||
return '%' + c.charCodeAt(0).toString(16);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Page level plugins -->
|
||||
{{block "extra_js" .}}{{end}}
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{end}}
|
74
templates/webclient/credentials.html
Normal file
74
templates/webclient/credentials.html
Normal file
|
@ -0,0 +1,74 @@
|
|||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}{{.Title}}{{end}}
|
||||
|
||||
{{define "page_body"}}
|
||||
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Change password</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .PwdError}}
|
||||
<div class="card mb-4 border-left-warning">
|
||||
<div class="card-body text-form-error">{{.PwdError}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<form id="user_form" action="{{.ChangePwdURL}}" method="POST" autocomplete="off">
|
||||
<div class="form-group row">
|
||||
<label for="idCurrentPassword" class="col-sm-2 col-form-label">Current password</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="password" class="form-control" id="idCurrentPassword" name="current_password" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idNewPassword1" class="col-sm-2 col-form-label">New password</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="password" class="form-control" id="idNewPassword1" name="new_password1" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idNewPassword2" class="col-sm-2 col-form-label">Confirm password</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="password" class="form-control" id="idNewPassword2" name="new_password2" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Change my password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{if .LoggedUser.CanManahePublicKeys}}
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Manage public keys</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .KeyError}}
|
||||
<div class="card mb-4 border-left-warning">
|
||||
<div class="card-body text-form-error">{{.KeyError}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<form id="key_form" action="{{.ManageKeysURL}}" method="POST">
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idPublicKeys" class="col-sm-2 col-form-label">Keys</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" id="idPublicKeys" name="public_keys" rows="3"
|
||||
aria-describedby="pkHelpBlock">{{range .PublicKeys}}{{.}} {{end}}</textarea>
|
||||
<small id="pkHelpBlock" class="form-text text-muted">
|
||||
One public key per line
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
125
templates/webclient/files.html
Normal file
125
templates/webclient/files.html
Normal file
|
@ -0,0 +1,125 @@
|
|||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}{{.Title}}{{end}}
|
||||
|
||||
{{define "extra_css"}}
|
||||
<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
|
||||
<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
|
||||
<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
|
||||
<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
|
||||
<link href="{{.StaticURL}}/vendor/datatables/select.bootstrap4.min.css" rel="stylesheet">
|
||||
{{end}}
|
||||
|
||||
{{define "page_body"}}
|
||||
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold"><a href="{{.FilesURL}}?path=%2F"><i class="fas fa-home"></i> Home</a> {{range .Paths}}{{if eq .Href ""}}/{{.DirName}}{{else}}<a href="{{.Href}}">/{{.DirName}}</a>{{end}}{{end}}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .Error}}
|
||||
<div class="card mb-4 border-left-warning">
|
||||
<div class="card-body text-form-error">{{.Error}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Last modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Files}}
|
||||
{{if .IsDir}}
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td><i class="fas fa-folder"></i> <a href="{{call $.GetObjectURL $.CurrentDir .Name}}">{{.Name}}</a></td>
|
||||
<td></td>
|
||||
<td>{{call $.FormatTime .ModTime}}</td>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td><i class="{{if call $.IsLink .}}fas fa-external-link-alt{{else}}fas fa-file{{end}}"></i> <a href="{{call $.GetObjectURL $.CurrentDir .Name}}">{{.Name}}</a></td>
|
||||
<td>{{if not (call $.IsLink .)}}{{call $.GetSize .Size}}{{end}}</td>
|
||||
<td>{{call $.FormatTime .ModTime}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "extra_js"}}
|
||||
<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/datatables/dataTables.select.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
$.fn.dataTable.ext.buttons.refresh = {
|
||||
text: '<i class="fas fa-sync-alt"></i>',
|
||||
name: 'refresh',
|
||||
titleAttr: "Refresh",
|
||||
action: function (e, dt, node, config) {
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
var table = $('#dataTable').DataTable({
|
||||
"buttons": [],
|
||||
"lengthChange": false,
|
||||
"columnDefs": [
|
||||
{
|
||||
"targets": [0],
|
||||
"visible": false,
|
||||
"searchable": false
|
||||
},
|
||||
{
|
||||
"targets": [2,3],
|
||||
"searchable": false
|
||||
}
|
||||
],
|
||||
"scrollX": false,
|
||||
"scrollY": false,
|
||||
"responsive": true,
|
||||
"language": {
|
||||
"emptyTable": "You have no files or folders"
|
||||
},
|
||||
/*"select": {
|
||||
"style": 'single',
|
||||
"blurable": true
|
||||
},*/
|
||||
"orderFixed": [ 0, 'asc' ],
|
||||
"order": [[1, 'asc']]
|
||||
});
|
||||
|
||||
new $.fn.dataTable.FixedHeader( table );
|
||||
|
||||
table.button().add(0,'refresh');
|
||||
table.button().add(0,'pageLength');
|
||||
table.buttons().container().appendTo('#dataTable_wrapper .col-md-6:eq(0)');
|
||||
|
||||
/*table.on('select', function (e, dt, type, indexes) {
|
||||
if (type === 'row') {
|
||||
var rows = table.rows(indexes).nodes().to$();
|
||||
$.each(rows, function() {
|
||||
if ($(this).hasClass('ignoreselection')) table.row($(this)).deselect();
|
||||
})
|
||||
}
|
||||
});*/
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
{{end}}
|
134
templates/webclient/login.html
Normal file
134
templates/webclient/login.html
Normal file
|
@ -0,0 +1,134 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="description" content="">
|
||||
<meta name="author" content="">
|
||||
|
||||
<title>SFTPGo - Login</title>
|
||||
|
||||
<link rel="shortcut icon" href="{{.StaticURL}}/favicon.ico" />
|
||||
|
||||
<!-- Custom styles for this template-->
|
||||
<link href="{{.StaticURL}}/css/sb-admin-2.min.css" rel="stylesheet">
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('{{.StaticURL}}/vendor/fonts/Roboto-Bold-webfont.woff');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('{{.StaticURL}}/vendor/fonts/Roboto-Regular-webfont.woff');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
src: url('{{.StaticURL}}/vendor/fonts/Roboto-Light-webfont.woff');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
div.dt-buttons {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.text-form-error {
|
||||
color: var(--red) !important;
|
||||
}
|
||||
|
||||
div.dt-buttons {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.text-form-error {
|
||||
color: var(--red) !important;
|
||||
}
|
||||
|
||||
form.user-custom .custom-checkbox.small label {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
form.user-custom .form-control-user-custom {
|
||||
font-size: 0.9rem;
|
||||
border-radius: 10rem;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
form.user-custom .btn-user-custom {
|
||||
font-size: 0.9rem;
|
||||
border-radius: 10rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
||||
<body class="bg-gradient-primary">
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Outer Row -->
|
||||
<div class="row justify-content-center">
|
||||
|
||||
<div class="col-xl-6 col-lg-7 col-md-9">
|
||||
|
||||
<div class="card o-hidden border-0 shadow-lg my-5">
|
||||
<div class="card-body p-0">
|
||||
<!-- Nested Row within Card Body -->
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div class="p-5">
|
||||
<div class="text-center">
|
||||
<h1 class="h4 text-gray-900 mb-4">SFTPGo WebClient - {{.Version}}</h1>
|
||||
</div>
|
||||
{{if .Error}}
|
||||
<div class="card mb-4 border-left-warning">
|
||||
<div class="card-body text-form-error">{{.Error}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<form id="login_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"
|
||||
class="user-custom">
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control form-control-user-custom"
|
||||
id="inputUsername" name="username" placeholder="Username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" class="form-control form-control-user-custom"
|
||||
id="inputPassword" name="password" placeholder="Password">
|
||||
</div>
|
||||
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
|
||||
<button type="submit" class="btn btn-primary btn-user-custom btn-block">
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap core JavaScript-->
|
||||
<script src="{{.StaticURL}}/vendor/jquery/jquery.min.js"></script>
|
||||
<script src="{{.StaticURL}}/vendor/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Core plugin JavaScript-->
|
||||
<script src="{{.StaticURL}}/vendor/jquery-easing/jquery.easing.min.js"></script>
|
||||
|
||||
<!-- Custom scripts for all pages-->
|
||||
<script src="{{.StaticURL}}/js/sb-admin-2.min.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
59
templates/webclient/message.html
Normal file
59
templates/webclient/message.html
Normal file
|
@ -0,0 +1,59 @@
|
|||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}{{.Title}}{{end}}
|
||||
|
||||
{{define "page_body"}}
|
||||
|
||||
{{if .LoggedUser.Username}}
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">{{.Title}}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .Error}}
|
||||
<div class="card mb-2 border-left-warning">
|
||||
<div class="card-body text-form-error">{{.Error}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Success}}
|
||||
<div class="card mb-2 border-left-success">
|
||||
<div class="card-body">{{.Success}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="row justify-content-center">
|
||||
|
||||
<div class="col-xl-8 col-lg-9 col-md-9">
|
||||
|
||||
<div class="card o-hidden border-0 shadow-lg my-5">
|
||||
<div class="card-body p-0">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-9">
|
||||
<div class="p-5">
|
||||
<div class="text-center">
|
||||
<h1 class="h4 text-gray-900 mb-4">{{.Title}}</h1>
|
||||
</div>
|
||||
{{if .Error}}
|
||||
<div class="card mb-4 border-left-warning">
|
||||
<div class="card-body text-form-error">{{.Error}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Success}}
|
||||
<div class="card mb-4 border-left-success">
|
||||
<div class="card-body">{{.Success}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{end}}
|
|
@ -40,8 +40,8 @@ const (
|
|||
|
||||
// IsStringInSlice searches a string in a slice and returns true if the string is found
|
||||
func IsStringInSlice(obj string, list []string) bool {
|
||||
for _, v := range list {
|
||||
if v == obj {
|
||||
for i := 0; i < len(list); i++ {
|
||||
if list[i] == obj {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
@ -51,8 +51,8 @@ func IsStringInSlice(obj string, list []string) bool {
|
|||
// IsStringPrefixInSlice searches a string prefix in a slice and returns true
|
||||
// if a matching prefix is found
|
||||
func IsStringPrefixInSlice(obj string, list []string) bool {
|
||||
for _, v := range list {
|
||||
if strings.HasPrefix(obj, v) {
|
||||
for i := 0; i < len(list); i++ {
|
||||
if strings.HasPrefix(obj, list[i]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -96,6 +96,7 @@ func (fs *CryptFs) Open(name string, offset int64) (File, *pipeat.PipeReaderAt,
|
|||
finished := false
|
||||
for !finished {
|
||||
readed, err = readerAt.ReadAt(buf, offset)
|
||||
offset += int64(readed)
|
||||
if err != nil && err != io.EOF {
|
||||
break
|
||||
}
|
||||
|
|
10
vfs/osfs.go
10
vfs/osfs.go
|
@ -64,6 +64,16 @@ func (fs *OsFs) Lstat(name string) (os.FileInfo, error) {
|
|||
// Open opens the named file for reading
|
||||
func (*OsFs) Open(name string, offset int64) (File, *pipeat.PipeReaderAt, func(), error) {
|
||||
f, err := os.Open(name)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if offset > 0 {
|
||||
_, err = f.Seek(offset, io.SeekStart)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
return f, nil, nil, err
|
||||
}
|
||||
|
||||
|
|
|
@ -323,6 +323,7 @@ func (f *webDavFile) closeIO() error {
|
|||
func (f *webDavFile) setFinished() error {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
|
||||
if f.isFinished {
|
||||
return common.ErrTransferClosed
|
||||
}
|
||||
|
|
|
@ -151,7 +151,7 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
defer common.Connections.RemoveNetworkConnection()
|
||||
|
||||
if !common.Connections.IsNewConnectionAllowed() {
|
||||
logger.Log(logger.LevelDebug, common.ProtocolFTP, "", "connection refused, configured limit reached")
|
||||
logger.Log(logger.LevelDebug, common.ProtocolWebDAV, "", "connection refused, configured limit reached")
|
||||
http.Error(w, common.ErrConnectionDenied.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
@ -281,7 +281,7 @@ func (s *webDavServer) authenticate(r *http.Request, ip string) (dataprovider.Us
|
|||
if err != nil {
|
||||
user.Username = username
|
||||
updateLoginMetrics(&user, ip, loginMethod, err)
|
||||
return user, false, nil, loginMethod, err
|
||||
return user, false, nil, loginMethod, dataprovider.ErrInvalidCredentials
|
||||
}
|
||||
lockSystem := webdav.NewMemLS()
|
||||
cachedUser = &dataprovider.CachedUser{
|
||||
|
|
|
@ -1979,7 +1979,7 @@ func TestWrongClientCertificate(t *testing.T) {
|
|||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, string(body))
|
||||
assert.Contains(t, string(body), "CN \"client1\" does not match username \"client2\"")
|
||||
assert.Contains(t, string(body), "invalid credentials")
|
||||
}
|
||||
|
||||
_, err = httpdtest.RemoveUser(user, http.StatusOK)
|
||||
|
|
|
@ -53,7 +53,8 @@ Name: "{commonappdata}\{#MyAppName}\backups"; Permissions: everyone-full
|
|||
Name: "{commonappdata}\{#MyAppName}\credentials"; Permissions: everyone-full
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\Web Admin"; Filename: "http://127.0.0.1:8080/web";
|
||||
Name: "{group}\Web Admin"; Filename: "http://127.0.0.1:8080/web/admin";
|
||||
Name: "{group}\Web Client"; Filename: "http://127.0.0.1:8080/web/client";
|
||||
Name: "{group}\Service Control"; WorkingDir: "{app}"; Filename: "powershell.exe"; Parameters: "-Command ""Start-Process cmd \""/k cd {app} & {#MyAppExeName} service --help\"" -Verb RunAs"; Comment: "Manage SFTPGo Service"
|
||||
Name: "{group}\Documentation"; Filename: "{#DocURL}";
|
||||
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
|
||||
|
|
Loading…
Reference in a new issue