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:
parent
a3d0cf5ddf
commit
7e85356325
15 changed files with 411 additions and 64 deletions
|
@ -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
2
go.mod
|
@ -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
4
go.sum
|
@ -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=
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,19 +469,25 @@ func (s *httpdServer) checkPublicShare(w http.ResponseWriter, r *http.Request, v
|
|||
return share, nil, err
|
||||
}
|
||||
if share.Password != "" {
|
||||
username, password, ok := r.BasicAuth()
|
||||
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(username, password)
|
||||
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)
|
||||
if err != nil {
|
||||
renderError(err, "", getRespStatus(err))
|
||||
|
|
|
@ -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"
|
||||
|
@ -64,6 +65,7 @@ const (
|
|||
|
||||
var (
|
||||
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")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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, "")
|
||||
}
|
||||
|
|
|
@ -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">×</span>
|
||||
</button>
|
||||
</div>
|
||||
{{end}}
|
||||
<form id="login_form" action="{{.CurrentURL}}" method="POST"
|
||||
|
|
40
templates/webclient/sharelogin.html
Normal file
40
templates/webclient/sharelogin.html
Normal 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">×</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}}
|
|
@ -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');
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Reference in a new issue