WebClient shares: replace basic auth with a login form

basic auth will continue to work for REST API

Fixes #1166

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2023-02-12 08:29:53 +01:00
parent a3d0cf5ddf
commit 7e85356325
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
15 changed files with 411 additions and 64 deletions

View file

@ -18,5 +18,5 @@ SFTPGo supports checking passwords stored with argon2id, bcrypt, pbkdf2, md5cryp
If you want to use your existing accounts, you have these options:
- you can import your users inside SFTPGo. Take a look at [convert users](.../examples/convertusers) script, it can convert and import users from Linux system users and Pure-FTPd/ProFTPD virtual users
- you can import your users inside SFTPGo. Take a look at [convert users](../examples/convertusers) script, it can convert and import users from Linux system users and Pure-FTPd/ProFTPD virtual users
- you can use an external authentication program

2
go.mod
View file

@ -35,7 +35,7 @@ require (
github.com/hashicorp/go-hclog v1.4.0
github.com/hashicorp/go-plugin v1.4.8
github.com/hashicorp/go-retryablehttp v0.7.2
github.com/jackc/pgx/v5 v5.2.0
github.com/jackc/pgx/v5 v5.3.0
github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
github.com/klauspost/compress v1.15.15
github.com/lestrrat-go/jwx/v2 v2.0.8

4
go.sum
View file

@ -1350,8 +1350,8 @@ github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgS
github.com/jackc/pgx/v4 v4.16.0/go.mod h1:N0A9sFdWzkw/Jy1lwoiB64F2+ugFZi987zRxcPez/wI=
github.com/jackc/pgx/v4 v4.16.1/go.mod h1:SIhx0D5hoADaiXZVyv+3gSm3LCIIINTVO0PficsvWGQ=
github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw=
github.com/jackc/pgx/v5 v5.2.0 h1:NdPpngX0Y6z6XDFKqmFQaE+bCtkqzvQIOt1wvBlAqs8=
github.com/jackc/pgx/v5 v5.2.0/go.mod h1:Ptn7zmohNsWEsdxRawMzk3gaKma2obW+NWTnKa0S4nk=
github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA=
github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=

View file

@ -273,14 +273,11 @@ func (s *Share) validate() error {
}
// CheckCredentials verifies the share credentials if a password if set
func (s *Share) CheckCredentials(username, password string) (bool, error) {
func (s *Share) CheckCredentials(password string) (bool, error) {
if s.Password == "" {
return true, nil
}
if username == "" || password == "" {
return false, ErrInvalidCredentials
}
if username != s.Username {
if password == "" {
return false, ErrInvalidCredentials
}
if strings.HasPrefix(s.Password, bcryptPwdPrefix) {

View file

@ -25,6 +25,7 @@ import (
"strings"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
"github.com/sftpgo/sdk"
@ -179,7 +180,7 @@ func deleteShare(w http.ResponseWriter, r *http.Request) {
func (s *httpdServer) readBrowsableShareContents(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite}
share, connection, err := s.checkPublicShare(w, r, validScopes, false)
share, connection, err := s.checkPublicShare(w, r, validScopes)
if err != nil {
return
}
@ -210,7 +211,7 @@ func (s *httpdServer) readBrowsableShareContents(w http.ResponseWriter, r *http.
func (s *httpdServer) downloadBrowsableSharedFile(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite}
share, connection, err := s.checkPublicShare(w, r, validScopes, false)
share, connection, err := s.checkPublicShare(w, r, validScopes)
if err != nil {
return
}
@ -260,7 +261,7 @@ func (s *httpdServer) downloadBrowsableSharedFile(w http.ResponseWriter, r *http
func (s *httpdServer) downloadFromShare(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite}
share, connection, err := s.checkPublicShare(w, r, validScopes, false)
share, connection, err := s.checkPublicShare(w, r, validScopes)
if err != nil {
return
}
@ -323,7 +324,7 @@ func (s *httpdServer) uploadFileToShare(w http.ResponseWriter, r *http.Request)
}
name := getURLParam(r, "name")
validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeWrite, dataprovider.ShareScopeReadWrite}
share, connection, err := s.checkPublicShare(w, r, validScopes, false)
share, connection, err := s.checkPublicShare(w, r, validScopes)
if err != nil {
return
}
@ -353,7 +354,7 @@ func (s *httpdServer) uploadFilesToShare(w http.ResponseWriter, r *http.Request)
r.Body = http.MaxBytesReader(w, r.Body, maxUploadFileSize)
}
validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeWrite, dataprovider.ShareScopeReadWrite}
share, connection, err := s.checkPublicShare(w, r, validScopes, false)
share, connection, err := s.checkPublicShare(w, r, validScopes)
if err != nil {
return
}
@ -402,9 +403,43 @@ func (s *httpdServer) uploadFilesToShare(w http.ResponseWriter, r *http.Request)
}
}
func (s *httpdServer) checkWebClientShareCredentials(w http.ResponseWriter, r *http.Request, share *dataprovider.Share) error {
doRedirect := func() {
redirectURL := path.Join(webClientPubSharesPath, share.ShareID, fmt.Sprintf("login?next=%s", url.QueryEscape(r.RequestURI)))
http.Redirect(w, r, redirectURL, http.StatusFound)
}
token, err := jwtauth.VerifyRequest(s.tokenAuth, r, jwtauth.TokenFromCookie)
if err != nil || token == nil {
doRedirect()
return errInvalidToken
}
if !util.Contains(token.Audience(), tokenAudienceWebShare) {
logger.Debug(logSender, "", "invalid token audience for share %q", share.ShareID)
doRedirect()
return errInvalidToken
}
if tokenValidationMode != tokenValidationNoIPMatch {
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
if !util.Contains(token.Audience(), ipAddr) {
logger.Debug(logSender, "", "token for share %q is not valid for the ip address %q", share.ShareID, ipAddr)
doRedirect()
return errInvalidToken
}
}
ctx := jwtauth.NewContext(r.Context(), token, nil)
claims, err := getTokenClaims(r.WithContext(ctx))
if err != nil || claims.Username != share.ShareID {
logger.Debug(logSender, "", "token not valid for share %q", share.ShareID)
doRedirect()
return errInvalidToken
}
return nil
}
func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, validScopes []dataprovider.ShareScope,
isWebClient bool,
) (dataprovider.Share, *Connection, error) {
isWebClient := isWebClientRequest(r)
renderError := func(err error, message string, statusCode int) {
if isWebClient {
s.renderClientMessagePage(w, r, "Unable to access the share", message, statusCode, err, "")
@ -434,17 +469,23 @@ func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, v
return share, nil, err
}
if share.Password != "" {
username, password, ok := r.BasicAuth()
if !ok {
w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
renderError(dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return share, nil, dataprovider.ErrInvalidCredentials
}
match, err := share.CheckCredentials(username, password)
if !match || err != nil {
w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
renderError(dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return share, nil, dataprovider.ErrInvalidCredentials
if isWebClient {
if err := s.checkWebClientShareCredentials(w, r, &share); err != nil {
return share, nil, dataprovider.ErrInvalidCredentials
}
} else {
_, password, ok := r.BasicAuth()
if !ok {
w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
renderError(dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return share, nil, dataprovider.ErrInvalidCredentials
}
match, err := share.CheckCredentials(password)
if !match || err != nil {
w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
renderError(dataprovider.ErrInvalidCredentials, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return share, nil, dataprovider.ErrInvalidCredentials
}
}
}
user, err := getUserForShare(share)

View file

@ -34,6 +34,7 @@ type tokenAudience = string
const (
tokenAudienceWebAdmin tokenAudience = "WebAdmin"
tokenAudienceWebClient tokenAudience = "WebClient"
tokenAudienceWebShare tokenAudience = "WebShare"
tokenAudienceWebAdminPartial tokenAudience = "WebAdminPartial"
tokenAudienceWebClientPartial tokenAudience = "WebClientPartial"
tokenAudienceAPI tokenAudience = "API"
@ -63,7 +64,8 @@ const (
)
var (
tokenDuration = 20 * time.Minute
tokenDuration = 20 * time.Minute
shareTokenDuration = 12 * time.Hour
// csrf token duration is greater than normal token duration to reduce issues
// with the login form
csrfTokenDuration = 6 * time.Hour
@ -267,12 +269,16 @@ func (c *jwtTokenClaims) createAndSetCookie(w http.ResponseWriter, r *http.Reque
} else {
basePath = webBaseClientPath
}
duration := tokenDuration
if audience == tokenAudienceWebShare {
duration = shareTokenDuration
}
http.SetCookie(w, &http.Cookie{
Name: jwtCookieKey,
Value: resp["access_token"].(string),
Path: basePath,
Expires: time.Now().Add(tokenDuration),
MaxAge: int(tokenDuration / time.Second),
Expires: time.Now().Add(duration),
MaxAge: int(duration / time.Second),
HttpOnly: true,
Secure: isTLS(r),
SameSite: http.SameSiteStrictMode,
@ -403,6 +409,7 @@ func verifyCSRFToken(tokenString, ip string) error {
if tokenValidationMode != tokenValidationNoIPMatch {
if !util.Contains(token.Audience(), ip) {
fmt.Printf("ip %v audience %+v\n\n", ip, token.Audience())
logger.Debug(logSender, "", "error validating CSRF token IP audience")
return errors.New("the form token is not valid")
}

View file

@ -12471,6 +12471,13 @@ func TestShareUsage(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID+"_mod", nil)
assert.NoError(t, err)
req.RequestURI = webClientPubSharesPath + "/" + objectID + "_mod"
req.SetBasicAuth(defaultUsername, defaultPassword)
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
share.ExpiresAt = 0
jsonReq := make(map[string]any)
jsonReq["name"] = share.Name
@ -12680,6 +12687,148 @@ func TestShareUsage(t *testing.T) {
executeRequest(req)
}
func TestWebClientShareCredentials(t *testing.T) {
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err)
token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
assert.NoError(t, err)
shareRead := dataprovider.Share{
Name: "test share read",
Scope: dataprovider.ShareScopeRead,
Password: defaultPassword,
Paths: []string{"/"},
}
shareWrite := dataprovider.Share{
Name: "test share write",
Scope: dataprovider.ShareScopeReadWrite,
Password: defaultPassword,
Paths: []string{"/"},
}
asJSON, err := json.Marshal(shareRead)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, token)
rr := executeRequest(req)
checkResponseCode(t, http.StatusCreated, rr)
shareReadID := rr.Header().Get("X-Object-ID")
assert.NotEmpty(t, shareReadID)
asJSON, err = json.Marshal(shareWrite)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON))
assert.NoError(t, err)
setBearerForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusCreated, rr)
shareWriteID := rr.Header().Get("X-Object-ID")
assert.NotEmpty(t, shareWriteID)
uri := path.Join(webClientPubSharesPath, shareReadID, "browse")
req, err = http.NewRequest(http.MethodGet, uri, nil)
assert.NoError(t, err)
req.RequestURI = uri
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
location := rr.Header().Get("Location")
assert.Contains(t, location, url.QueryEscape(uri))
// get the login form
req, err = http.NewRequest(http.MethodGet, location, nil)
assert.NoError(t, err)
req.RequestURI = uri
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
// now set the user token, it is not valid for the share
req, err = http.NewRequest(http.MethodGet, uri, nil)
assert.NoError(t, err)
req.RequestURI = uri
setJWTCookieForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
// get a share token
form := make(url.Values)
form.Set("share_password", defaultPassword)
loginURI := path.Join(webClientPubSharesPath, shareReadID, "login")
req, err = http.NewRequest(http.MethodPost, loginURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "unable to verify form token")
// set the CSRF token
csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
assert.NoError(t, err)
form.Set(csrfFormToken, csrfToken)
req, err = http.NewRequest(http.MethodPost, loginURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), "Share login successful")
cookie := rr.Header().Get("Set-Cookie")
cookie = strings.TrimPrefix(cookie, "jwt=")
assert.NotEmpty(t, cookie)
req, err = http.NewRequest(http.MethodGet, uri, nil)
assert.NoError(t, err)
req.RequestURI = uri
setJWTCookieForReq(req, cookie)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
// the same cookie will not work for the other share
req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, shareWriteID, "browse"), nil)
assert.NoError(t, err)
req.RequestURI = uri
setJWTCookieForReq(req, cookie)
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
// IP address does not match
req, err = http.NewRequest(http.MethodGet, uri, nil)
assert.NoError(t, err)
req.RequestURI = uri
setJWTCookieForReq(req, cookie)
req.RemoteAddr = "1.2.3.4:1234"
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
// try to login with invalid credentials
form.Set("share_password", "")
req, err = http.NewRequest(http.MethodPost, loginURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), dataprovider.ErrInvalidCredentials.Error())
// login with the next param set
form.Set("share_password", defaultPassword)
nextURI := path.Join(webClientPubSharesPath, shareReadID, "browse")
loginURI = path.Join(webClientPubSharesPath, shareReadID, fmt.Sprintf("login?next=%s", url.QueryEscape(nextURI)))
req, err = http.NewRequest(http.MethodPost, loginURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
assert.Equal(t, nextURI, rr.Header().Get("Location"))
// try to login to a missing share
loginURI = path.Join(webClientPubSharesPath, "missing", "login")
req, err = http.NewRequest(http.MethodPost, loginURI, bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = defaultRemoteAddr
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
assert.Contains(t, rr.Body.String(), dataprovider.ErrInvalidCredentials.Error())
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
}
func TestShareMaxSessions(t *testing.T) {
u := getTestUser()
u.MaxSessions = 1
@ -13716,13 +13865,10 @@ func TestUserAPIShares(t *testing.T) {
s, err := dataprovider.ShareExists(objectID, defaultUsername)
assert.NoError(t, err)
match, err := s.CheckCredentials(defaultUsername, defaultPassword)
match, err := s.CheckCredentials(defaultPassword)
assert.True(t, match)
assert.NoError(t, err)
match, err = s.CheckCredentials(defaultUsername, defaultPassword+"mod")
assert.False(t, match)
assert.Error(t, err)
match, err = s.CheckCredentials(altAdminUsername, defaultPassword)
match, err = s.CheckCredentials(defaultPassword + "mod")
assert.False(t, match)
assert.Error(t, err)
@ -13737,10 +13883,10 @@ func TestUserAPIShares(t *testing.T) {
s, err = dataprovider.ShareExists(objectID, defaultUsername)
assert.NoError(t, err)
match, err = s.CheckCredentials(defaultUsername, defaultPassword)
match, err = s.CheckCredentials(defaultPassword)
assert.True(t, match)
assert.NoError(t, err)
match, err = s.CheckCredentials(defaultUsername, defaultPassword+"mod")
match, err = s.CheckCredentials(defaultPassword + "mod")
assert.False(t, match)
assert.Error(t, err)
@ -16605,7 +16751,7 @@ func TestWebUserShare(t *testing.T) {
// check the password
s, err := dataprovider.ShareExists(share.ShareID, user.Username)
assert.NoError(t, err)
match, err := s.CheckCredentials(user.Username, defaultPassword)
match, err := s.CheckCredentials(defaultPassword)
assert.NoError(t, err)
assert.True(t, match)

View file

@ -1009,6 +1009,56 @@ func TestCSRFToken(t *testing.T) {
csrfTokenAuth = jwtauth.New(jwa.HS256.String(), util.GenerateRandomBytes(32), nil)
}
func TestCreateShareCookieError(t *testing.T) {
username := "share_user"
pwd := "pwd"
user := &dataprovider.User{
BaseUser: sdk.BaseUser{
Username: username,
Password: pwd,
HomeDir: filepath.Join(os.TempDir(), username),
Status: 1,
Permissions: map[string][]string{
"/": {dataprovider.PermAny},
},
},
}
err := dataprovider.AddUser(user, "", "", "")
assert.NoError(t, err)
share := &dataprovider.Share{
Name: "test share cookie error",
ShareID: util.GenerateUniqueID(),
Scope: dataprovider.ShareScopeRead,
Password: pwd,
Paths: []string{"/"},
Username: username,
}
err = dataprovider.AddShare(share, "", "", "")
assert.NoError(t, err)
server := httpdServer{
tokenAuth: jwtauth.New("TS256", util.GenerateRandomBytes(32), nil),
}
form := make(url.Values)
form.Set("share_password", pwd)
form.Set(csrfFormToken, createCSRFToken("127.0.0.1"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", share.ShareID)
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodPost, path.Join(webClientPubSharesPath, share.ShareID, "login"),
bytes.NewBuffer([]byte(form.Encode())))
assert.NoError(t, err)
req.RemoteAddr = "127.0.0.1:2345"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
server.handleClientShareLoginPost(rr, req)
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
assert.Contains(t, rr.Body.String(), common.ErrInternalFailure.Error())
err = dataprovider.DeleteUser(username, "", "", "")
assert.NoError(t, err)
}
func TestCreateTokenError(t *testing.T) {
server := httpdServer{
tokenAuth: jwtauth.New("PS256", util.GenerateRandomBytes(32), nil),
@ -1087,6 +1137,12 @@ func TestCreateTokenError(t *testing.T) {
_, err = getIPListEntryFromPostFields(req, dataprovider.IPListTypeAllowList)
assert.Error(t, err)
req, _ = http.NewRequest(http.MethodPost, path.Join(webClientSharePath, "shareID", "login?a=a%C3%AO%GG"), bytes.NewBuffer([]byte(form.Encode())))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
server.handleClientShareLoginPost(rr, req)
assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
req, _ = http.NewRequest(http.MethodPost, webClientLoginPath+"?a=a%C3%AO%GG", bytes.NewBuffer([]byte(form.Encode())))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()

View file

@ -86,14 +86,14 @@ func validateJWTToken(w http.ResponseWriter, r *http.Request, audience tokenAudi
return err
}
if !util.Contains(token.Audience(), audience) {
logger.Debug(logSender, "", "the token is not valid for audience %#v", audience)
logger.Debug(logSender, "", "the token is not valid for audience %q", audience)
doRedirect("Your token audience is not valid", nil)
return errInvalidToken
}
if tokenValidationMode != tokenValidationNoIPMatch {
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
if !util.Contains(token.Audience(), ipAddr) {
logger.Debug(logSender, "", "the token with id %#v is not valid for the ip address %#v", token.JwtID(), ipAddr)
logger.Debug(logSender, "", "the token with id %q is not valid for the ip address %q", token.JwtID(), ipAddr)
doRedirect("Your token is not valid", nil)
return errInvalidToken
}

View file

@ -1447,6 +1447,8 @@ func (s *httpdServer) setupWebClientRoutes() {
Post(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecoveryPost)
}
// share routes available to external users
s.router.Get(webClientPubSharesPath+"/{id}/login", s.handleClientShareLoginGet)
s.router.Post(webClientPubSharesPath+"/{id}/login", s.handleClientShareLoginPost)
s.router.Get(webClientPubSharesPath+"/{id}", s.downloadFromShare)
s.router.Get(webClientPubSharesPath+"/{id}/partial", s.handleClientSharePartialDownload)
s.router.Get(webClientPubSharesPath+"/{id}/browse", s.handleShareGetFiles)

View file

@ -60,6 +60,7 @@ const (
templateClientShare = "share.html"
templateClientShares = "shares.html"
templateClientViewPDF = "viewpdf.html"
templateShareLogin = "sharelogin.html"
templateShareFiles = "sharefiles.html"
templateUploadToShare = "shareupload.html"
pageClientFilesTitle = "My Files"
@ -156,6 +157,15 @@ type filesPage struct {
HasIntegrations bool
}
type shareLoginPage struct {
CurrentURL string
Version string
Error string
CSRFToken string
StaticURL string
Branding UIBranding
}
type shareFilesPage struct {
baseClientPage
CurrentDir string
@ -298,6 +308,11 @@ func loadClientTemplates(templatesPath string) {
filepath.Join(templatesPath, templateCommonDir, templateCommonCSS),
filepath.Join(templatesPath, templateClientDir, templateClientViewPDF),
}
shareLoginPath := []string{
filepath.Join(templatesPath, templateCommonDir, templateCommonCSS),
filepath.Join(templatesPath, templateClientDir, templateClientBaseLogin),
filepath.Join(templatesPath, templateClientDir, templateShareLogin),
}
shareFilesPath := []string{
filepath.Join(templatesPath, templateCommonDir, templateCommonCSS),
filepath.Join(templatesPath, templateClientDir, templateClientBase),
@ -318,6 +333,7 @@ func loadClientTemplates(templatesPath string) {
twoFactorTmpl := util.LoadTemplate(nil, twoFactorPath...)
twoFactorRecoveryTmpl := util.LoadTemplate(nil, twoFactorRecoveryPath...)
editFileTmpl := util.LoadTemplate(nil, editFilePath...)
shareLoginTmpl := util.LoadTemplate(nil, shareLoginPath...)
sharesTmpl := util.LoadTemplate(nil, sharesPaths...)
shareTmpl := util.LoadTemplate(nil, sharePaths...)
forgotPwdTmpl := util.LoadTemplate(nil, forgotPwdPaths...)
@ -340,6 +356,7 @@ func loadClientTemplates(templatesPath string) {
clientTemplates[templateForgotPassword] = forgotPwdTmpl
clientTemplates[templateResetPassword] = resetPwdTmpl
clientTemplates[templateClientViewPDF] = viewPDFTmpl
clientTemplates[templateShareLogin] = shareLoginTmpl
clientTemplates[templateShareFiles] = shareFilesTmpl
clientTemplates[templateUploadToShare] = shareUploadTmpl
}
@ -397,6 +414,18 @@ func (s *httpdServer) renderClientResetPwdPage(w http.ResponseWriter, error, ip
renderClientTemplate(w, templateResetPassword, data)
}
func (s *httpdServer) renderShareLoginPage(w http.ResponseWriter, currentURL, error, ip string) {
data := shareLoginPage{
CurrentURL: currentURL,
Version: version.Get().Version,
Error: error,
CSRFToken: createCSRFToken(ip),
StaticURL: webStaticFilesPath,
Branding: s.binding.Branding.WebClient,
}
renderClientTemplate(w, templateShareLogin, data)
}
func renderClientTemplate(w http.ResponseWriter, tmplName string, data any) {
err := clientTemplates[tmplName].ExecuteTemplate(w, tmplName, data)
if err != nil {
@ -663,7 +692,7 @@ func (s *httpdServer) handleWebClientDownloadZip(w http.ResponseWriter, r *http.
func (s *httpdServer) handleClientSharePartialDownload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite}
share, connection, err := s.checkPublicShare(w, r, validScopes, true)
share, connection, err := s.checkPublicShare(w, r, validScopes)
if err != nil {
return
}
@ -706,7 +735,7 @@ func (s *httpdServer) handleClientSharePartialDownload(w http.ResponseWriter, r
func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite}
share, connection, err := s.checkPublicShare(w, r, validScopes, true)
share, connection, err := s.checkPublicShare(w, r, validScopes)
if err != nil {
return
}
@ -757,7 +786,7 @@ func (s *httpdServer) handleShareGetDirContents(w http.ResponseWriter, r *http.R
func (s *httpdServer) handleClientUploadToShare(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeWrite, dataprovider.ShareScopeReadWrite}
share, _, err := s.checkPublicShare(w, r, validScopes, true)
share, _, err := s.checkPublicShare(w, r, validScopes)
if err != nil {
return
}
@ -771,7 +800,7 @@ func (s *httpdServer) handleClientUploadToShare(w http.ResponseWriter, r *http.R
func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead, dataprovider.ShareScopeReadWrite}
share, connection, err := s.checkPublicShare(w, r, validScopes, true)
share, connection, err := s.checkPublicShare(w, r, validScopes)
if err != nil {
return
}
@ -1427,3 +1456,46 @@ func (s *httpdServer) handleClientGetPDF(w http.ResponseWriter, r *http.Request)
}
downloadFile(w, r, connection, name, info, true, nil) //nolint:errcheck
}
func (s *httpdServer) handleClientShareLoginGet(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
s.renderShareLoginPage(w, r.RequestURI, "", util.GetIPFromRemoteAddress(r.RemoteAddr))
}
func (s *httpdServer) handleClientShareLoginPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
if err := r.ParseForm(); err != nil {
s.renderShareLoginPage(w, r.RequestURI, err.Error(), ipAddr)
return
}
if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
s.renderShareLoginPage(w, r.RequestURI, err.Error(), ipAddr)
return
}
shareID := getURLParam(r, "id")
share, err := dataprovider.ShareExists(shareID, "")
if err != nil {
s.renderShareLoginPage(w, r.RequestURI, dataprovider.ErrInvalidCredentials.Error(), ipAddr)
return
}
match, err := share.CheckCredentials(r.Form.Get("share_password"))
if !match || err != nil {
s.renderShareLoginPage(w, r.RequestURI, dataprovider.ErrInvalidCredentials.Error(), ipAddr)
return
}
c := jwtTokenClaims{
Username: shareID,
}
err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebShare, ipAddr)
if err != nil {
s.renderShareLoginPage(w, r.RequestURI, common.ErrInternalFailure.Error(), ipAddr)
return
}
next := r.URL.Query().Get("next")
if strings.HasPrefix(next, path.Join(webClientPubSharesPath, share.ShareID)) {
http.Redirect(w, r, next, http.StatusFound)
}
s.renderClientMessagePage(w, r, "Share Login OK", "Share login successful, you can now use your link",
http.StatusOK, nil, "")
}

View file

@ -19,8 +19,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{{define "content"}}
{{if .Error}}
<div class="card mb-4 border-left-warning">
<div class="card-body text-form-error">{{.Error}}</div>
<div class="alert alert-warning alert-dismissible fade show" role="alert">
{{.Error}}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
{{end}}
<form id="login_form" action="{{.CurrentURL}}" method="POST"

View file

@ -0,0 +1,40 @@
<!--
Copyright (C) 2019-2023 Nicola Murino
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, version 3.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
{{template "baselogin" .}}
{{define "title"}}Share login{{end}}
{{define "content"}}
{{if .Error}}
<div class="alert alert-warning alert-dismissible fade show" role="alert">
{{.Error}}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
{{end}}
<form id="login_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"
class="user-custom">
<div class="form-group">
<input type="password" class="form-control form-control-user-custom"
id="inputSharePassword" name="share_password" placeholder="Password" spellcheck="false" required>
</div>
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
<button type="submit" class="btn btn-primary btn-user-custom btn-block">
Login
</button>
</form>
{{end}}

View file

@ -112,19 +112,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div>
<div id="writeShare">
<p>You can upload one or more files to the shared directory using this <a id="writePageLink" href="#" target="_blank">page</a></p>
<p>
<a data-toggle="collapse" href="#collapseWriteShareAdvanced" aria-expanded="false" aria-controls="collapseWriteShareAdvanced">
Advanced options
</a>
</p>
<div class="collapse" id="collapseWriteShareAdvanced">
<div class="card card-body">
<p>You can upload one or more files to the shared directory by sending a multipart/form-data request to this <a id="writeLink" href="#" target="_blank">link</a>. The form field name for the file(s) is <b><code>filenames</code></b>.</p>
<p>Example: <code>curl -F filenames=@file1.txt -F filenames=@file2.txt "share link"</code></p>
<p>Or you can upload files one by one by adding the path encoded file name to the share <a id="writeLinkSingle" href="#" target="_blank">link</a> and sending the file as POST body. The optional <b><code>X-SFTPGO-MTIME</code></b> header allows to set the file modification time as milliseconds since epoch.</p>
<p>Example: <code>curl --data-binary @file.txt -H "Content-Type: application/octet-stream" -H "X-SFTPGO-MTIME: 1638882991234" "share link/file.txt"</code></p>
</div>
</div>
</div>
<div id="expiredShare">
This share is no longer accessible because it has expired
@ -249,10 +236,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
$('#readShare').hide();
$('#writePageLink').attr("href", shareURL+"/upload");
$('#writePageLink').attr("title", shareURL+"/upload");
$('#writeLink').attr("href", shareURL);
$('#writeLink').attr("title", shareURL);
$('#writeLinkSingle').attr("href", shareURL);
$('#writeLinkSingle').attr("title", shareURL);
}
}
$('#linkModal').modal('show');

View file

@ -22,8 +22,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="col-xl-5 col-lg-6 col-md-8">
<div class="card shadow-lg my-5">
<div class="card-header py-3">
<h6 id="default_title" class="m-0 font-weight-bold text-primary">Upload one or more files to share "{{.Share.Name}}", user "{{.Share.Username}}"</h6>
<h6 id="success_title" class="m-0 font-weight-bold text-primary" style="display: none;">Upload completed to share "{{.Share.Name}}", user "{{.Share.Username}}"</h6>
<h6 id="default_title" class="m-0 font-weight-bold text-primary">Upload one or more files to share "{{.Share.Name}}"</h6>
<h6 id="success_title" class="m-0 font-weight-bold text-primary" style="display: none;">Upload completed to share "{{.Share.Name}}"</h6>
</div>
<div class="card-body">
<div id="errorMsg" class="alert alert-warning alert-dismissible fade show" style="display: none;" role="alert">