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 . {{define "content"}} {{if .Error}} -
-
{{.Error}}
+ {{end}}
. +--> +{{template "baselogin" .}} + +{{define "title"}}Share login{{end}} + +{{define "content"}} + {{if .Error}} + + {{end}} + +
+ +
+ + +
+{{end}} \ No newline at end of file diff --git a/templates/webclient/shares.html b/templates/webclient/shares.html index 25026573..4b9d0507 100644 --- a/templates/webclient/shares.html +++ b/templates/webclient/shares.html @@ -112,19 +112,6 @@ along with this program. If not, see .

You can upload one or more files to the shared directory using this page

-

- -

-
-
-

You can upload one or more files to the shared directory by sending a multipart/form-data request to this link. The form field name for the file(s) is filenames.

-

Example: curl -F filenames=@file1.txt -F filenames=@file2.txt "share link"

-

Or you can upload files one by one by adding the path encoded file name to the share link and sending the file as POST body. The optional X-SFTPGO-MTIME header allows to set the file modification time as milliseconds since epoch.

-

Example: curl --data-binary @file.txt -H "Content-Type: application/octet-stream" -H "X-SFTPGO-MTIME: 1638882991234" "share link/file.txt"

-
-
This share is no longer accessible because it has expired @@ -249,10 +236,6 @@ along with this program. If not, see . $('#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'); diff --git a/templates/webclient/shareupload.html b/templates/webclient/shareupload.html index 2f7e2be6..18652ca4 100644 --- a/templates/webclient/shareupload.html +++ b/templates/webclient/shareupload.html @@ -22,8 +22,8 @@ along with this program. If not, see .
-
Upload one or more files to share "{{.Share.Name}}", user "{{.Share.Username}}"
- +
Upload one or more files to share "{{.Share.Name}}"
+