OpenAPI: add users API

These new APIs match the web client features.

I'm aware that some API do not follow REST best practises.

I want to avoid things likes "/user/folders/<path>"

where "path" must be encoded and making it optional create issues, so
I defined resources as query parameters instead of path parameters
This commit is contained in:
Nicola Murino 2021-06-05 16:07:09 +02:00
parent 976f588863
commit 43182fc25e
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
25 changed files with 1633 additions and 427 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

26
go.mod
View file

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

52
go.sum
View file

@ -127,8 +127,8 @@ github.com/aws/aws-sdk-go v1.25.37/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpi
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go v1.30.27/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
github.com/aws/aws-sdk-go v1.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=

View file

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

238
httpd/api_http_user.go Normal file
View file

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

View file

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

View file

@ -21,6 +21,7 @@ const (
tokenAudienceWebAdmin tokenAudience = "WebAdmin"
tokenAudienceWebClient tokenAudience = "WebClient"
tokenAudienceAPI tokenAudience = "API"
tokenAudienceAPIUser tokenAudience = "APIUser"
tokenAudienceCSRF tokenAudience = "CSRF"
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -172,7 +172,7 @@
$.fn.dataTable.ext.buttons.download = {
text: '<i class="fas fa-download"></i>',
name: 'download',
titleAttr: "Download",
titleAttr: "Download Zip",
action: function (e, dt, node, config) {
var filesArray = [];
var selected = dt.column(0).checkboxes.selected();

View file

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

View file

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