diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index 7f5346ab..cb889174 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -31,18 +31,18 @@ jobs: - name: Build for Linux/macOS x86_64 if: startsWith(matrix.os, 'windows-') != true - run: go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo + run: go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo - name: Build for macOS arm64 if: startsWith(matrix.os, 'macos-') == true - run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64 + run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64 - name: Build for Windows if: startsWith(matrix.os, 'windows-') run: | $GIT_COMMIT = (git describe --always --dirty) | Out-String $DATE_TIME = ([datetime]::Now.ToUniversalTime().toString("yyyy-MM-ddTHH:mm:ssZ")) | Out-String - go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/version.date=$DATE_TIME" -o sftpgo.exe + go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/version.date=$DATE_TIME" -o sftpgo.exe - name: Run test cases using SQLite provider run: go test -v -p 1 -timeout 10m ./... -coverprofile=coverage.txt -covermode=atomic @@ -148,7 +148,7 @@ jobs: go-version: 1.16 - name: Build - run: go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo + run: go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo - name: Run tests using PostgreSQL provider run: | @@ -216,7 +216,7 @@ jobs: - name: Build on amd64 if: ${{ matrix.arch == 'amd64' }} run: | - go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo + go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo mkdir -p output/{init,bash_completion,zsh_completion} cp sftpgo.json output/ cp -r templates output/ @@ -253,7 +253,7 @@ jobs: tar -C /usr/local -xzf go.tar.gz run: | export PATH=$PATH:/usr/local/go/bin - go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo + go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo mkdir -p output/{init,bash_completion,zsh_completion} cp sftpgo.json output/ cp -r templates output/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c329d617..cc4009eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: tags: 'v*' env: - GO_VERSION: 1.16.3 + GO_VERSION: 1.16.5 jobs: prepare-sources-with-deps: @@ -53,18 +53,18 @@ jobs: - name: Build for macOS x86_64 if: startsWith(matrix.os, 'windows-') != true - run: go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo + run: go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo - name: Build for macOS arm64 if: startsWith(matrix.os, 'macos-') == true - run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64 + run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 SDKROOT=$(xcrun --sdk macosx --show-sdk-path) go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo_arm64 - name: Build for Windows if: startsWith(matrix.os, 'windows-') run: | $GIT_COMMIT = (git describe --always --dirty) | Out-String $DATE_TIME = ([datetime]::Now.ToUniversalTime().toString("yyyy-MM-ddTHH:mm:ssZ")) | Out-String - go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/version.date=$DATE_TIME" -o sftpgo.exe + go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/version.date=$DATE_TIME" -o sftpgo.exe - name: Initialize data provider run: ./sftpgo initprovider @@ -227,7 +227,7 @@ jobs: - name: Build on amd64 if: ${{ matrix.arch == 'amd64' }} run: | - go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo + go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo mkdir -p output/{init,sqlite,bash_completion,zsh_completion} echo "For documentation please take a look here:" > output/README.txt echo "" >> output/README.txt @@ -269,7 +269,7 @@ jobs: tar -C /usr/local -xzf go.tar.gz run: | export PATH=$PATH:/usr/local/go/bin - go build -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo + go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=`git describe --always --dirty` -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -o sftpgo mkdir -p output/{init,sqlite,bash_completion,zsh_completion} echo "For documentation please take a look here:" > output/README.txt echo "" >> output/README.txt diff --git a/Dockerfile b/Dockerfile index a680433f..c12e5357 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ COPY . . RUN set -xe && \ export COMMIT_SHA=${COMMIT_SHA:-$(git describe --always --dirty)} && \ - go build $(if [ -n "${FEATURES}" ]; then echo "-tags ${FEATURES}"; fi) -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=${COMMIT_SHA} -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -v -o sftpgo + go build $(if [ -n "${FEATURES}" ]; then echo "-tags ${FEATURES}"; fi) -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=${COMMIT_SHA} -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -v -o sftpgo FROM debian:buster-slim diff --git a/Dockerfile.alpine b/Dockerfile.alpine index a5d83b44..f59d37a4 100644 --- a/Dockerfile.alpine +++ b/Dockerfile.alpine @@ -23,7 +23,7 @@ COPY . . RUN set -xe && \ export COMMIT_SHA=${COMMIT_SHA:-$(git describe --always --dirty)} && \ - go build $(if [ -n "${FEATURES}" ]; then echo "-tags ${FEATURES}"; fi) -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=${COMMIT_SHA} -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -v -o sftpgo + go build $(if [ -n "${FEATURES}" ]; then echo "-tags ${FEATURES}"; fi) -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/version.commit=${COMMIT_SHA} -X github.com/drakkan/sftpgo/version.date=`date -u +%FT%TZ`" -v -o sftpgo FROM alpine:3.13 diff --git a/common/common.go b/common/common.go index 270f6d98..4be94a57 100644 --- a/common/common.go +++ b/common/common.go @@ -103,6 +103,8 @@ var ( ErrConnectionDenied = errors.New("you are not allowed to connect") ErrNoBinding = errors.New("no binding configured") ErrCrtRevoked = errors.New("your certificate has been revoked") + ErrNoCredentials = errors.New("no credential provided") + ErrInternalFailure = errors.New("internal failure") errNoTransfer = errors.New("requested transfer not found") errTransferMismatch = errors.New("transfer mismatch") ) diff --git a/common/protocol_test.go b/common/protocol_test.go index c565d34d..4a574a8e 100644 --- a/common/protocol_test.go +++ b/common/protocol_test.go @@ -211,7 +211,7 @@ func TestBaseConnection(t *testing.T) { } err = client.RemoveDirectory(linkName) if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Failure") + assert.Contains(t, err.Error(), "SSH_FX_FAILURE") } err = client.Remove(testFileName) assert.NoError(t, err) @@ -1735,35 +1735,35 @@ func TestVirtualFoldersLink(t *testing.T) { assert.NoError(t, err) err = client.Symlink(testFileName, path.Join(vdirPath1, testFileName+".link1")) if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } err = client.Symlink(testFileName, path.Join(vdirPath1, testDir, testFileName+".link1")) if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } err = client.Symlink(testFileName, path.Join(vdirPath2, testFileName+".link1")) if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } err = client.Symlink(testFileName, path.Join(vdirPath2, testDir, testFileName+".link1")) if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } err = client.Symlink(path.Join(vdirPath1, testFileName), testFileName+".link1") if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } err = client.Symlink(path.Join(vdirPath2, testFileName), testFileName+".link1") if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } err = client.Symlink(path.Join(vdirPath1, testFileName), path.Join(vdirPath2, testDir, testFileName+".link1")) if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } err = client.Symlink(path.Join(vdirPath2, testFileName), path.Join(vdirPath1, testFileName+".link1")) if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } err = client.Symlink("/", "/roolink") assert.ErrorIs(t, err, os.ErrPermission) @@ -1771,11 +1771,11 @@ func TestVirtualFoldersLink(t *testing.T) { assert.ErrorIs(t, err, os.ErrPermission) err = client.Symlink(testFileName, vdirPath1) if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } err = client.Symlink(vdirPath1, testFileName+".link2") if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } } _, err = httpdtest.RemoveUser(user, http.StatusOK) @@ -1828,7 +1828,7 @@ func TestDirs(t *testing.T) { assert.ErrorIs(t, err, os.ErrPermission) err = client.RemoveDirectory(path.Dir(vdirPath)) if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } err = client.Mkdir(vdirPath) assert.ErrorIs(t, err, os.ErrPermission) @@ -1836,13 +1836,13 @@ func TestDirs(t *testing.T) { assert.NoError(t, err) err = client.Rename("/adir", path.Dir(vdirPath)) if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } err = client.MkdirAll("/subdir/adir") assert.NoError(t, err) err = client.Rename("adir", "subdir/adir") if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } err = writeSFTPFile("/subdir/afile.bin", 64, client) assert.NoError(t, err) @@ -1854,7 +1854,7 @@ func TestDirs(t *testing.T) { assert.NoError(t, err) err = client.Rename(path.Dir(vdirPath), "renamed_vdir") if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } } @@ -2513,7 +2513,7 @@ func TestNonLocalCrossRename(t *testing.T) { // renaming a path to a virtual folder is not allowed err = client.Rename("/vdir", "new_vdir") if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } } @@ -2608,7 +2608,7 @@ func TestNonLocalCrossRenameNonLocalBaseUser(t *testing.T) { // renaming a path to a virtual folder is not allowed err = client.Rename("/vdir", "new_vdir") if assert.Error(t, err) { - assert.Contains(t, err.Error(), "Operation Unsupported") + assert.Contains(t, err.Error(), "SSH_FX_OP_UNSUPPORTED") } } diff --git a/dataprovider/cachedpassword.go b/dataprovider/cachedpassword.go index dd96a588..049a3575 100644 --- a/dataprovider/cachedpassword.go +++ b/dataprovider/cachedpassword.go @@ -1,6 +1,8 @@ package dataprovider -import "sync" +import ( + "sync" +) var cachedPasswords passwordsCache @@ -37,7 +39,7 @@ func (c *passwordsCache) Remove(username string) { delete(c.cache, username) } -// returns if the user is found and if the password match +// Check returns if the user is found and if the password match func (c *passwordsCache) Check(username, password string) (bool, bool) { if username == "" || password == "" { return false, false diff --git a/ftpd/server.go b/ftpd/server.go index a47e6f58..081bfaff 100644 --- a/ftpd/server.go +++ b/ftpd/server.go @@ -332,7 +332,7 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext if err != nil { errClose := user.CloseFs() logger.Warn(logSender, connectionID, "unable to check fs root: %v close fs error: %v", err, errClose) - return nil, err + return nil, common.ErrInternalFailure } connection := &Connection{ BaseConnection: common.NewBaseConnection(fmt.Sprintf("%v_%v", s.ID, cc.ID()), common.ProtocolFTP, remoteAddr, user), @@ -342,14 +342,14 @@ func (s *Server) validateUser(user dataprovider.User, cc ftpserver.ClientContext if err != nil { err = user.CloseFs() logger.Warn(logSender, connectionID, "unable to swap connection, close fs error: %v", err) - return nil, errors.New("internal authentication error") + return nil, common.ErrInternalFailure } return connection, nil } func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error) { metrics.AddLoginAttempt(loginMethod) - if err != nil { + if err != nil && err != common.ErrInternalFailure { logger.ConnectionFailedLog(user.Username, ip, loginMethod, common.ProtocolFTP, err.Error()) event := common.HostEventLoginFailed diff --git a/go.mod b/go.mod index 80dd740c..daac3f0d 100644 --- a/go.mod +++ b/go.mod @@ -8,27 +8,27 @@ 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-20210511081203-7d35d68092b8 - github.com/aws/aws-sdk-go v1.38.51 + github.com/aws/aws-sdk-go v1.38.55 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-20210528001815-f1fcb2512a3a - github.com/fclairamb/ftpserverlib v0.13.1 + github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b + github.com/fclairamb/ftpserverlib v0.13.2 github.com/frankban/quicktest v1.13.0 // 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.5.1 // indirect + github.com/goccy/go-json v0.6.1 // indirect github.com/google/go-cmp v0.5.6 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/grandcat/zeroconf v1.0.0 github.com/hashicorp/go-retryablehttp v0.7.0 github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126 - github.com/klauspost/compress v1.12.3 + github.com/klauspost/compress v1.13.0 github.com/klauspost/cpuid/v2 v2.0.6 // indirect github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect - github.com/lestrrat-go/jwx v1.2.0 + github.com/lestrrat-go/jwx v1.2.1 github.com/lib/pq v1.10.2 github.com/magiconair/properties v1.8.5 // indirect github.com/mattn/go-sqlite3 v1.14.7 @@ -37,14 +37,14 @@ require ( github.com/otiai10/copy v1.6.0 github.com/pelletier/go-toml v1.9.1 // indirect github.com/pires/go-proxyproto v0.5.0 - github.com/pkg/sftp v1.13.0 + github.com/pkg/sftp v1.13.1-0.20210522170736-5b98d05076b8 github.com/prometheus/client_golang v1.10.0 - github.com/prometheus/common v0.25.0 // indirect + github.com/prometheus/common v0.27.0 // indirect github.com/rs/cors v1.7.1-0.20200626170627-8b4a00bd362b github.com/rs/xid v1.3.0 github.com/rs/zerolog v1.22.0 github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/shirou/gopsutil/v3 v3.21.4 + github.com/shirou/gopsutil/v3 v3.21.5 github.com/spf13/afero v1.6.0 github.com/spf13/cast v1.3.1 // indirect github.com/spf13/cobra v1.1.3 @@ -53,18 +53,16 @@ require ( github.com/stretchr/testify v1.7.0 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.etcd.io/bbolt v1.3.6 go.uber.org/automaxprocs v1.4.0 gocloud.dev v0.23.0 gocloud.dev/secrets/hashivault v0.23.0 golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a golang.org/x/net v0.0.0-20210525063256-abc453219eb5 - golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea + golang.org/x/sys v0.0.0-20210603125802-9665404d3644 golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba - golang.org/x/tools v0.1.2 // indirect google.golang.org/api v0.47.0 - google.golang.org/genproto v0.0.0-20210524171403-669157292da3 // indirect - google.golang.org/grpc v1.38.0 // indirect + google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08 // 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 diff --git a/go.sum b/go.sum index 20e938ae..155775d2 100644 --- a/go.sum +++ b/go.sum @@ -127,8 +127,8 @@ github.com/aws/aws-sdk-go v1.25.37/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpi github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.30.27/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= -github.com/aws/aws-sdk-go v1.38.51 h1:aKQmbVbwOCuQSd8+fm/MR3bq0QOsu9Q7S+/QEND36oQ= -github.com/aws/aws-sdk-go v1.38.51/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go v1.38.55 h1:1Wv5CE1Zy0hJ6MJUQ1ekFiCsNKBK5W69+towYQ1P4Vs= +github.com/aws/aws-sdk-go v1.38.55/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= @@ -215,8 +215,8 @@ github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= -github.com/eikenb/pipeat v0.0.0-20210528001815-f1fcb2512a3a h1:+btFAKG3kNCqm1DMKDGaWkolX/4aytcbvnfdgt6z+UI= -github.com/eikenb/pipeat v0.0.0-20210528001815-f1fcb2512a3a/go.mod h1:+JPhBw5JdJrSF80r6xsSg1TYHjyAGxYs4X24VyUdMZU= +github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b h1:h7A/b/M1yuYqQwZmRf+9BKuUJQgkgdvXYAwVl7L7WBo= +github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84= github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -228,8 +228,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/fclairamb/ftpserverlib v0.13.1 h1:tCQuM6d7Rt7hwwhzlteQBZLz6XQnLlPG1Gtn1AnW6x4= -github.com/fclairamb/ftpserverlib v0.13.1/go.mod h1:T7GFWYWSREftxINf3ielPlaQ+aiE04/elEH0zLwkxBA= +github.com/fclairamb/ftpserverlib v0.13.2 h1:kh1n2NzdIJALzF1+X83MuNFPEU5Qx/q+Az5tv98th1M= +github.com/fclairamb/ftpserverlib v0.13.2/go.mod h1:T7GFWYWSREftxINf3ielPlaQ+aiE04/elEH0zLwkxBA= github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= @@ -261,6 +261,7 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.10.0 h1:dXFJfIHVvUcpSgDOV+Ne6t7jXri8Tfv2uOLHUZ2XNuo= github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-ldap/ldap/v3 v3.1.3/go.mod h1:3rbOH3jRS2u6jg2rJnKAMLE/xQyCKIveG2Sa/Cohzb8= github.com/go-ldap/ldap/v3 v3.1.10/go.mod h1:5Zun81jBTabRaI8lzN7E1JjyEl1g6zI6u9pd8luAK4Q= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -288,8 +289,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.5.1 h1:R9UYTOUvo7eIY9aeDMZ4L6OVtHaSr1k2No9W6MKjXrA= -github.com/goccy/go-json v0.5.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.6.1 h1:O7xC9WR7B09imThbRIEMIWK4MVcxOsLzWtGe16cv5SU= +github.com/goccy/go-json v0.6.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -544,8 +545,8 @@ github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQL github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.12.2/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= -github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU= -github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/klauspost/compress v1.13.0 h1:2T7tUoQrQT+fQWdaY5rjWztFGAFwbGD04iPJg90ZiOs= +github.com/klauspost/compress v1.13.0/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.6 h1:dQ5ueTiftKxp0gyjKSx5+8BtPWkyQbd95m8Gys/RarI= github.com/klauspost/cpuid/v2 v2.0.6/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -577,8 +578,8 @@ github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++ github.com/lestrrat-go/iter v1.0.1 h1:q8faalr2dY6o8bV45uwrxq12bRa1ezKrB6oM9FUgN4A= github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc= github.com/lestrrat-go/jwx v1.1.6/go.mod h1:c+R8G7qsaFNmTzYjU98A+sMh8Bo/MJqO9GnpqR+X024= -github.com/lestrrat-go/jwx v1.2.0 h1:n08WEu8cJy3uzuQ39KWAOIhM4XfeozgaEGA8mTiioZ8= -github.com/lestrrat-go/jwx v1.2.0/go.mod h1:Tg2uP7bpxEHUDtuWjap/PxroJ4okxGzkQznXiG+a5Dc= +github.com/lestrrat-go/jwx v1.2.1 h1:WJ/3tiPUz1wV24KiwMEanbENwHnYub9UqzCbQ82mv9c= +github.com/lestrrat-go/jwx v1.2.1/go.mod h1:Tg2uP7bpxEHUDtuWjap/PxroJ4okxGzkQznXiG+a5Dc= github.com/lestrrat-go/option v0.0.0-20210103042652-6f1ecfceda35/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lestrrat-go/option v1.0.0 h1:WqAWL8kh8VcSoD6xjSH34/1m8yxluXQbDeKNfvFeEO4= github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= @@ -716,8 +717,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pkg/sftp v1.13.0 h1:Riw6pgOKK41foc1I1Uu03CjvbLZDXeGpInycM4shXoI= -github.com/pkg/sftp v1.13.0/go.mod h1:41g+FIPlQUTDCveupEmEA65IoiQFrtgCeDopC4ajGIM= +github.com/pkg/sftp v1.13.1-0.20210522170736-5b98d05076b8 h1:vvdLIAEGhdfZaetTYpjq9cy4vob6dYLN6lxw1GVXd+g= +github.com/pkg/sftp v1.13.1-0.20210522170736-5b98d05076b8/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= @@ -747,8 +748,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.25.0 h1:IjJYZJCI8HZYtqA3xYwGyDzSCy1r4CA2GRh+4vdOmtE= -github.com/prometheus/common v0.25.0/go.mod h1:H6QK/N6XVT42whUeIdI3dp36w49c+/iMDk7UAI2qm7Q= +github.com/prometheus/common v0.27.0 h1:kJb5BtkTmonXrV2nfyRRlChGpgqhPCdj2ooGivZ8txo= +github.com/prometheus/common v0.27.0/go.mod h1:LdLj/WiR+LL0ThCPrtSZbijrsxInIhizDTiPlJhPPq4= 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= @@ -784,8 +785,8 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo= github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY= -github.com/shirou/gopsutil/v3 v3.21.4 h1:XB/+p+kVnyYLuPHCfa99lxz2aJyvVhnyd+FxZqH/k7M= -github.com/shirou/gopsutil/v3 v3.21.4/go.mod h1:ghfMypLDrFSWN2c9cDYFLHyynQ+QUht0cv/18ZqVczw= +github.com/shirou/gopsutil/v3 v3.21.5 h1:YUBf0w/KPLk7w1803AYBnH7BmA+1Z/Q5MEZxpREUaB4= +github.com/shirou/gopsutil/v3 v3.21.5/go.mod h1:ghfMypLDrFSWN2c9cDYFLHyynQ+QUht0cv/18ZqVczw= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -861,8 +862,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0= -go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= go.opencensus.io v0.15.0/go.mod h1:UffZAU+4sDEINUGP/B7UfBBkq4fqLu9zXAX7ke6CHW0= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= @@ -1010,6 +1011,7 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1025,12 +1027,14 @@ golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210420205809-ac73e9fd8988/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/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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea h1:+WiDlPBBaO+h9vPNZi8uJ3k4BkKQB7Iow3aqwHVA5hI= -golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603125802-9665404d3644 h1:CA1DEQ4NdKphKeL70tvsWNdT5oFh1lOjihRcEDROi0I= +golang.org/x/sys v0.0.0-20210603125802-9665404d3644/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= @@ -1208,8 +1212,8 @@ google.golang.org/genproto v0.0.0-20210423144448-3a41ef94ed2b/go.mod h1:P3QM42oQ google.golang.org/genproto v0.0.0-20210429181445-86c259c2b4ab/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= google.golang.org/genproto v0.0.0-20210506142907-4a47615972c2/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210524171403-669157292da3 h1:xFyh6GBb+NO1L0xqb978I3sBPQpk6FrKO0jJGRvdj/0= -google.golang.org/genproto v0.0.0-20210524171403-669157292da3/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08 h1:pc16UedxnxXXtGxHCSUhafAoVHQZ0yXl8ZelMH4EETc= +google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= 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= diff --git a/httpd/api_admin.go b/httpd/api_admin.go index 56e1c206..a64c9742 100644 --- a/httpd/api_admin.go +++ b/httpd/api_admin.go @@ -11,11 +11,6 @@ import ( "github.com/drakkan/sftpgo/dataprovider" ) -type adminPwd struct { - CurrentPassword string `json:"current_password"` - NewPassword string `json:"new_password"` -} - func getAdmins(w http.ResponseWriter, r *http.Request) { limit, offset, order, err := getSearchFilters(w, r) if err != nil { @@ -129,7 +124,7 @@ func deleteAdmin(w http.ResponseWriter, r *http.Request) { func changeAdminPassword(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) - var pwd adminPwd + var pwd pwdChange err := render.DecodeJSON(r.Body, &pwd) if err != nil { sendAPIResponse(w, r, err, "", http.StatusBadRequest) diff --git a/httpd/api_http_user.go b/httpd/api_http_user.go new file mode 100644 index 00000000..6a9a0782 --- /dev/null +++ b/httpd/api_http_user.go @@ -0,0 +1,238 @@ +package httpd + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-chi/render" + "github.com/rs/xid" + + "github.com/drakkan/sftpgo/common" + "github.com/drakkan/sftpgo/dataprovider" + "github.com/drakkan/sftpgo/utils" +) + +func readUserFolder(w http.ResponseWriter, r *http.Request) { + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) + return + } + user, err := dataprovider.UserExists(claims.Username) + if err != nil { + sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err)) + return + } + connID := xid.New().String() + connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID) + if err := checkHTTPClientUser(&user, r, connectionID); err != nil { + sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + connection := &Connection{ + BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, r.RemoteAddr, user), + request: r, + } + common.Connections.Add(connection) + defer common.Connections.Remove(connection.GetID()) + + name := utils.CleanPath(r.URL.Query().Get("path")) + contents, err := connection.ReadDir(name) + if err != nil { + sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err)) + return + } + results := make([]map[string]interface{}, 0, len(contents)) + for _, info := range contents { + res := make(map[string]interface{}) + res["name"] = info.Name() + if info.Mode().IsRegular() { + res["size"] = info.Size() + } + res["mode"] = info.Mode() + res["last_modified"] = info.ModTime().UTC().Format(time.RFC3339) + results = append(results, res) + } + + render.JSON(w, r, results) +} + +func getUserFile(w http.ResponseWriter, r *http.Request) { + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) + return + } + user, err := dataprovider.UserExists(claims.Username) + if err != nil { + sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err)) + return + } + connID := xid.New().String() + connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID) + if err := checkHTTPClientUser(&user, r, connectionID); err != nil { + sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + connection := &Connection{ + BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, r.RemoteAddr, user), + request: r, + } + common.Connections.Add(connection) + defer common.Connections.Remove(connection.GetID()) + + name := utils.CleanPath(r.URL.Query().Get("path")) + if name == "/" { + sendAPIResponse(w, r, nil, "Please set the path to a valid file", http.StatusBadRequest) + return + } + info, err := connection.Stat(name, 0) + if err != nil { + sendAPIResponse(w, r, err, "Unable to stat the requested file", getMappedStatusCode(err)) + return + } + if info.IsDir() { + sendAPIResponse(w, r, nil, fmt.Sprintf("Please set the path to a valid file, %#v is a directory", name), http.StatusBadRequest) + return + } + + if status, err := downloadFile(w, r, connection, name, info); err != nil { + resp := apiResponse{ + Error: err.Error(), + Message: http.StatusText(status), + } + ctx := r.Context() + if status != 0 { + ctx = context.WithValue(ctx, render.StatusCtxKey, status) + } + render.JSON(w, r.WithContext(ctx), resp) + } +} + +func getUserFilesAsZipStream(w http.ResponseWriter, r *http.Request) { + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) + return + } + user, err := dataprovider.UserExists(claims.Username) + if err != nil { + sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err)) + return + } + connID := xid.New().String() + connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID) + if err := checkHTTPClientUser(&user, r, connectionID); err != nil { + sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + connection := &Connection{ + BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, r.RemoteAddr, user), + request: r, + } + common.Connections.Add(connection) + defer common.Connections.Remove(connection.GetID()) + + var filesList []string + err = render.DecodeJSON(r.Body, &filesList) + if err != nil { + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + + baseDir := "/" + for idx := range filesList { + filesList[idx] = utils.CleanPath(filesList[idx]) + } + + w.Header().Set("Content-Disposition", "attachment; filename=\"sftpgo-download.zip\"") + renderCompressedFiles(w, connection, baseDir, filesList) +} + +func getUserPublicKeys(w http.ResponseWriter, r *http.Request) { + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) + return + } + user, err := dataprovider.UserExists(claims.Username) + if err != nil { + sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err)) + return + } + render.JSON(w, r, user.PublicKeys) +} + +func setUserPublicKeys(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + + claims, err := getTokenClaims(r) + if err != nil || claims.Username == "" { + sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest) + return + } + user, err := dataprovider.UserExists(claims.Username) + if err != nil { + sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err)) + return + } + + var publicKeys []string + err = render.DecodeJSON(r.Body, &publicKeys) + if err != nil { + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + + user.PublicKeys = publicKeys + err = dataprovider.UpdateUser(&user) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + sendAPIResponse(w, r, err, "Public keys updated", http.StatusOK) +} + +func changeUserPassword(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + + var pwd pwdChange + err := render.DecodeJSON(r.Body, &pwd) + if err != nil { + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + err = doChangeUserPassword(r, pwd.CurrentPassword, pwd.NewPassword, pwd.NewPassword) + if err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + sendAPIResponse(w, r, err, "Password updated", http.StatusOK) +} + +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) +} diff --git a/httpd/api_utils.go b/httpd/api_utils.go index 8a3e10b1..73b69f22 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -3,12 +3,15 @@ package httpd import ( "context" "errors" + "fmt" "io" + "mime" "net/http" "os" "path" "strconv" "strings" + "time" "github.com/go-chi/render" "github.com/klauspost/compress/zip" @@ -16,8 +19,15 @@ import ( "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" ) +type pwdChange struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message string, code int) { var errorString string if err != nil { @@ -47,6 +57,19 @@ func getRespStatus(err error) int { return http.StatusInternalServerError } +func getMappedStatusCode(err error) int { + var statusCode int + switch err { + case os.ErrPermission: + statusCode = http.StatusForbidden + case os.ErrNotExist: + statusCode = http.StatusNotFound + default: + statusCode = http.StatusInternalServerError + } + return statusCode +} + func handleCloseConnection(w http.ResponseWriter, r *http.Request) { connectionID := getURLParam(r, "connectionID") if connectionID == "" { @@ -166,3 +189,212 @@ func getZipEntryName(entryPath, baseDir string) string { entryPath = strings.TrimPrefix(entryPath, baseDir) return strings.TrimPrefix(entryPath, "/") } + +func downloadFile(w http.ResponseWriter, r *http.Request, connection *Connection, name string, info os.FileInfo) (int, error) { + 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, ",") { + return http.StatusRequestedRangeNotSatisfiable, fmt.Errorf("unsupported range %#v", rangeHeader) + } + offset, size, err = parseRangeRequest(rangeHeader[6:], size) + if err != nil { + return http.StatusRequestedRangeNotSatisfiable, err + } + responseStatus = http.StatusPartialContent + } + reader, err := connection.getFileReader(name, offset, r.Method) + if err != nil { + return getMappedStatusCode(err), fmt.Errorf("unable to read file %#v: %v", name, err) + } + defer reader.Close() + + w.Header().Set("Last-Modified", info.ModTime().UTC().Format(http.TimeFormat)) + if checkPreconditions(w, r, info.ModTime()) { + return 0, fmt.Errorf("%v", http.StatusText(http.StatusPreconditionFailed)) + } + 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 + } + return http.StatusOK, nil +} + +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 + // start cannit be < 0 here, we did end = size -1 above + } 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 + } + // 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 && err != common.ErrInternalFailure { + 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 checkHTTPClientUser(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) + } + return nil +} diff --git a/httpd/auth_utils.go b/httpd/auth_utils.go index 617aed75..a3f51912 100644 --- a/httpd/auth_utils.go +++ b/httpd/auth_utils.go @@ -21,6 +21,7 @@ const ( tokenAudienceWebAdmin tokenAudience = "WebAdmin" tokenAudienceWebClient tokenAudience = "WebClient" tokenAudienceAPI tokenAudience = "API" + tokenAudienceAPIUser tokenAudience = "APIUser" tokenAudienceCSRF tokenAudience = "CSRF" ) diff --git a/httpd/httpd.go b/httpd/httpd.go index feac5c5d..191038b2 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -32,6 +32,8 @@ const ( logSender = "httpd" tokenPath = "/api/v2/token" logoutPath = "/api/v2/logout" + userTokenPath = "/api/v2/user/token" + userLogoutPath = "/api/v2/user/logout" activeConnectionsPath = "/api/v2/connections" quotaScanPath = "/api/v2/quota-scans" quotaScanVFolderPath = "/api/v2/folder-quota-scans" @@ -47,7 +49,13 @@ const ( defenderUnban = "/api/v2/defender/unban" defenderScore = "/api/v2/defender/score" adminPath = "/api/v2/admins" - adminPwdPath = "/api/v2/changepwd/admin" + adminPwdPath = "/api/v2/admin/changepwd" + adminPwdCompatPath = "/api/v2/changepwd/admin" + userPwdPath = "/api/v2/user/changepwd" + userPublicKeysPath = "/api/v2/user/publickeys" + userReadFolderPath = "/api/v2/user/folder" + userGetFilePath = "/api/v2/user/file" + userStreamZipPath = "/api/v2/user/streamzip" healthzPath = "/healthz" webRootPathDefault = "/" webBasePathDefault = "/web" @@ -75,7 +83,7 @@ const ( webClientLoginPathDefault = "/web/client/login" webClientFilesPathDefault = "/web/client/files" webClientDirContentsPathDefault = "/web/client/listdir" - webClientDownloadPathDefault = "/web/client/download" + webClientDownloadZipPathDefault = "/web/client/downloadzip" webClientCredentialsPathDefault = "/web/client/credentials" webChangeClientPwdPathDefault = "/web/client/changepwd" webChangeClientKeysPathDefault = "/web/client/managekeys" @@ -121,7 +129,7 @@ var ( webClientLoginPath string webClientFilesPath string webClientDirContentsPath string - webClientDownloadPath string + webClientDownloadZipPath string webClientCredentialsPath string webChangeClientPwdPath string webChangeClientKeysPath string @@ -417,7 +425,7 @@ func updateWebClientURLs(baseURL string) { webClientLoginPath = path.Join(baseURL, webClientLoginPathDefault) webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault) webClientDirContentsPath = path.Join(baseURL, webClientDirContentsPathDefault) - webClientDownloadPath = path.Join(baseURL, webClientDownloadPathDefault) + webClientDownloadZipPath = path.Join(baseURL, webClientDownloadZipPathDefault) webClientCredentialsPath = path.Join(baseURL, webClientCredentialsPathDefault) webChangeClientPwdPath = path.Join(baseURL, webChangeClientPwdPathDefault) webChangeClientKeysPath = path.Join(baseURL, webChangeClientKeysPathDefault) diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index d49f35fe..50891804 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -56,9 +56,11 @@ const ( altAdminPassword = "password1" csrfFormToken = "_form_token" tokenPath = "/api/v2/token" + userTokenPath = "/api/v2/user/token" + userLogoutPath = "/api/v2/user/logout" userPath = "/api/v2/users" adminPath = "/api/v2/admins" - adminPwdPath = "/api/v2/changepwd/admin" + adminPwdPath = "/api/v2/admin/changepwd" folderPath = "/api/v2/folders" activeConnectionsPath = "/api/v2/connections" serverStatusPath = "/api/v2/status" @@ -69,6 +71,11 @@ const ( defenderUnban = "/api/v2/defender/unban" versionPath = "/api/v2/version" logoutPath = "/api/v2/logout" + userPwdPath = "/api/v2/user/changepwd" + userPublicKeysPath = "/api/v2/user/publickeys" + userReadFolderPath = "/api/v2/user/folder" + userGetFilePath = "/api/v2/user/file" + userStreamZipPath = "/api/v2/user/streamzip" healthzPath = "/healthz" webBasePath = "/web" webBasePathAdmin = "/web/admin" @@ -92,7 +99,7 @@ const ( webClientLoginPath = "/web/client/login" webClientFilesPath = "/web/client/files" webClientDirContentsPath = "/web/client/listdir" - webClientDownloadPath = "/web/client/download" + webClientDownloadZipPath = "/web/client/downloadzip" webClientCredentialsPath = "/web/client/credentials" webChangeClientPwdPath = "/web/client/changepwd" webChangeClientKeysPath = "/web/client/managekeys" @@ -372,6 +379,182 @@ func TestBasicUserHandling(t *testing.T) { assert.NoError(t, err) } +func TestHTTPUserAuthentication(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + resp, err := httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + responseHolder := make(map[string]interface{}) + err = render.DecodeJSON(resp.Body, &responseHolder) + assert.NoError(t, err) + userToken := responseHolder["access_token"].(string) + assert.NotEmpty(t, userToken) + err = resp.Body.Close() + assert.NoError(t, err) + // login with wrong credentials + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, "") + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil) + assert.NoError(t, err) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, "wrong pwd") + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + respBody, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Contains(t, string(respBody), "invalid credentials") + err = resp.Body.Close() + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil) + assert.NoError(t, err) + req.SetBasicAuth("wrong username", defaultPassword) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + respBody, err = io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Contains(t, string(respBody), "invalid credentials") + err = resp.Body.Close() + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, tokenPath), nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultTokenAuthUser, defaultTokenAuthPass) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + responseHolder = make(map[string]interface{}) + err = render.DecodeJSON(resp.Body, &responseHolder) + assert.NoError(t, err) + adminToken := responseHolder["access_token"].(string) + assert.NotEmpty(t, adminToken) + err = resp.Body.Close() + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, versionPath), nil) + assert.NoError(t, err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", adminToken)) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + // using the user token should not work + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, versionPath), nil) + assert.NoError(t, err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", userToken)) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userPublicKeysPath), nil) + assert.NoError(t, err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", userToken)) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + // using the admin token should not work + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userPublicKeysPath), nil) + assert.NoError(t, err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", adminToken)) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userLogoutPath), nil) + assert.NoError(t, err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", adminToken)) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userLogoutPath), nil) + assert.NoError(t, err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", userToken)) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userPublicKeysPath), nil) + assert.NoError(t, err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", userToken)) + resp, err = httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + err = resp.Body.Close() + assert.NoError(t, err) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestHTTPStreamZipError(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, userTokenPath), nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + resp, err := httpclient.GetHTTPClient().Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + responseHolder := make(map[string]interface{}) + err = render.DecodeJSON(resp.Body, &responseHolder) + assert.NoError(t, err) + userToken := responseHolder["access_token"].(string) + assert.NotEmpty(t, userToken) + err = resp.Body.Close() + assert.NoError(t, err) + + filesList := []string{"missing"} + asJSON, err := json.Marshal(filesList) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, fmt.Sprintf("%v%v", httpBaseURL, userStreamZipPath), bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", userToken)) + resp, err = httpclient.GetHTTPClient().Do(req) + if !assert.Error(t, err) { // the connection will be closed + err = resp.Body.Close() + assert.NoError(t, err) + } + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestBasicAdminHandling(t *testing.T) { // we have one admin by default admins, _, err := httpdtest.GetAdmins(0, 0, http.StatusOK) @@ -1044,16 +1227,6 @@ func TestAddUserInvalidVirtualFolders(t *testing.T) { // folder name is mandatory _, _, err = httpdtest.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) - u.VirtualFolders = nil - u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ - BaseVirtualFolder: vfs.BaseVirtualFolder{ - Name: "aa=a", // char not allowed - MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), - }, - VirtualPath: "/vdir1", - }) - _, _, err = httpdtest.AddUser(u, http.StatusBadRequest) - assert.NoError(t, err) } func TestUserPublicKey(t *testing.T) { @@ -3496,6 +3669,50 @@ func TestChangeAdminPwdInvalidJsonMock(t *testing.T) { checkResponseCode(t, http.StatusBadRequest, rr) } +func TestWebAPIChangeUserPwdMock(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + // invalid json + req, err := http.NewRequest(http.MethodPut, userPwdPath, bytes.NewBuffer([]byte("{"))) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + pwd := make(map[string]string) + pwd["current_password"] = defaultPassword + pwd["new_password"] = defaultPassword + asJSON, err := json.Marshal(pwd) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPut, userPwdPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "the new password must be different from the current one") + + pwd["new_password"] = altAdminPassword + asJSON, err = json.Marshal(pwd) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPut, userPwdPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + _, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.Error(t, err) + token, err = getJWTAPIUserTokenFromTestServer(defaultUsername, altAdminPassword) + assert.NoError(t, err) + assert.NotEmpty(t, token) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestLoginInvalidPasswordMock(t *testing.T) { _, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass+"1") assert.Error(t, err) @@ -4508,6 +4725,50 @@ func TestTokenAudience(t *testing.T) { assert.Equal(t, webLoginPath, rr.Header().Get("Location")) } +func TestWebAPILoginMock(t *testing.T) { + _, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.Error(t, err) + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + _, err = getJWTAPIUserTokenFromTestServer(defaultUsername+"1", defaultPassword) + assert.Error(t, err) + _, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword+"1") + assert.Error(t, err) + apiToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + // a web token is not valid for API usage + req, err := http.NewRequest(http.MethodGet, userReadFolderPath, nil) + assert.NoError(t, err) + setBearerForReq(req, webToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusUnauthorized, rr) + assert.Contains(t, rr.Body.String(), "Your token audience is not valid") + + req, err = http.NewRequest(http.MethodGet, userReadFolderPath+"/?path=%2F", nil) + assert.NoError(t, err) + setBearerForReq(req, apiToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + // API token is not valid for web usage + req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil) + setJWTCookieForReq(req, apiToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Equal(t, webClientLoginPath, rr.Header().Get("Location")) + + req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestWebClientLoginMock(t *testing.T) { _, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) assert.Error(t, err) @@ -4556,6 +4817,8 @@ func TestWebClientLoginMock(t *testing.T) { // get a new token and use it after removing the user webToken, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) assert.NoError(t, err) + apiUserToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) err = os.RemoveAll(user.GetHomeDir()) @@ -4568,19 +4831,49 @@ func TestWebClientLoginMock(t *testing.T) { req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) setJWTCookieForReq(req, webToken) rr = executeRequest(req) - checkResponseCode(t, http.StatusInternalServerError, rr) - assert.Contains(t, rr.Body.String(), "unable to retrieve your user") + checkResponseCode(t, http.StatusNotFound, rr) + assert.Contains(t, rr.Body.String(), "Unable to retrieve your user") req, _ = http.NewRequest(http.MethodGet, webClientDirContentsPath, nil) setJWTCookieForReq(req, webToken) rr = executeRequest(req) - checkResponseCode(t, http.StatusInternalServerError, rr) - assert.Contains(t, rr.Body.String(), "unable to retrieve your user") + checkResponseCode(t, http.StatusNotFound, rr) + assert.Contains(t, rr.Body.String(), "Unable to retrieve your user") - req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath, nil) + req, _ = http.NewRequest(http.MethodGet, webClientDownloadZipPath, nil) setJWTCookieForReq(req, webToken) rr = executeRequest(req) - checkResponseCode(t, http.StatusInternalServerError, rr) + checkResponseCode(t, http.StatusNotFound, rr) + assert.Contains(t, rr.Body.String(), "Unable to retrieve your user") + + req, _ = http.NewRequest(http.MethodGet, userReadFolderPath, nil) + setBearerForReq(req, apiUserToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + assert.Contains(t, rr.Body.String(), "Unable to retrieve your user") + + req, _ = http.NewRequest(http.MethodGet, userGetFilePath, nil) + setBearerForReq(req, apiUserToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + assert.Contains(t, rr.Body.String(), "Unable to retrieve your user") + + req, _ = http.NewRequest(http.MethodPost, userStreamZipPath, bytes.NewBuffer([]byte(`{}`))) + setBearerForReq(req, apiUserToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + assert.Contains(t, rr.Body.String(), "Unable to retrieve your user") + + req, _ = http.NewRequest(http.MethodGet, userPublicKeysPath, nil) + setBearerForReq(req, apiUserToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + assert.Contains(t, rr.Body.String(), "Unable to retrieve your user") + + req, _ = http.NewRequest(http.MethodPut, userPublicKeysPath, bytes.NewBuffer([]byte(`{}`))) + setBearerForReq(req, apiUserToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) assert.Contains(t, rr.Body.String(), "Unable to retrieve your user") csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath) @@ -4671,6 +4964,7 @@ func TestDefender(t *testing.T) { assert.NoError(t, err) webToken, err := getJWTWebClientTokenFromTestServerWithAddr(defaultUsername, defaultPassword, remoteAddr) assert.NoError(t, err) + req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil) req.RemoteAddr = remoteAddr req.RequestURI = webClientFilesPath @@ -4733,12 +5027,18 @@ func TestPostConnectHook(t *testing.T) { _, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) assert.NoError(t, err) + _, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + err = os.WriteFile(postConnectPath, getExitCodeScriptContent(1), os.ModePerm) assert.NoError(t, err) _, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) assert.Error(t, err) + _, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.Error(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) err = os.RemoveAll(user.GetHomeDir()) @@ -4754,6 +5054,8 @@ func TestMaxSessions(t *testing.T) { assert.NoError(t, err) _, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) assert.NoError(t, err) + _, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) // now add a fake connection fs := vfs.NewOsFs("id", os.TempDir(), "") connection := &httpd.Connection{ @@ -4762,6 +5064,8 @@ func TestMaxSessions(t *testing.T) { common.Connections.Add(connection) _, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) assert.Error(t, err) + _, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.Error(t, err) common.Connections.Remove(connection.GetID()) _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) @@ -4790,6 +5094,9 @@ func TestLoginInvalidFs(t *testing.T) { _, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) assert.Error(t, err) + _, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.Error(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) err = os.RemoveAll(user.GetHomeDir()) @@ -4857,6 +5164,75 @@ func TestWebClientChangePwd(t *testing.T) { assert.NoError(t, err) } +func TestWebAPIPublicKeys(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + apiToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodGet, userPublicKeysPath, nil) + assert.NoError(t, err) + setBearerForReq(req, apiToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var keys []string + err = json.Unmarshal(rr.Body.Bytes(), &keys) + assert.NoError(t, err) + assert.Len(t, keys, 0) + + keys = []string{testPubKey, testPubKey1} + asJSON, err := json.Marshal(keys) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPut, userPublicKeysPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, apiToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, userPublicKeysPath, nil) + assert.NoError(t, err) + setBearerForReq(req, apiToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + keys = nil + err = json.Unmarshal(rr.Body.Bytes(), &keys) + assert.NoError(t, err) + assert.Len(t, keys, 2) + + req, err = http.NewRequest(http.MethodPut, userPublicKeysPath, bytes.NewBuffer([]byte(`invalid json`))) + assert.NoError(t, err) + setBearerForReq(req, apiToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + keys = []string{`not a public key`} + asJSON, err = json.Marshal(keys) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPut, userPublicKeysPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, apiToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "could not parse key") + + user.Filters.WebClient = append(user.Filters.WebClient, dataprovider.WebClientPubKeyChangeDisabled) + _, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + + apiToken, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodGet, userPublicKeysPath, nil) + assert.NoError(t, err) + setBearerForReq(req, apiToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestWebClientChangePubKeys(t *testing.T) { user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) @@ -4903,6 +5279,7 @@ func TestWebClientChangePubKeys(t *testing.T) { form.Set(csrfFormToken, csrfToken) form.Set("public_keys", testPubKey) req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode()))) + req.RequestURI = webChangeClientKeysPath req.Header.Set("Content-Type", "application/x-www-form-urlencoded") setJWTCookieForReq(req, webToken) rr = executeRequest(req) @@ -4939,6 +5316,8 @@ func TestPreDownloadHook(t *testing.T) { webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) assert.NoError(t, err) + webAPIToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) req, err := http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil) assert.NoError(t, err) setJWTCookieForReq(req, webToken) @@ -4946,6 +5325,13 @@ func TestPreDownloadHook(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) assert.Equal(t, testFileContents, rr.Body.Bytes()) + req, err = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testFileName, nil) + assert.NoError(t, err) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Equal(t, testFileContents, rr.Body.Bytes()) + err = os.WriteFile(preDownloadPath, getExitCodeScriptContent(1), os.ModePerm) assert.NoError(t, err) req, err = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil) @@ -4955,6 +5341,13 @@ func TestPreDownloadHook(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) assert.Contains(t, rr.Body.String(), "permission denied") + req, err = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testFileName, nil) + assert.NoError(t, err) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "permission denied") + _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) err = os.RemoveAll(user.GetHomeDir()) @@ -4981,6 +5374,8 @@ func TestWebGetFiles(t *testing.T) { assert.NoError(t, err) webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) assert.NoError(t, err) + webAPIToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) req, _ := http.NewRequest(http.MethodGet, webClientFilesPath, nil) setJWTCookieForReq(req, webToken) rr := executeRequest(req) @@ -5000,19 +5395,42 @@ func TestWebGetFiles(t *testing.T) { assert.NoError(t, err) assert.Len(t, dirContents, 1) - req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath+"?path="+url.QueryEscape("/")+"&files="+ + req, _ = http.NewRequest(http.MethodGet, userReadFolderPath+"?path="+testDir, nil) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var dirEntries []map[string]interface{} + err = json.Unmarshal(rr.Body.Bytes(), &dirEntries) + assert.NoError(t, err) + assert.Len(t, dirEntries, 1) + + req, _ = http.NewRequest(http.MethodGet, webClientDownloadZipPath+"?path="+url.QueryEscape("/")+"&files="+ url.QueryEscape(fmt.Sprintf(`["%v","%v","%v"]`, testFileName, testDir, testFileName+extensions[2])), nil) setJWTCookieForReq(req, webToken) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) - req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath+"?path="+url.QueryEscape("/")+"&files="+ + filesList := []string{testFileName, testDir, testFileName + extensions[2]} + asJSON, err := json.Marshal(filesList) + assert.NoError(t, err) + req, _ = http.NewRequest(http.MethodPost, userStreamZipPath, bytes.NewBuffer(asJSON)) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + assert.NoError(t, err) + req, _ = http.NewRequest(http.MethodPost, userStreamZipPath, bytes.NewBuffer([]byte(`file`))) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + req, _ = http.NewRequest(http.MethodGet, webClientDownloadZipPath+"?path="+url.QueryEscape("/")+"&files="+ url.QueryEscape(fmt.Sprintf(`["%v"]`, testDir)), nil) setJWTCookieForReq(req, webToken) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr) - req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath+"?path="+url.QueryEscape("/")+"&files=notalist", nil) + req, _ = http.NewRequest(http.MethodGet, webClientDownloadZipPath+"?path="+url.QueryEscape("/")+"&files=notalist", nil) setJWTCookieForReq(req, webToken) rr = executeRequest(req) checkResponseCode(t, http.StatusInternalServerError, rr) @@ -5027,10 +5445,26 @@ func TestWebGetFiles(t *testing.T) { assert.NoError(t, err) assert.Len(t, dirContents, len(extensions)+1) + req, _ = http.NewRequest(http.MethodGet, userReadFolderPath+"?path=/", nil) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + dirEntries = nil + err = json.Unmarshal(rr.Body.Bytes(), &dirEntries) + assert.NoError(t, err) + assert.Len(t, dirEntries, len(extensions)+1) + req, _ = http.NewRequest(http.MethodGet, webClientDirContentsPath+"?path=/missing", nil) setJWTCookieForReq(req, webToken) rr = executeRequest(req) - checkResponseCode(t, http.StatusInternalServerError, rr) + checkResponseCode(t, http.StatusNotFound, rr) + assert.Contains(t, rr.Body.String(), "Unable to get directory contents") + + req, _ = http.NewRequest(http.MethodGet, userReadFolderPath+"?path=missing", nil) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + assert.Contains(t, rr.Body.String(), "Unable to get directory contents") req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil) setJWTCookieForReq(req, webToken) @@ -5038,6 +5472,30 @@ func TestWebGetFiles(t *testing.T) { checkResponseCode(t, http.StatusOK, rr) assert.Equal(t, testFileContents, rr.Body.Bytes()) + req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testFileName, nil) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Equal(t, testFileContents, rr.Body.Bytes()) + + req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path=", nil) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "Please set the path to a valid file") + + req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testDir, nil) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "is a directory") + + req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path=notafile", nil) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + assert.Contains(t, rr.Body.String(), "Unable to stat the requested file") + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil) req.Header.Set("Range", "bytes=2-") setJWTCookieForReq(req, webToken) @@ -5045,6 +5503,13 @@ func TestWebGetFiles(t *testing.T) { checkResponseCode(t, http.StatusPartialContent, rr) assert.Equal(t, testFileContents[2:], rr.Body.Bytes()) + req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testFileName, nil) + req.Header.Set("Range", "bytes=2-") + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusPartialContent, rr) + assert.Equal(t, testFileContents[2:], rr.Body.Bytes()) + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath+"?path="+testFileName, nil) req.Header.Set("Range", "bytes=-2") setJWTCookieForReq(req, webToken) @@ -5064,6 +5529,12 @@ func TestWebGetFiles(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusRequestedRangeNotSatisfiable, rr) + req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testFileName, nil) + req.Header.Set("Range", "bytes=2b-") + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusRequestedRangeNotSatisfiable, rr) + req, _ = http.NewRequest(http.MethodHead, webClientFilesPath+"?path="+testFileName, nil) req.Header.Set("Range", "bytes=2-") req.Header.Set("If-Range", time.Now().UTC().Add(120*time.Second).Format(http.TimeFormat)) @@ -5096,6 +5567,12 @@ func TestWebGetFiles(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusPreconditionFailed, rr) + req, _ = http.NewRequest(http.MethodHead, userGetFilePath+"?path="+testFileName, nil) + req.Header.Set("If-Unmodified-Since", time.Now().UTC().Add(-120*time.Second).Format(http.TimeFormat)) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusPreconditionFailed, rr) + req, _ = http.NewRequest(http.MethodHead, webClientFilesPath+"?path="+testFileName, nil) req.Header.Set("If-Unmodified-Since", time.Now().UTC().Add(120*time.Second).Format(http.TimeFormat)) setJWTCookieForReq(req, webToken) @@ -5111,6 +5588,29 @@ func TestWebGetFiles(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusForbidden, rr) + req, _ = http.NewRequest(http.MethodGet, webClientDirContentsPath+"?path=/", nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + req, _ = http.NewRequest(http.MethodGet, userGetFilePath+"?path="+testFileName, nil) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + req, _ = http.NewRequest(http.MethodGet, userReadFolderPath+"?path="+testDir, nil) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + filesList = []string{testDir} + asJSON, err = json.Marshal(filesList) + assert.NoError(t, err) + req, _ = http.NewRequest(http.MethodPost, userStreamZipPath, bytes.NewBuffer(asJSON)) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + user.Filters.DeniedProtocols = []string{common.ProtocolFTP} user.Filters.DeniedLoginMethods = []string{dataprovider.LoginMethodPassword} _, resp, err = httpdtest.UpdateUser(user, http.StatusOK, "") @@ -5121,6 +5621,16 @@ func TestWebGetFiles(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusForbidden, rr) + req, _ = http.NewRequest(http.MethodGet, webClientDownloadZipPath, nil) + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + + req, _ = http.NewRequest(http.MethodGet, userReadFolderPath+"?path="+testDir, nil) + setBearerForReq(req, webAPIToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) err = os.RemoveAll(user.GetHomeDir()) @@ -5143,7 +5653,7 @@ func TestCompressionErrorMock(t *testing.T) { webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) assert.NoError(t, err) - req, _ := http.NewRequest(http.MethodGet, webClientDownloadPath+"?path="+url.QueryEscape("/")+"&files="+ + req, _ := http.NewRequest(http.MethodGet, webClientDownloadZipPath+"?path="+url.QueryEscape("/")+"&files="+ url.QueryEscape(`["missing"]`), nil) setJWTCookieForReq(req, webToken) executeRequest(req) @@ -8085,11 +8595,26 @@ func setJWTCookieForReq(req *http.Request, jwtToken string) { } func getJWTAPITokenFromTestServer(username, password string) (string, error) { - req, _ := http.NewRequest(http.MethodGet, "/api/v2/token", nil) + req, _ := http.NewRequest(http.MethodGet, tokenPath, nil) req.SetBasicAuth(username, password) rr := executeRequest(req) if rr.Code != http.StatusOK { - return "", fmt.Errorf("unexpected status code %v", rr) + return "", fmt.Errorf("unexpected status code %v", rr.Code) + } + responseHolder := make(map[string]interface{}) + err := render.DecodeJSON(rr.Body, &responseHolder) + if err != nil { + return "", err + } + return responseHolder["access_token"].(string), nil +} + +func getJWTAPIUserTokenFromTestServer(username, password string) (string, error) { + req, _ := http.NewRequest(http.MethodGet, userTokenPath, nil) + req.SetBasicAuth(username, password) + rr := executeRequest(req) + if rr.Code != http.StatusOK { + return "", fmt.Errorf("unexpected status code %v", rr.Code) } responseHolder := make(map[string]interface{}) err := render.DecodeJSON(rr.Body, &responseHolder) diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 4b047317..1362f43a 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -306,6 +306,18 @@ func TestGetRespStatus(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, respStatus) } +func TestMappedStatusCode(t *testing.T) { + err := os.ErrPermission + code := getMappedStatusCode(err) + assert.Equal(t, http.StatusForbidden, code) + err = os.ErrNotExist + code = getMappedStatusCode(err) + assert.Equal(t, http.StatusNotFound, code) + err = os.ErrClosed + code = getMappedStatusCode(err) + assert.Equal(t, http.StatusInternalServerError, code) +} + func TestGCSWebInvalidFormFile(t *testing.T) { form := make(url.Values) form.Set("username", "test_username") @@ -337,7 +349,7 @@ func TestInvalidToken(t *testing.T) { deleteAdmin(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code) - adminPwd := adminPwd{ + adminPwd := pwdChange{ CurrentPassword: "old", NewPassword: "new", } @@ -351,6 +363,31 @@ func TestInvalidToken(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, rr.Code) adm := getAdminFromToken(req) assert.Empty(t, adm.Username) + + rr = httptest.NewRecorder() + readUserFolder(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") + + rr = httptest.NewRecorder() + getUserFile(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") + + rr = httptest.NewRecorder() + getUserFilesAsZipStream(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") + + rr = httptest.NewRecorder() + getUserPublicKeys(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") + + rr = httptest.NewRecorder() + setUserPublicKeys(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "Invalid token claims") } func TestUpdateWebAdminInvalidClaims(t *testing.T) { @@ -441,6 +478,16 @@ func TestCreateTokenError(t *testing.T) { server.generateAndSendToken(rr, req, admin) assert.Equal(t, http.StatusInternalServerError, rr.Code) + rr = httptest.NewRecorder() + user := dataprovider.User{ + Username: "u", + Password: "pwd", + } + req, _ = http.NewRequest(http.MethodGet, userTokenPath, nil) + + server.generateAndSendUserToken(rr, req, "", user) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + rr = httptest.NewRecorder() form := make(url.Values) form.Set("username", admin.Username) @@ -492,7 +539,7 @@ func TestCreateTokenError(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String()) username := "webclientuser" - user := dataprovider.User{ + user = dataprovider.User{ Username: username, Password: "clientpwd", HomeDir: filepath.Join(os.TempDir(), username), @@ -569,7 +616,7 @@ func TestJWTTokenValidation(t *testing.T) { fn.ServeHTTP(rr, req.WithContext(ctx)) assert.Equal(t, http.StatusBadRequest, rr.Code) - permClientFn := checkClientPerm(dataprovider.WebClientPubKeyChangeDisabled) + permClientFn := checkHTTPUserPerm(dataprovider.WebClientPubKeyChangeDisabled) fn = permClientFn(r) rr = httptest.NewRecorder() req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, nil) @@ -577,6 +624,13 @@ 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) + + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPost, userPublicKeysPath, nil) + req.RequestURI = userPublicKeysPath + ctx = jwtauth.NewContext(req.Context(), token, errTest) + fn.ServeHTTP(rr, req.WithContext(ctx)) + assert.Equal(t, http.StatusBadRequest, rr.Code) } func TestUpdateContextFromCookie(t *testing.T) { @@ -1385,25 +1439,6 @@ func TestConnection(t *testing.T) { 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", @@ -1490,9 +1525,9 @@ func TestGetFilesInvalidClaims(t *testing.T) { assert.Contains(t, rr.Body.String(), "invalid token claims") rr = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, webClientDownloadPath, nil) + req, _ = http.NewRequest(http.MethodGet, webClientDownloadZipPath, nil) req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"])) - handleWebClientDownload(rr, req) + handleWebClientDownloadZip(rr, req) assert.Equal(t, http.StatusForbidden, rr.Code) assert.Contains(t, rr.Body.String(), "Invalid token claims") } diff --git a/httpd/middleware.go b/httpd/middleware.go index 6cb11516..4489830f 100644 --- a/httpd/middleware.go +++ b/httpd/middleware.go @@ -36,9 +36,11 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi redirectPath = webClientLoginPath } + isAPIToken := (audience == tokenAudienceAPI || audience == tokenAudienceAPIUser) + if err != nil || token == nil { logger.Debug(logSender, "", "error getting jwt token: %v", err) - if audience == tokenAudienceAPI { + if isAPIToken { sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) } else { http.Redirect(w, r, redirectPath, http.StatusFound) @@ -49,7 +51,7 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi err = jwt.Validate(token) if err != nil { logger.Debug(logSender, "", "error validating jwt token: %v", err) - if audience == tokenAudienceAPI { + if isAPIToken { sendAPIResponse(w, r, err, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) } else { http.Redirect(w, r, redirectPath, http.StatusFound) @@ -58,7 +60,7 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi } if !utils.IsStringInSlice(audience, token.Audience()) { logger.Debug(logSender, "", "the token is not valid for audience %#v", audience) - if audience == tokenAudienceAPI { + if isAPIToken { sendAPIResponse(w, r, nil, "Your token audience is not valid", http.StatusUnauthorized) } else { http.Redirect(w, r, redirectPath, http.StatusFound) @@ -67,7 +69,7 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi } if isTokenInvalidated(r) { logger.Debug(logSender, "", "the token has been invalidated") - if audience == tokenAudienceAPI { + if isAPIToken { sendAPIResponse(w, r, nil, "Your token is no longer valid", http.StatusUnauthorized) } else { http.Redirect(w, r, redirectPath, http.StatusFound) @@ -88,6 +90,17 @@ func jwtAuthenticatorAPI(next http.Handler) http.Handler { }) } +func jwtAuthenticatorAPIUser(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := validateJWTToken(w, r, tokenAudienceAPIUser); err != nil { + return + } + + // Token is authenticated, pass it through + next.ServeHTTP(w, r) + }) +} + func jwtAuthenticatorWebAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := validateJWTToken(w, r, tokenAudienceWebAdmin); err != nil { @@ -110,19 +123,28 @@ func jwtAuthenticatorWebClient(next http.Handler) http.Handler { }) } -func checkClientPerm(perm string) func(next http.Handler) http.Handler { +//nolint:unparam +func checkHTTPUserPerm(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) + if isWebRequest(r) { + renderClientBadRequestPage(w, r, err) + } else { + sendAPIResponse(w, r, err, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + } 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") + if isWebRequest(r) { + renderClientForbiddenPage(w, r, "You don't have permission for this action") + } else { + sendAPIResponse(w, r, nil, http.StatusText(http.StatusForbidden), http.StatusForbidden) + } return } diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 1bacbb20..bc9de9fe 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -9,6 +9,7 @@ tags: - name: quota - name: folders - name: users + - name: users API info: title: SFTPGo description: | @@ -16,7 +17,7 @@ info: Several storage backends are supported and they are configurable per user, so you can serve a local directory for a user and an S3 bucket (or part of it) for another one. SFTPGo also supports virtual folders, a virtual folder can use any of the supported storage backends. So you can have, for example, an S3 user that exposes a GCS bucket (or part of it) on a specified path and an encrypted local filesystem on another one. Virtual folders can be private or shared among multiple users, for shared virtual folders you can define different quota limits for each user. - version: 2.0.5 + version: 2.0.9 contact: name: API support url: 'https://github.com/drakkan/sftpgo' @@ -52,7 +53,7 @@ paths: - BasicAuth: [] tags: - token - summary: Get a new access token + summary: Get a new admin access token description: Returns an access token and its expiration operationId: get_token responses: @@ -74,8 +75,8 @@ paths: get: tags: - token - summary: Invalidate - description: Allows to invalidate a token before its expiration + summary: Invalidate an admin access token + description: Allows to invalidate an admin token before its expiration operationId: logout responses: '200': @@ -92,6 +93,52 @@ paths: $ref: '#/components/responses/InternalServerError' default: $ref: '#/components/responses/DefaultResponse' + /user/token: + get: + security: + - BasicAuth: [] + tags: + - token + summary: Get a new user access token + description: Returns an access token and its expiration + operationId: get_user_token + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Token' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' + /user/logout: + get: + tags: + - token + summary: Invalidate a user access token + description: Allows to invalidate a client token before its expiration + operationId: client_logout + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' /version: get: tags: @@ -115,6 +162,35 @@ paths: default: $ref: '#/components/responses/DefaultResponse' /changepwd/admin: + put: + tags: + - admins + summary: Change admin password + description: Changes the password for the logged in admin. Please use /admin/changepwd instead + operationId: change_admin_pwd + deprecated: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PwdChange' + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' + /admin/changepwd: put: tags: - admins @@ -1242,6 +1318,198 @@ paths: $ref: '#/components/responses/InternalServerError' default: $ref: '#/components/responses/DefaultResponse' + /user/changepwd: + put: + tags: + - users API + summary: Change user password + description: Changes the password for the logged in user + operationId: change_user_password + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PwdChange' + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' + /user/publickeys: + get: + tags: + - users API + summary: Get the user's public keys + description: Returns the public keys for the logged in user + operationId: get_user_public_keys + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: array + items: + type: string + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' + put: + tags: + - users API + summary: Set the user's public keys + description: Sets the public keys for the logged in user. Public keys must be in OpenSSH format + operationId: set_user_public_keys + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: string + description: Public key in OpenSSH format + example: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPVILdH2u3yV5SAeE6XksD1z1vXRg0E4hJUov8ITDAZ2 user@host + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' + /user/folder: + get: + tags: + - users API + summary: Read folders contents + description: Returns the contents of the specified folder for the logged in user + operationId: get_user_folder_contents + parameters: + - in: query + name: path + description: Path to the folder to read. It must be URL encoded, for example the path "my dir/àdir" must be sent as "my%20dir%2F%C3%A0dir". If empty or missing the root folder is assumed + schema: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DirEntry' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' + /user/file: + get: + tags: + - users API + summary: Download a single file + description: Returns the file contents as response body + operationId: get_user_file + parameters: + - in: query + name: path + required: true + description: Path to the file to download. It must be URL encoded, for example the path "my dir/àdir/file.txt" must be sent as "my%20dir%2F%C3%A0dir%2Ffile.txt" + schema: + type: string + responses: + '200': + description: successful operation + content: + '*/*': + schema: + type: string + format: binary + '206': + description: successful operation + content: + '*/*': + schema: + type: string + format: binary + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' + /user/streamzip: + post: + tags: + - users API + summary: Download multiple files and folders as a single zip file + description: A zip file, containing the specified files and folders, will be generated on the fly and returned as response body. Only folders and regular files will be included in the zip + operationId: streamzip + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: string + description: Absolute file or folder path + responses: + '200': + description: successful operation + content: + 'application/zip': + schema: + type: string + format: binary + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + default: + $ref: '#/components/responses/DefaultResponse' components: responses: BadRequest: @@ -1751,7 +2019,8 @@ components: type: array items: type: string - description: a password or at least one public key/SSH user certificate are mandatory. + example: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEUWwDwEWhTbF0MqAsp/oXK1HR2cElhM8oo1uVmL3ZeDKDiTm4ljMr92wfTgIGDqIoxmVqgYIkAOAhuykAVWBzc= user@host + description: Public keys in OpenSSH format. A password or at least one public key/SSH user certificate are mandatory. home_dir: type: string description: path to the user home directory. The user cannot upload or download files outside this directory. SFTPGo tries to automatically create this folder if missing. Must be an absolute path @@ -2124,6 +2393,27 @@ components: type: string new_password: type: string + DirEntry: + type: object + properties: + name: + type: string + description: name of the file (or subdirectory) described by the entry. This name is the final element of the path (the base name), not the entire path + size: + type: integer + format: int64 + description: file size, omitted for folders and non regular files + mode: + type: integer + description: | + File mode and permission bits. More details here: https://golang.org/pkg/io/fs/#FileMode. + Let's see some examples: + - for a directory mode&2147483648 != 0 + - for a symlink mode&134217728 != 0 + - for a regular file mode&2401763328 == 0 + last_modified: + type: string + format: date-time ApiResponse: type: object properties: diff --git a/httpd/server.go b/httpd/server.go index c37abf05..0e347641 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -120,18 +120,20 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re renderClientLoginPage(w, err.Error()) return } + ipAddr := utils.GetIPFromRemoteAddress(r.RemoteAddr) username := r.Form.Get("username") password := r.Form.Get("password") if username == "" || password == "" { + updateLoginMetrics(&dataprovider.User{Username: username}, ipAddr, common.ErrNoCredentials) renderClientLoginPage(w, "Invalid credentials") return } if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil { + updateLoginMetrics(&dataprovider.User{Username: username}, ipAddr, err) renderClientLoginPage(w, err.Error()) return } - ipAddr := utils.GetIPFromRemoteAddress(r.RemoteAddr) if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolHTTP); err != nil { renderClientLoginPage(w, fmt.Sprintf("access denied by post connect hook: %v", err)) return @@ -144,7 +146,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re return } connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String()) - if err := checkWebClientUser(&user, r, connectionID); err != nil { + if err := checkHTTPClientUser(&user, r, connectionID); err != nil { updateLoginMetrics(&user, ipAddr, err) renderClientLoginPage(w, err.Error()) return @@ -154,7 +156,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re err = user.CheckFsRoot(connectionID) if err != nil { logger.Warn(logSender, connectionID, "unable to check fs root: %v", err) - updateLoginMetrics(&user, ipAddr, err) + updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure) renderClientLoginPage(w, err.Error()) return } @@ -168,11 +170,12 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebClient) if err != nil { logger.Warn(logSender, connectionID, "unable to set client login cookie %v", err) - updateLoginMetrics(&user, ipAddr, err) + updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure) renderClientLoginPage(w, err.Error()) return } updateLoginMetrics(&user, ipAddr, err) + dataprovider.UpdateLastLogin(&user) //nolint:errcheck http.Redirect(w, r, webClientFilesPath, http.StatusFound) } @@ -266,6 +269,72 @@ func (s *httpdServer) logout(w http.ResponseWriter, r *http.Request) { sendAPIResponse(w, r, nil, "Your token has been invalidated", http.StatusOK) } +func (s *httpdServer) getUserToken(w http.ResponseWriter, r *http.Request) { + ipAddr := utils.GetIPFromRemoteAddress(r.RemoteAddr) + username, password, ok := r.BasicAuth() + if !ok { + updateLoginMetrics(&dataprovider.User{Username: username}, ipAddr, common.ErrNoCredentials) + w.Header().Set(common.HTTPAuthenticationHeader, basicRealm) + sendAPIResponse(w, r, nil, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + if username == "" || password == "" { + updateLoginMetrics(&dataprovider.User{Username: username}, ipAddr, common.ErrNoCredentials) + w.Header().Set(common.HTTPAuthenticationHeader, basicRealm) + sendAPIResponse(w, r, nil, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + if err := common.Config.ExecutePostConnectHook(ipAddr, common.ProtocolHTTP); err != nil { + sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, common.ProtocolHTTP) + if err != nil { + w.Header().Set(common.HTTPAuthenticationHeader, basicRealm) + updateLoginMetrics(&user, ipAddr, err) + sendAPIResponse(w, r, dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized), + http.StatusUnauthorized) + return + } + connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, xid.New().String()) + if err := checkHTTPClientUser(&user, r, connectionID); err != nil { + updateLoginMetrics(&user, ipAddr, err) + sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden) + 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, common.ErrInternalFailure) + sendAPIResponse(w, r, err, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + s.generateAndSendUserToken(w, r, ipAddr, user) +} + +func (s *httpdServer) generateAndSendUserToken(w http.ResponseWriter, r *http.Request, ipAddr string, user dataprovider.User) { + c := jwtTokenClaims{ + Username: user.Username, + Permissions: user.Filters.WebClient, + Signature: user.GetSignature(), + } + + resp, err := c.createTokenResponse(s.tokenAuth, tokenAudienceAPIUser) + + if err != nil { + updateLoginMetrics(&user, ipAddr, common.ErrInternalFailure) + sendAPIResponse(w, r, err, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + updateLoginMetrics(&user, ipAddr, err) + dataprovider.UpdateLastLogin(&user) //nolint:errcheck + + render.JSON(w, r, resp) +} + func (s *httpdServer) getToken(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() if !ok { @@ -329,7 +398,7 @@ func (s *httpdServer) refreshClientToken(w http.ResponseWriter, r *http.Request, 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 { + if err := checkHTTPClientUser(&user, r, xid.New().String()); err != nil { logger.Debug(logSender, "", "unable to refresh cookie for user %#v: %v", user.Username, err) return } @@ -496,6 +565,8 @@ func (s *httpdServer) initializeRouter() { router.Get(logoutPath, s.logout) router.Put(adminPwdPath, changeAdminPassword) + // compatibility layer to remove in v2.2 + router.Put(adminPwdCompatPath, changeAdminPassword) router.With(checkPerm(dataprovider.PermAdminViewServerStatus)). Get(serverStatusPath, func(w http.ResponseWriter, r *http.Request) { @@ -538,6 +609,21 @@ func (s *httpdServer) initializeRouter() { router.With(checkPerm(dataprovider.PermAdminManageAdmins)).Delete(adminPath+"/{username}", deleteAdmin) }) + s.router.Get(userTokenPath, s.getUserToken) + + s.router.Group(func(router chi.Router) { + router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromHeader)) + router.Use(jwtAuthenticatorAPIUser) + + router.Get(userLogoutPath, s.logout) + router.Put(userPwdPath, changeUserPassword) + router.With(checkHTTPUserPerm(dataprovider.WebClientPubKeyChangeDisabled)).Get(userPublicKeysPath, getUserPublicKeys) + router.With(checkHTTPUserPerm(dataprovider.WebClientPubKeyChangeDisabled)).Put(userPublicKeysPath, setUserPublicKeys) + router.Get(userReadFolderPath, readUserFolder) + router.Get(userGetFilePath, getUserFile) + router.Post(userStreamZipPath, getUserFilesAsZipStream) + }) + if s.enableWebAdmin || s.enableWebClient { s.router.Group(func(router chi.Router) { router.Use(compressor.Handler) @@ -574,10 +660,10 @@ func (s *httpdServer) initializeRouter() { router.Get(webClientLogoutPath, handleWebClientLogout) router.With(s.refreshCookie).Get(webClientFilesPath, handleClientGetFiles) router.With(compressor.Handler, s.refreshCookie).Get(webClientDirContentsPath, handleClientGetDirContents) - router.With(s.refreshCookie).Get(webClientDownloadPath, handleWebClientDownload) + router.With(s.refreshCookie).Get(webClientDownloadZipPath, handleWebClientDownloadZip) router.With(s.refreshCookie).Get(webClientCredentialsPath, handleClientGetCredentials) router.Post(webChangeClientPwdPath, handleWebClientChangePwdPost) - router.With(checkClientPerm(dataprovider.WebClientPubKeyChangeDisabled)). + router.With(checkHTTPUserPerm(dataprovider.WebClientPubKeyChangeDisabled)). Post(webChangeClientKeysPath, handleWebClientManageKeysPost) }) } diff --git a/httpd/webclient.go b/httpd/webclient.go index ae02439f..0b6693c4 100644 --- a/httpd/webclient.go +++ b/httpd/webclient.go @@ -2,18 +2,13 @@ package httpd import ( "encoding/json" - "errors" "fmt" "html/template" - "io" - "mime" "net/http" "net/url" "os" "path" "path/filepath" - "strconv" - "strings" "time" "github.com/go-chi/render" @@ -21,8 +16,6 @@ import ( "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" @@ -83,7 +76,6 @@ type filesPage struct { CurrentDir string ReadDirURL string DownloadURL string - Files []os.FileInfo Error string Paths []dirMapping } @@ -215,13 +207,12 @@ 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) { +func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string) { data := filesPage{ baseClientPage: getBaseClientPageData(pageClientFilesTitle, webClientFilesPath, r), - Files: files, Error: error, CurrentDir: url.QueryEscape(dirName), - DownloadURL: webClientDownloadPath, + DownloadURL: webClientDownloadZipPath, ReadDirURL: webClientDirContentsPath, } paths := []dirMapping{} @@ -272,7 +263,7 @@ func handleWebClientLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, webClientLoginPath, http.StatusFound) } -func handleWebClientDownload(w http.ResponseWriter, r *http.Request) { +func handleWebClientDownloadZip(w http.ResponseWriter, r *http.Request) { claims, err := getTokenClaims(r) if err != nil || claims.Username == "" { renderClientMessagePage(w, r, "Invalid token claims", "", http.StatusForbidden, nil, "") @@ -281,12 +272,18 @@ func handleWebClientDownload(w http.ResponseWriter, r *http.Request) { user, err := dataprovider.UserExists(claims.Username) if err != nil { - renderClientMessagePage(w, r, "Unable to retrieve your user", "", http.StatusInternalServerError, nil, "") + renderClientMessagePage(w, r, "Unable to retrieve your user", "", getRespStatus(err), nil, "") return } + connID := xid.New().String() + connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID) + if err := checkHTTPClientUser(&user, r, connectionID); err != nil { + renderClientForbiddenPage(w, r, err.Error()) + return + } connection := &Connection{ - BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, r.RemoteAddr, user), + BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, r.RemoteAddr, user), request: r, } common.Connections.Add(connection) @@ -318,12 +315,18 @@ func handleClientGetDirContents(w http.ResponseWriter, r *http.Request) { user, err := dataprovider.UserExists(claims.Username) if err != nil { - sendAPIResponse(w, r, nil, "unable to retrieve your user", http.StatusInternalServerError) + sendAPIResponse(w, r, nil, "Unable to retrieve your user", getRespStatus(err)) return } + connID := xid.New().String() + connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID) + if err := checkHTTPClientUser(&user, r, connectionID); err != nil { + sendAPIResponse(w, r, err, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } connection := &Connection{ - BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, r.RemoteAddr, user), + BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTP, r.RemoteAddr, user), request: r, } common.Connections.Add(connection) @@ -336,7 +339,7 @@ func handleClientGetDirContents(w http.ResponseWriter, r *http.Request) { contents, err := connection.ReadDir(name) if err != nil { - sendAPIResponse(w, r, nil, err.Error(), http.StatusInternalServerError) + sendAPIResponse(w, r, err, "Unable to get directory contents", getMappedStatusCode(err)) return } @@ -372,13 +375,13 @@ func handleClientGetFiles(w http.ResponseWriter, r *http.Request) { user, err := dataprovider.UserExists(claims.Username) if err != nil { - renderClientInternalServerErrorPage(w, r, errors.New("unable to retrieve your user")) + renderClientMessagePage(w, r, "Unable to retrieve your user", "", getRespStatus(err), nil, "") return } connID := xid.New().String() connectionID := fmt.Sprintf("%v_%v", common.ProtocolHTTP, connID) - if err := checkWebClientUser(&user, r, connectionID); err != nil { + if err := checkHTTPClientUser(&user, r, connectionID); err != nil { renderClientForbiddenPage(w, r, err.Error()) return } @@ -400,14 +403,22 @@ func handleClientGetFiles(w http.ResponseWriter, r *http.Request) { info, err = connection.Stat(name, 0) } if err != nil { - renderFilesPage(w, r, nil, name, fmt.Sprintf("unable to stat file %#v: %v", name, err)) + renderFilesPage(w, r, path.Dir(name), fmt.Sprintf("unable to stat file %#v: %v", name, err)) return } if info.IsDir() { - renderDirContents(w, r, connection, name) + renderFilesPage(w, r, name, "") return } - downloadFile(w, r, connection, name, info) + if status, err := downloadFile(w, r, connection, name, info); err != nil && status != 0 { + if status > 0 { + if status == http.StatusRequestedRangeNotSatisfiable { + renderClientMessagePage(w, r, http.StatusText(status), "", status, err, "") + return + } + renderFilesPage(w, r, path.Dir(name), err.Error()) + } + } } func handleClientGetCredentials(w http.ResponseWriter, r *http.Request) { @@ -463,247 +474,3 @@ func handleWebClientManageKeysPost(w http.ResponseWriter, r *http.Request) { } 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, r.Method) - 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 - // start cannit be < 0 here, we did end = size -1 above - } 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 - } - // 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) - } - return nil -} diff --git a/httpdtest/httpdtest.go b/httpdtest/httpdtest.go index 765c5cb9..9f6eb6ae 100644 --- a/httpdtest/httpdtest.go +++ b/httpdtest/httpdtest.go @@ -42,7 +42,7 @@ const ( defenderUnban = "/api/v2/defender/unban" defenderScore = "/api/v2/defender/score" adminPath = "/api/v2/admins" - adminPwdPath = "/api/v2/changepwd/admin" + adminPwdPath = "/api/v2/admin/changepwd" ) const ( diff --git a/templates/webclient/files.html b/templates/webclient/files.html index bb076cd3..9022bb77 100644 --- a/templates/webclient/files.html +++ b/templates/webclient/files.html @@ -172,7 +172,7 @@ $.fn.dataTable.ext.buttons.download = { text: '', name: 'download', - titleAttr: "Download", + titleAttr: "Download Zip", action: function (e, dt, node, config) { var filesArray = []; var selected = dt.column(0).checkboxes.selected(); diff --git a/vfs/sftpfs.go b/vfs/sftpfs.go index 27147d9e..813e2e61 100644 --- a/vfs/sftpfs.go +++ b/vfs/sftpfs.go @@ -269,6 +269,9 @@ func (fs *SFTPFs) Open(name string, offset int64) (File, *pipeat.PipeReaderAt, f return nil, nil, nil, err } go func() { + // if we enable buffering the client stalls + //br := bufio.NewReaderSize(f, int(fs.config.BufferSize)*1024*1024) + //n, err := fs.copy(w, br) n, err := io.Copy(w, f) w.CloseWithError(err) //nolint:errcheck f.Close() diff --git a/webdavd/server.go b/webdavd/server.go index cbb6df8f..163d06ef 100644 --- a/webdavd/server.go +++ b/webdavd/server.go @@ -26,10 +26,6 @@ import ( "github.com/drakkan/sftpgo/utils" ) -var ( - err401 = errors.New("Unauthorized") -) - type webDavServer struct { config *Configuration binding Binding @@ -171,6 +167,7 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { } user, isCached, lockSystem, loginMethod, err := s.authenticate(r, ipAddr) if err != nil { + updateLoginMetrics(&user, ipAddr, loginMethod, err) w.Header().Set("WWW-Authenticate", "Basic realm=\"SFTPGo WebDAV\"") http.Error(w, fmt.Sprintf("Authentication error: %v", err), http.StatusUnauthorized) return @@ -193,7 +190,7 @@ func (s *webDavServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { if err != nil { errClose := user.CloseFs() logger.Warn(logSender, connectionID, "unable to check fs root: %v close fs error: %v", err, errClose) - updateLoginMetrics(&user, ipAddr, loginMethod, err) + updateLoginMetrics(&user, ipAddr, loginMethod, common.ErrInternalFailure) http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -253,7 +250,8 @@ func (s *webDavServer) authenticate(r *http.Request, ip string) (dataprovider.Us var err error username, password, loginMethod, tlsCert, ok := s.getCredentialsAndLoginMethod(r) if !ok { - return user, false, nil, loginMethod, err401 + user.Username = username + return user, false, nil, loginMethod, common.ErrNoCredentials } cachedUser, ok := dataprovider.GetCachedWebDAVUser(username) if ok { @@ -369,7 +367,7 @@ func writeLog(r *http.Request, err error) { func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error) { metrics.AddLoginAttempt(loginMethod) - if err != nil { + if err != nil && err != common.ErrInternalFailure { logger.ConnectionFailedLog(user.Username, ip, loginMethod, common.ProtocolWebDAV, err.Error()) event := common.HostEventLoginFailed if _, ok := err.(*dataprovider.RecordNotFoundError); ok {