WebClient shares: add a logout button

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2024-06-18 19:10:32 +02:00
parent 8bc08b25dc
commit 363770ab84
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
9 changed files with 155 additions and 60 deletions

View file

@ -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 { func (s *httpdServer) checkWebClientShareCredentials(w http.ResponseWriter, r *http.Request, share *dataprovider.Share) error {
doRedirect := func() { doRedirect := func() {
redirectURL := path.Join(webClientPubSharesPath, share.ShareID, fmt.Sprintf("login?next=%s", url.QueryEscape(r.RequestURI))) redirectURL := path.Join(webClientPubSharesPath, share.ShareID, fmt.Sprintf("login?next=%s", url.QueryEscape(r.RequestURI)))
http.Redirect(w, r, redirectURL, http.StatusFound) http.Redirect(w, r, redirectURL, http.StatusFound)
} }
token, err := jwtauth.VerifyRequest(s.tokenAuth, r, jwtauth.TokenFromCookie) if _, err := s.getShareClaims(r, share.ShareID); err != nil {
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)
doRedirect() doRedirect()
return err 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 return nil
} }

View file

@ -14431,6 +14431,30 @@ func TestWebClientShareCredentials(t *testing.T) {
req.RemoteAddr = "1.2.3.4:1234" req.RemoteAddr = "1.2.3.4:1234"
rr = executeRequest(req) rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr) 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 // try to login with invalid credentials
loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr) loginCookie, csrfToken, err = getCSRFTokenMock(webLoginPath, defaultRemoteAddr)
assert.NoError(t, err) assert.NoError(t, err)

View file

@ -1551,6 +1551,7 @@ func (s *httpdServer) setupWebClientRoutes() {
s.router.Get(webClientPubSharesPath+"/{id}/login", s.handleClientShareLoginGet) s.router.Get(webClientPubSharesPath+"/{id}/login", s.handleClientShareLoginGet)
s.router.With(jwtauth.Verify(s.csrfTokenAuth, jwtauth.TokenFromCookie)). s.router.With(jwtauth.Verify(s.csrfTokenAuth, jwtauth.TokenFromCookie)).
Post(webClientPubSharesPath+"/{id}/login", s.handleClientShareLoginPost) Post(webClientPubSharesPath+"/{id}/login", s.handleClientShareLoginPost)
s.router.Get(webClientPubSharesPath+"/{id}/logout", s.handleClientShareLogout)
s.router.Get(webClientPubSharesPath+"/{id}", s.downloadFromShare) s.router.Get(webClientPubSharesPath+"/{id}", s.downloadFromShare)
s.router.Post(webClientPubSharesPath+"/{id}/partial", s.handleClientSharePartialDownload) s.router.Post(webClientPubSharesPath+"/{id}/partial", s.handleClientSharePartialDownload)
s.router.Get(webClientPubSharesPath+"/{id}/browse", s.handleShareGetFiles) s.router.Get(webClientPubSharesPath+"/{id}/browse", s.handleShareGetFiles)

View file

@ -151,6 +151,7 @@ type basePage struct {
HasSearcher bool HasSearcher bool
HasExternalLogin bool HasExternalLogin bool
LoggedUser *dataprovider.Admin LoggedUser *dataprovider.Admin
IsLoggedToShare bool
Branding UIBranding Branding UIBranding
} }

View file

@ -81,21 +81,22 @@ func isZeroTime(t time.Time) bool {
type baseClientPage struct { type baseClientPage struct {
commonBasePage commonBasePage
Title string Title string
CurrentURL string CurrentURL string
FilesURL string FilesURL string
SharesURL string SharesURL string
ShareURL string ShareURL string
ProfileURL string ProfileURL string
PingURL string PingURL string
ChangePwdURL string ChangePwdURL string
LogoutURL string LogoutURL string
LoginURL string LoginURL string
EditURL string EditURL string
MFAURL string MFAURL string
CSRFToken string CSRFToken string
LoggedUser *dataprovider.User LoggedUser *dataprovider.User
Branding UIBranding IsLoggedToShare bool
Branding UIBranding
} }
type dirMapping struct { type dirMapping struct {
@ -530,21 +531,22 @@ func (s *httpdServer) getBaseClientPageData(title, currentURL string, w http.Res
} }
data := baseClientPage{ data := baseClientPage{
commonBasePage: getCommonBasePage(r), commonBasePage: getCommonBasePage(r),
Title: title, Title: title,
CurrentURL: currentURL, CurrentURL: currentURL,
FilesURL: webClientFilesPath, FilesURL: webClientFilesPath,
SharesURL: webClientSharesPath, SharesURL: webClientSharesPath,
ShareURL: webClientSharePath, ShareURL: webClientSharePath,
ProfileURL: webClientProfilePath, ProfileURL: webClientProfilePath,
PingURL: webClientPingPath, PingURL: webClientPingPath,
ChangePwdURL: webChangeClientPwdPath, ChangePwdURL: webChangeClientPwdPath,
LogoutURL: webClientLogoutPath, LogoutURL: webClientLogoutPath,
EditURL: webClientEditFilePath, EditURL: webClientEditFilePath,
MFAURL: webClientMFAPath, MFAURL: webClientMFAPath,
CSRFToken: csrfToken, CSRFToken: csrfToken,
LoggedUser: getUserFromToken(r), LoggedUser: getUserFromToken(r),
Branding: s.binding.Branding.WebClient, IsLoggedToShare: false,
Branding: s.binding.Branding.WebClient,
} }
if !strings.HasPrefix(r.RequestURI, webClientPubSharesPath) { if !strings.HasPrefix(r.RequestURI, webClientPubSharesPath) {
data.LoginURL = webClientLoginPath 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 := s.getBaseClientPageData(util.I18nSharedFilesTitle, currentURL, w, r)
baseData.FilesURL = currentURL baseData.FilesURL = currentURL
baseSharePath := path.Join(webClientPubSharesPath, share.ShareID) baseSharePath := path.Join(webClientPubSharesPath, share.ShareID)
baseData.LogoutURL = path.Join(webClientPubSharesPath, share.ShareID, "logout")
baseData.IsLoggedToShare = share.Password != ""
data := filesPage{ data := filesPage{
baseClientPage: baseData, baseClientPage: baseData,
@ -766,21 +770,32 @@ func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Reque
renderClientTemplate(w, templateClientFiles, data) 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{ data := shareDownloadPage{
baseClientPage: s.getBaseClientPageData(util.I18nShareDownloadTitle, "", w, r), baseClientPage: s.getBaseClientPageData(util.I18nShareDownloadTitle, "", w, r),
DownloadLink: downloadLink, DownloadLink: downloadLink,
} }
data.LogoutURL = ""
if share.Password != "" {
data.LogoutURL = path.Join(webClientPubSharesPath, share.ShareID, "logout")
}
renderClientTemplate(w, templateShareDownload, data) 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") currentURL := path.Join(webClientPubSharesPath, share.ShareID, "upload")
data := shareUploadPage{ data := shareUploadPage{
baseClientPage: s.getBaseClientPageData(util.I18nShareUploadTitle, currentURL, w, r), baseClientPage: s.getBaseClientPageData(util.I18nShareUploadTitle, currentURL, w, r),
Share: &share, Share: share,
UploadBasePath: path.Join(webClientPubSharesPath, share.ShareID), UploadBasePath: path.Join(webClientPubSharesPath, share.ShareID),
} }
data.LogoutURL = ""
if share.Password != "" {
data.LogoutURL = path.Join(webClientPubSharesPath, share.ShareID, "logout")
}
renderClientTemplate(w, templateUploadToShare, data) 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) http.Redirect(w, r, path.Join(webClientPubSharesPath, share.ShareID, "browse"), http.StatusFound)
return return
} }
s.renderUploadToSharePage(w, r, share) s.renderUploadToSharePage(w, r, &share)
} }
func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request) { 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)) s.renderShareLoginPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials))
return return
} }
next := path.Clean(r.URL.Query().Get("next"))
baseShareURL := path.Join(webClientPubSharesPath, share.ShareID)
isRedirect, redirectTo := checkShareRedirectURL(next, baseShareURL)
c := jwtTokenClaims{ c := jwtTokenClaims{
Username: shareID, Username: shareID,
} }
if isRedirect {
c.Ref = next
}
err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebShare, ipAddr) err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebShare, ipAddr)
if err != nil { if err != nil {
s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nError500Message)) s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nError500Message))
return return
} }
next := path.Clean(r.URL.Query().Get("next"))
baseShareURL := path.Join(webClientPubSharesPath, share.ShareID)
isRedirect, redirectTo := checkShareRedirectURL(next, baseShareURL)
if isRedirect { if isRedirect {
http.Redirect(w, r, redirectTo, http.StatusFound) http.Redirect(w, r, redirectTo, http.StatusFound)
return 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) 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) { func (s *httpdServer) handleClientSharedFile(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead} validScopes := []dataprovider.ShareScope{dataprovider.ShareScopeRead}
@ -1906,7 +1940,7 @@ func (s *httpdServer) handleClientSharedFile(w http.ResponseWriter, r *http.Requ
if r.URL.RawQuery != "" { if r.URL.RawQuery != "" {
query = "?" + 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) { func (s *httpdServer) handleClientCheckExist(w http.ResponseWriter, r *http.Request) {

View file

@ -845,6 +845,17 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
</div> </div>
</div> </div>
</div> </div>
{{- else if .IsLoggedToShare}}
<div class="app-container container-fluid d-flex mt-5">
<div class="d-flex align-items-center d-block ms-3">
<img alt="Logo" src="{{.StaticURL}}{{.Branding.LogoPath}}" class="h-50px" />
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-end">
{{- template "navitems" .}}
</div>
</div>
</div>
{{- end}} {{- end}}
<div class="{{- if .LoggedUser.Username}}app-wrapper{{- end}} flex-column flex-row-fluid " id="kt_app_wrapper"> <div class="{{- if .LoggedUser.Username}}app-wrapper{{- end}} flex-column flex-row-fluid " id="kt_app_wrapper">
{{- if .LoggedUser.Username}} {{- if .LoggedUser.Username}}

View file

@ -26,6 +26,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
</i> </i>
</div> </div>
<div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-title-gray-700 menu-icon-gray-500 menu-active-bg menu-state-color fw-semibold py-4 w-250px" data-kt-menu="true"> <div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-title-gray-700 menu-icon-gray-500 menu-active-bg menu-state-color fw-semibold py-4 w-250px" data-kt-menu="true">
{{- if not .IsLoggedToShare }}
<div class="menu-item px-3 my-0"> <div class="menu-item px-3 my-0">
<div class="menu-content d-flex align-items-center px-3 py-2"> <div class="menu-content d-flex align-items-center px-3 py-2">
<div class="me-5"> <div class="me-5">
@ -54,6 +55,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
</a> </a>
</div> </div>
{{- end}} {{- end}}
{{- end}}
<div class="menu-item px-3 my-0"> <div class="menu-item px-3 my-0">
<a id="id_logout_link" href="#" class="menu-link px-3 py-2"> <a id="id_logout_link" href="#" class="menu-link px-3 py-2">
<span data-i18n="login.signout" class="menu-title">Sign out</span> <span data-i18n="login.signout" class="menu-title">Sign out</span>

View file

@ -25,9 +25,16 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
{{.Branding.ShortName}} {{.Branding.ShortName}}
</span> </span>
</div> </div>
<div class="card shadow-sm w-lg-600px"> <div class="card shadow-sm w-lg-600px w-md-450px">
<div class="card-header bg-light"> <div class="card-header bg-light">
<h3 data-i18n="fs.download_ready" class="card-title section-title">Your download is ready</h3> <h3 data-i18n="fs.download_ready" class="card-title section-title">Your download is ready</h3>
{{- if .LogoutURL}}
<div class="card-toolbar">
<a id="id_logout_link" href="#" class="btn btn-light-primary">
<span data-i18n="login.signout">Sign out</span>
</a>
</div>
{{- end}}
</div> </div>
<div class="card-body"> <div class="card-body">
<div> <div>

View file

@ -25,9 +25,16 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
{{.Branding.ShortName}} {{.Branding.ShortName}}
</span> </span>
</div> </div>
<div class="card shadow-sm w-lg-600px"> <div class="card shadow-sm w-lg-600px w-md-450px">
<div class="card-header bg-light"> <div class="card-header bg-light">
<h3 data-i18n="title.upload_to_share" class="card-title section-title">Upload one or more files to share</h3> <h3 data-i18n="title.upload_to_share" class="card-title section-title">Upload one or more files to share</h3>
{{- if .LogoutURL}}
<div class="card-toolbar">
<a id="id_logout_link" href="#" class="btn btn-light-primary">
<span data-i18n="login.signout">Sign out</span>
</a>
</div>
{{- end}}
</div> </div>
<div class="card-body"> <div class="card-body">
{{- template "errmsg" ""}} {{- template "errmsg" ""}}