add a basic front-end web interface for end-users

Fixes #339 #321 #398
This commit is contained in:
Nicola Murino 2021-05-06 21:35:43 +02:00
parent 5c99f4fb60
commit 23d9ebfc91
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
64 changed files with 4961 additions and 1858 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

1559
httpd/webadmin.go Normal file

File diff suppressed because it is too large Load diff

656
httpd/webclient.go Normal file
View 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
}

View file

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

View file

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

View file

@ -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": [],

View file

@ -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']]
});

View file

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

View file

@ -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": [],

View file

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

View file

@ -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": [],

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

View 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}}{{.}}&#10;{{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}}

View 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>&nbsp;Home</a>&nbsp;{{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>&nbsp;<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>&nbsp;<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}}

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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