diff --git a/docs/account.md b/docs/account.md
index e384e442..4f6984bb 100644
--- a/docs/account.md
+++ b/docs/account.md
@@ -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
diff --git a/go.mod b/go.mod
index 3f3ce33a..f8921074 100644
--- a/go.mod
+++ b/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
diff --git a/go.sum b/go.sum
index a4656802..df60c31b 100644
--- a/go.sum
+++ b/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=
diff --git a/internal/dataprovider/share.go b/internal/dataprovider/share.go
index 1b0857ba..b9edd45b 100644
--- a/internal/dataprovider/share.go
+++ b/internal/dataprovider/share.go
@@ -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) {
diff --git a/internal/httpd/api_shares.go b/internal/httpd/api_shares.go
index ea7549b5..9692f773 100644
--- a/internal/httpd/api_shares.go
+++ b/internal/httpd/api_shares.go
@@ -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)
diff --git a/internal/httpd/auth_utils.go b/internal/httpd/auth_utils.go
index fe5df0f7..293f07d1 100644
--- a/internal/httpd/auth_utils.go
+++ b/internal/httpd/auth_utils.go
@@ -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")
}
diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go
index 9e22d4f5..13ddce6a 100644
--- a/internal/httpd/httpd_test.go
+++ b/internal/httpd/httpd_test.go
@@ -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)
diff --git a/internal/httpd/internal_test.go b/internal/httpd/internal_test.go
index d9bb5fdc..69cb92e6 100644
--- a/internal/httpd/internal_test.go
+++ b/internal/httpd/internal_test.go
@@ -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()
diff --git a/internal/httpd/middleware.go b/internal/httpd/middleware.go
index 1c4f0cc0..ae829bbc 100644
--- a/internal/httpd/middleware.go
+++ b/internal/httpd/middleware.go
@@ -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
}
diff --git a/internal/httpd/server.go b/internal/httpd/server.go
index 6a78d0a9..b7b36e63 100644
--- a/internal/httpd/server.go
+++ b/internal/httpd/server.go
@@ -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)
diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go
index 20641c2f..c91a61dd 100644
--- a/internal/httpd/webclient.go
+++ b/internal/httpd/webclient.go
@@ -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, "")
+}
diff --git a/templates/webclient/login.html b/templates/webclient/login.html
index 73fc93b8..09fd2fa3 100644
--- a/templates/webclient/login.html
+++ b/templates/webclient/login.html
@@ -19,8 +19,11 @@ along with this program. If not, see