From 363770ab8483cb650d1b1e0d08cd4ec9e48eb664 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Tue, 18 Jun 2024 19:10:32 +0200 Subject: [PATCH] WebClient shares: add a logout button Signed-off-by: Nicola Murino --- internal/httpd/api_shares.go | 48 ++++++----- internal/httpd/httpd_test.go | 24 ++++++ internal/httpd/server.go | 1 + internal/httpd/webadmin.go | 1 + internal/httpd/webclient.go | 110 ++++++++++++++++--------- templates/common/base.html | 11 +++ templates/webclient/base.html | 2 + templates/webclient/sharedownload.html | 9 +- templates/webclient/shareupload.html | 9 +- 9 files changed, 155 insertions(+), 60 deletions(-) diff --git a/internal/httpd/api_shares.go b/internal/httpd/api_shares.go index f167aee2..597cfa6f 100644 --- a/internal/httpd/api_shares.go +++ b/internal/httpd/api_shares.go @@ -425,35 +425,43 @@ func (s *httpdServer) uploadFilesToShare(w http.ResponseWriter, r *http.Request) } } +func (s *httpdServer) getShareClaims(r *http.Request, shareID string) (*jwtTokenClaims, error) { + token, err := jwtauth.VerifyRequest(s.tokenAuth, r, jwtauth.TokenFromCookie) + if err != nil || token == nil { + return nil, errInvalidToken + } + tokenString := jwtauth.TokenFromCookie(r) + if tokenString == "" || invalidatedJWTTokens.Get(tokenString) { + return nil, errInvalidToken + } + if !util.Contains(token.Audience(), tokenAudienceWebShare) { + logger.Debug(logSender, "", "invalid token audience for share %q", shareID) + return nil, errInvalidToken + } + ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) + if err := validateIPForToken(token, ipAddr); err != nil { + logger.Debug(logSender, "", "token for share %q is not valid for the ip address %q", shareID, ipAddr) + return nil, err + } + ctx := jwtauth.NewContext(r.Context(), token, nil) + claims, err := getTokenClaims(r.WithContext(ctx)) + if err != nil || claims.Username != shareID { + logger.Debug(logSender, "", "token not valid for share %q", shareID) + return nil, errInvalidToken + } + return &claims, nil +} + 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 - } - ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) - if err := validateIPForToken(token, ipAddr); err != nil { - logger.Debug(logSender, "", "token for share %q is not valid for the ip address %q", share.ShareID, ipAddr) + if _, err := s.getShareClaims(r, share.ShareID); err != nil { doRedirect() return err } - 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 } diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index 7c14b8e1..6b377c01 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -14431,6 +14431,30 @@ func TestWebClientShareCredentials(t *testing.T) { req.RemoteAddr = "1.2.3.4:1234" rr = executeRequest(req) checkResponseCode(t, http.StatusFound, rr) + // logout to a different share, the cookie is not valid. + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, shareWriteID, "logout"), nil) + assert.NoError(t, err) + req.RequestURI = uri + setJWTCookieForReq(req, cookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), util.I18nErrorInvalidToken) + // logout + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, shareReadID, "logout"), nil) + assert.NoError(t, err) + req.RequestURI = uri + setJWTCookieForReq(req, cookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + // the cookie is no longer valid + req, err = http.NewRequest(http.MethodGet, path.Join(webClientPubSharesPath, shareReadID, "download?b=c"), nil) + assert.NoError(t, err) + req.RequestURI = uri + setJWTCookieForReq(req, cookie) + rr = executeRequest(req) + checkResponseCode(t, http.StatusFound, rr) + assert.Contains(t, rr.Header().Get("Location"), "/login") + // try to login with invalid credentials loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr) assert.NoError(t, err) diff --git a/internal/httpd/server.go b/internal/httpd/server.go index 01918377..c2d90017 100644 --- a/internal/httpd/server.go +++ b/internal/httpd/server.go @@ -1551,6 +1551,7 @@ func (s *httpdServer) setupWebClientRoutes() { s.router.Get(webClientPubSharesPath+"/{id}/login", s.handleClientShareLoginGet) s.router.With(jwtauth.Verify(s.csrfTokenAuth, jwtauth.TokenFromCookie)). Post(webClientPubSharesPath+"/{id}/login", s.handleClientShareLoginPost) + s.router.Get(webClientPubSharesPath+"/{id}/logout", s.handleClientShareLogout) s.router.Get(webClientPubSharesPath+"/{id}", s.downloadFromShare) s.router.Post(webClientPubSharesPath+"/{id}/partial", s.handleClientSharePartialDownload) s.router.Get(webClientPubSharesPath+"/{id}/browse", s.handleShareGetFiles) diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index c19bee1c..404b1c5f 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -151,6 +151,7 @@ type basePage struct { HasSearcher bool HasExternalLogin bool LoggedUser *dataprovider.Admin + IsLoggedToShare bool Branding UIBranding } diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go index 6f0ed5a3..3d082eef 100644 --- a/internal/httpd/webclient.go +++ b/internal/httpd/webclient.go @@ -81,21 +81,22 @@ func isZeroTime(t time.Time) bool { type baseClientPage struct { commonBasePage - Title string - CurrentURL string - FilesURL string - SharesURL string - ShareURL string - ProfileURL string - PingURL string - ChangePwdURL string - LogoutURL string - LoginURL string - EditURL string - MFAURL string - CSRFToken string - LoggedUser *dataprovider.User - Branding UIBranding + Title string + CurrentURL string + FilesURL string + SharesURL string + ShareURL string + ProfileURL string + PingURL string + ChangePwdURL string + LogoutURL string + LoginURL string + EditURL string + MFAURL string + CSRFToken string + LoggedUser *dataprovider.User + IsLoggedToShare bool + Branding UIBranding } type dirMapping struct { @@ -530,21 +531,22 @@ func (s *httpdServer) getBaseClientPageData(title, currentURL string, w http.Res } data := baseClientPage{ - commonBasePage: getCommonBasePage(r), - Title: title, - CurrentURL: currentURL, - FilesURL: webClientFilesPath, - SharesURL: webClientSharesPath, - ShareURL: webClientSharePath, - ProfileURL: webClientProfilePath, - PingURL: webClientPingPath, - ChangePwdURL: webChangeClientPwdPath, - LogoutURL: webClientLogoutPath, - EditURL: webClientEditFilePath, - MFAURL: webClientMFAPath, - CSRFToken: csrfToken, - LoggedUser: getUserFromToken(r), - Branding: s.binding.Branding.WebClient, + commonBasePage: getCommonBasePage(r), + Title: title, + CurrentURL: currentURL, + FilesURL: webClientFilesPath, + SharesURL: webClientSharesPath, + ShareURL: webClientSharePath, + ProfileURL: webClientProfilePath, + PingURL: webClientPingPath, + ChangePwdURL: webChangeClientPwdPath, + LogoutURL: webClientLogoutPath, + EditURL: webClientEditFilePath, + MFAURL: webClientMFAPath, + CSRFToken: csrfToken, + LoggedUser: getUserFromToken(r), + IsLoggedToShare: false, + Branding: s.binding.Branding.WebClient, } if !strings.HasPrefix(r.RequestURI, webClientPubSharesPath) { data.LoginURL = webClientLoginPath @@ -739,6 +741,8 @@ func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Reque baseData := s.getBaseClientPageData(util.I18nSharedFilesTitle, currentURL, w, r) baseData.FilesURL = currentURL baseSharePath := path.Join(webClientPubSharesPath, share.ShareID) + baseData.LogoutURL = path.Join(webClientPubSharesPath, share.ShareID, "logout") + baseData.IsLoggedToShare = share.Password != "" data := filesPage{ baseClientPage: baseData, @@ -766,21 +770,32 @@ func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Reque renderClientTemplate(w, templateClientFiles, data) } -func (s *httpdServer) renderShareDownloadPage(w http.ResponseWriter, r *http.Request, downloadLink string) { +func (s *httpdServer) renderShareDownloadPage(w http.ResponseWriter, r *http.Request, share *dataprovider.Share, + downloadLink string, +) { data := shareDownloadPage{ baseClientPage: s.getBaseClientPageData(util.I18nShareDownloadTitle, "", w, r), DownloadLink: downloadLink, } + data.LogoutURL = "" + if share.Password != "" { + data.LogoutURL = path.Join(webClientPubSharesPath, share.ShareID, "logout") + } + renderClientTemplate(w, templateShareDownload, data) } -func (s *httpdServer) renderUploadToSharePage(w http.ResponseWriter, r *http.Request, share dataprovider.Share) { +func (s *httpdServer) renderUploadToSharePage(w http.ResponseWriter, r *http.Request, share *dataprovider.Share) { currentURL := path.Join(webClientPubSharesPath, share.ShareID, "upload") data := shareUploadPage{ baseClientPage: s.getBaseClientPageData(util.I18nShareUploadTitle, currentURL, w, r), - Share: &share, + Share: share, UploadBasePath: path.Join(webClientPubSharesPath, share.ShareID), } + data.LogoutURL = "" + if share.Password != "" { + data.LogoutURL = path.Join(webClientPubSharesPath, share.ShareID, "logout") + } renderClientTemplate(w, templateUploadToShare, data) } @@ -1022,7 +1037,7 @@ func (s *httpdServer) handleClientUploadToShare(w http.ResponseWriter, r *http.R http.Redirect(w, r, path.Join(webClientPubSharesPath, share.ShareID, "browse"), http.StatusFound) return } - s.renderUploadToSharePage(w, r, share) + s.renderUploadToSharePage(w, r, &share) } func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request) { @@ -1877,17 +1892,20 @@ func (s *httpdServer) handleClientShareLoginPost(w http.ResponseWriter, r *http. s.renderShareLoginPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials)) return } + next := path.Clean(r.URL.Query().Get("next")) + baseShareURL := path.Join(webClientPubSharesPath, share.ShareID) + isRedirect, redirectTo := checkShareRedirectURL(next, baseShareURL) c := jwtTokenClaims{ Username: shareID, } + if isRedirect { + c.Ref = next + } err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebShare, ipAddr) if err != nil { s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nError500Message)) return } - next := path.Clean(r.URL.Query().Get("next")) - baseShareURL := path.Join(webClientPubSharesPath, share.ShareID) - isRedirect, redirectTo := checkShareRedirectURL(next, baseShareURL) if isRedirect { http.Redirect(w, r, redirectTo, http.StatusFound) return @@ -1895,6 +1913,22 @@ func (s *httpdServer) handleClientShareLoginPost(w http.ResponseWriter, r *http. s.renderClientMessagePage(w, r, util.I18nSharedFilesTitle, http.StatusOK, nil, util.I18nShareLoginOK) } +func (s *httpdServer) handleClientShareLogout(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize) + + shareID := getURLParam(r, "id") + claims, err := s.getShareClaims(r, shareID) + if err != nil { + s.renderClientMessagePage(w, r, util.I18nShareAccessErrorTitle, http.StatusForbidden, + util.NewI18nError(err, util.I18nErrorInvalidToken), "") + return + } + removeCookie(w, r, webBaseClientPath) + + redirectURL := path.Join(webClientPubSharesPath, shareID, fmt.Sprintf("login?next=%s", url.QueryEscape(claims.Ref))) + http.Redirect(w, r, redirectURL, http.StatusFound) +} + func (s *httpdServer) handleClientSharedFile(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead} @@ -1906,7 +1940,7 @@ func (s *httpdServer) handleClientSharedFile(w http.ResponseWriter, r *http.Requ if r.URL.RawQuery != "" { query = "?" + r.URL.RawQuery } - s.renderShareDownloadPage(w, r, path.Join(webClientPubSharesPath, share.ShareID)+query) + s.renderShareDownloadPage(w, r, &share, path.Join(webClientPubSharesPath, share.ShareID)+query) } func (s *httpdServer) handleClientCheckExist(w http.ResponseWriter, r *http.Request) { diff --git a/templates/common/base.html b/templates/common/base.html index fc3dd3cc..15b369aa 100644 --- a/templates/common/base.html +++ b/templates/common/base.html @@ -845,6 +845,17 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). + {{- else if .IsLoggedToShare}} +
+
+ Logo +
+
+
+ {{- template "navitems" .}} +
+
+
{{- end}}
{{- if .LoggedUser.Username}} diff --git a/templates/webclient/base.html b/templates/webclient/base.html index 99122cdb..1280cd3e 100644 --- a/templates/webclient/base.html +++ b/templates/webclient/base.html @@ -26,6 +26,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).