diff --git a/internal/config/config.go b/internal/config/config.go index 81eeff9e..cf7aa871 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -403,6 +403,9 @@ func Init() { SigningPassphrase: "", SigningPassphraseFile: "", TokenValidation: 0, + CookieLifetime: 20, + ShareCookieLifetime: 120, + JWTLifetime: 20, MaxUploadFileSize: 0, Cors: httpd.CorsConfig{ Enabled: false, @@ -2148,6 +2151,9 @@ func setViperDefaults() { viper.SetDefault("httpd.signing_passphrase", globalConf.HTTPDConfig.SigningPassphrase) viper.SetDefault("httpd.signing_passphrase_file", globalConf.HTTPDConfig.SigningPassphraseFile) viper.SetDefault("httpd.token_validation", globalConf.HTTPDConfig.TokenValidation) + viper.SetDefault("httpd.cookie_lifetime", globalConf.HTTPDConfig.CookieLifetime) + viper.SetDefault("httpd.share_cookie_lifetime", globalConf.HTTPDConfig.ShareCookieLifetime) + viper.SetDefault("httpd.jwt_lifetime", globalConf.HTTPDConfig.JWTLifetime) viper.SetDefault("httpd.max_upload_file_size", globalConf.HTTPDConfig.MaxUploadFileSize) viper.SetDefault("httpd.cors.enabled", globalConf.HTTPDConfig.Cors.Enabled) viper.SetDefault("httpd.cors.allowed_origins", globalConf.HTTPDConfig.Cors.AllowedOrigins) diff --git a/internal/dataprovider/node.go b/internal/dataprovider/node.go index d35370bc..8e40c480 100644 --- a/internal/dataprovider/node.go +++ b/internal/dataprovider/node.go @@ -184,6 +184,7 @@ func (n *Node) generateAuthToken(username, role string) (string, error) { t := jwt.New() t.Set("admin", username) //nolint:errcheck t.Set("role", role) //nolint:errcheck + t.Set(jwt.IssuedAtKey, now) //nolint:errcheck t.Set(jwt.JwtIDKey, xid.New().String()) //nolint:errcheck t.Set(jwt.NotBeforeKey, now.Add(-30*time.Second)) //nolint:errcheck t.Set(jwt.ExpirationKey, now.Add(1*time.Minute)) //nolint:errcheck diff --git a/internal/httpd/api_admin.go b/internal/httpd/api_admin.go index 5a7b0afe..2a8c7ec6 100644 --- a/internal/httpd/api_admin.go +++ b/internal/httpd/api_admin.go @@ -297,7 +297,7 @@ func changeAdminPassword(w http.ResponseWriter, r *http.Request) { sendAPIResponse(w, r, err, "", getRespStatus(err)) return } - invalidateToken(r, false) + invalidateToken(r) sendAPIResponse(w, r, err, "Password updated", http.StatusOK) } diff --git a/internal/httpd/api_http_user.go b/internal/httpd/api_http_user.go index fcd02fb6..0c2875fd 100644 --- a/internal/httpd/api_http_user.go +++ b/internal/httpd/api_http_user.go @@ -540,7 +540,7 @@ func changeUserPassword(w http.ResponseWriter, r *http.Request) { sendAPIResponse(w, r, err, "", getRespStatus(err)) return } - invalidateToken(r, false) + invalidateToken(r) sendAPIResponse(w, r, err, "Password updated", http.StatusOK) } diff --git a/internal/httpd/api_shares.go b/internal/httpd/api_shares.go index 6594d59d..7539bc90 100644 --- a/internal/httpd/api_shares.go +++ b/internal/httpd/api_shares.go @@ -432,31 +432,31 @@ func (s *httpdServer) uploadFilesToShare(w http.ResponseWriter, r *http.Request) } } -func (s *httpdServer) getShareClaims(r *http.Request, shareID string) (*jwtTokenClaims, error) { +func (s *httpdServer) getShareClaims(r *http.Request, shareID string) (context.Context, *jwtTokenClaims, error) { token, err := jwtauth.VerifyRequest(s.tokenAuth, r, jwtauth.TokenFromCookie) if err != nil || token == nil { - return nil, errInvalidToken + return nil, nil, errInvalidToken } tokenString := jwtauth.TokenFromCookie(r) if tokenString == "" || invalidatedJWTTokens.Get(tokenString) { - return nil, errInvalidToken + return nil, nil, errInvalidToken } if !slices.Contains(token.Audience(), tokenAudienceWebShare) { logger.Debug(logSender, "", "invalid token audience for share %q", shareID) - return nil, errInvalidToken + return nil, 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 + return nil, 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 nil, nil, errInvalidToken } - return &claims, nil + return ctx, &claims, nil } func (s *httpdServer) checkWebClientShareCredentials(w http.ResponseWriter, r *http.Request, share *dataprovider.Share) error { @@ -465,7 +465,7 @@ func (s *httpdServer) checkWebClientShareCredentials(w http.ResponseWriter, r *h http.Redirect(w, r, redirectURL, http.StatusFound) } - if _, err := s.getShareClaims(r, share.ShareID); err != nil { + if _, _, err := s.getShareClaims(r, share.ShareID); err != nil { doRedirect() return err } diff --git a/internal/httpd/auth_utils.go b/internal/httpd/auth_utils.go index 7cbdfc7d..961865c5 100644 --- a/internal/httpd/auth_utils.go +++ b/internal/httpd/auth_utils.go @@ -67,15 +67,68 @@ const ( ) var ( - tokenDuration = 20 * time.Minute - shareTokenDuration = 2 * time.Hour + apiTokenDuration = 20 * time.Minute + cookieTokenDuration = 20 * time.Minute + shareTokenDuration = 2 * time.Hour // csrf token duration is greater than normal token duration to reduce issues // with the login form - csrfTokenDuration = 4 * time.Hour - tokenRefreshThreshold = 10 * time.Minute - tokenValidationMode = tokenValidationModeDefault + csrfTokenDuration = 4 * time.Hour + cookieRefreshThreshold = 10 * time.Minute + maxTokenDuration = 12 * time.Hour + tokenValidationMode = tokenValidationModeDefault ) +func isTokenDurationValid(minutes int) bool { + return minutes >= 1 && minutes <= 720 +} + +func updateTokensDuration(api, cookie, share int) { + if isTokenDurationValid(api) { + apiTokenDuration = time.Duration(api) * time.Minute + } + if isTokenDurationValid(cookie) { + cookieTokenDuration = time.Duration(cookie) * time.Minute + cookieRefreshThreshold = cookieTokenDuration / 2 + if cookieTokenDuration > csrfTokenDuration { + csrfTokenDuration = cookieTokenDuration + } + } + if isTokenDurationValid(share) { + shareTokenDuration = time.Duration(share) * time.Minute + } + logger.Debug(logSender, "", "API token duration %s, cookie token duration %s, cookie refresh threshold %s, share token duration %s", + apiTokenDuration, cookieTokenDuration, cookieRefreshThreshold, shareTokenDuration) +} + +func getTokenDuration(audience tokenAudience) time.Duration { + switch audience { + case tokenAudienceWebShare: + return shareTokenDuration + case tokenAudienceWebLogin, tokenAudienceCSRF: + return csrfTokenDuration + case tokenAudienceAPI, tokenAudienceAPIUser: + return apiTokenDuration + case tokenAudienceWebAdmin, tokenAudienceWebClient: + return cookieTokenDuration + case tokenAudienceWebAdminPartial, tokenAudienceWebClientPartial, tokenAudienceOAuth2: + return 5 * time.Minute + default: + logger.Error(logSender, "", "token duration not handled for audience: %q", audience) + return 20 * time.Minute + } +} + +func getMaxCookieDuration() time.Duration { + result := csrfTokenDuration + if shareTokenDuration > result { + result = shareTokenDuration + } + if cookieTokenDuration > result { + result = cookieTokenDuration + } + return result +} + type jwtTokenClaims struct { Username string Permissions []string @@ -89,6 +142,7 @@ type jwtTokenClaims struct { RequiredTwoFactorProtocols []string HideUserPageSections int JwtID string + JwtIssuedAt time.Time Ref string } @@ -110,6 +164,9 @@ func (c *jwtTokenClaims) asMap() map[string]any { if c.JwtID != "" { claims[jwt.JwtIDKey] = c.JwtID } + if !c.JwtIssuedAt.IsZero() { + claims[jwt.IssuedAtKey] = c.JwtIssuedAt + } if c.Ref != "" { claims[claimRef] = c.Ref } @@ -241,12 +298,11 @@ func (c *jwtTokenClaims) createToken(tokenAuth *jwtauth.JWTAuth, audience tokenA if _, ok := claims[jwt.JwtIDKey]; !ok { claims[jwt.JwtIDKey] = xid.New().String() } - claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second) - if audience == tokenAudienceWebLogin { - claims[jwt.ExpirationKey] = now.Add(csrfTokenDuration) - } else { - claims[jwt.ExpirationKey] = now.Add(tokenDuration) + if _, ok := claims[jwt.IssuedAtKey]; !ok { + claims[jwt.IssuedAtKey] = now } + claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second) + claims[jwt.ExpirationKey] = now.Add(getTokenDuration(audience)) claims[jwt.AudienceKey] = []string{audience, ip} return tokenAuth.Encode(claims) @@ -278,11 +334,7 @@ func (c *jwtTokenClaims) createAndSetCookie(w http.ResponseWriter, r *http.Reque } else { basePath = webBaseClientPath } - duration := tokenDuration - if audience == tokenAudienceWebShare { - duration = shareTokenDuration - } - setCookie(w, r, basePath, resp["access_token"].(string), duration) + setCookie(w, r, basePath, resp["access_token"].(string), getTokenDuration(audience)) return nil } @@ -301,6 +353,7 @@ func setCookie(w http.ResponseWriter, r *http.Request, cookiePath, cookieValue s } func removeCookie(w http.ResponseWriter, r *http.Request, cookiePath string) { + invalidateToken(r) http.SetCookie(w, &http.Cookie{ Name: jwtCookieKey, Value: "", @@ -312,7 +365,6 @@ func removeCookie(w http.ResponseWriter, r *http.Request, cookiePath string) { SameSite: http.SameSiteStrictMode, }) w.Header().Add("Cache-Control", `no-cache="Set-Cookie"`) - invalidateToken(r, false) } func oidcTokenFromContext(r *http.Request) string { @@ -352,21 +404,26 @@ func isTokenInvalidated(r *http.Request) bool { return !isTokenFound } -func invalidateToken(r *http.Request, isLoginToken bool) { - duration := tokenDuration - if isLoginToken { - duration = csrfTokenDuration - } +func invalidateToken(r *http.Request) { tokenString := jwtauth.TokenFromHeader(r) if tokenString != "" { - invalidatedJWTTokens.Add(tokenString, time.Now().Add(duration).UTC()) + invalidateTokenString(r, tokenString, apiTokenDuration) } tokenString = jwtauth.TokenFromCookie(r) if tokenString != "" { - invalidatedJWTTokens.Add(tokenString, time.Now().Add(duration).UTC()) + invalidateTokenString(r, tokenString, getMaxCookieDuration()) } } +func invalidateTokenString(r *http.Request, tokenString string, fallbackDuration time.Duration) { + token, _, err := jwtauth.FromContext(r.Context()) + if err != nil || token == nil { + invalidatedJWTTokens.Add(tokenString, time.Now().Add(fallbackDuration).UTC()) + return + } + invalidatedJWTTokens.Add(tokenString, token.Expiration().Add(1*time.Minute).UTC()) +} + func getUserFromToken(r *http.Request) *dataprovider.User { user := &dataprovider.User{} _, claims, err := jwtauth.FromContext(r.Context()) @@ -416,6 +473,7 @@ func createCSRFToken(w http.ResponseWriter, r *http.Request, csrfTokenAuth *jwta now := time.Now().UTC() claims[jwt.JwtIDKey] = xid.New().String() + claims[jwt.IssuedAtKey] = now claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second) claims[jwt.ExpirationKey] = now.Add(csrfTokenDuration) claims[jwt.AudienceKey] = []string{tokenAudienceCSRF, ip} @@ -512,8 +570,9 @@ func createOAuth2Token(csrfTokenAuth *jwtauth.JWTAuth, state, ip string) string now := time.Now().UTC() claims[jwt.JwtIDKey] = state + claims[jwt.IssuedAtKey] = now claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second) - claims[jwt.ExpirationKey] = now.Add(3 * time.Minute) + claims[jwt.ExpirationKey] = now.Add(getTokenDuration(tokenAudienceOAuth2)) claims[jwt.AudienceKey] = []string{tokenAudienceOAuth2, ip} _, tokenString, err := csrfTokenAuth.Encode(claims) @@ -577,7 +636,7 @@ func checkTokenSignature(r *http.Request, token jwt.Token) error { } } if err != nil { - invalidateToken(r, false) + invalidateToken(r) } return err } diff --git a/internal/httpd/httpd.go b/internal/httpd/httpd.go index 4cec3a43..dfdb1863 100644 --- a/internal/httpd/httpd.go +++ b/internal/httpd/httpd.go @@ -859,6 +859,12 @@ type Conf struct { // By default all the available security checks are enabled. Set to 1 to disable the requirement // that a token must be used by the same IP for which it was issued. TokenValidation int `json:"token_validation" mapstructure:"token_validation"` + // CookieLifetime defines the duration of cookies for WebAdmin and WebClient + CookieLifetime int `json:"cookie_lifetime" mapstructure:"cookie_lifetime"` + // ShareCookieLifetime defines the duration of cookies for public shares + ShareCookieLifetime int `json:"share_cookie_lifetime" mapstructure:"share_cookie_lifetime"` + // JWTLifetime defines the duration of JWT tokens used in REST API + JWTLifetime int `json:"jwt_lifetime" mapstructure:"jwt_lifetime"` // MaxUploadFileSize Defines the maximum request body size, in bytes, for Web Client/API HTTP upload requests. // 0 means no limit MaxUploadFileSize int64 `json:"max_upload_file_size" mapstructure:"max_upload_file_size"` @@ -1095,7 +1101,8 @@ func (c *Conf) Initialize(configDir string, isShared int) error { maxUploadFileSize = c.MaxUploadFileSize installationCode = c.Setup.InstallationCode installationCodeHint = c.Setup.InstallationCodeHint - startCleanupTicker(tokenDuration / 2) + updateTokensDuration(c.JWTLifetime, c.CookieLifetime, c.ShareCookieLifetime) + startCleanupTicker(10 * time.Minute) c.setTokenValidationMode() return <-exitChannel } diff --git a/internal/httpd/internal_test.go b/internal/httpd/internal_test.go index 8bfd7d88..78d98f18 100644 --- a/internal/httpd/internal_test.go +++ b/internal/httpd/internal_test.go @@ -424,6 +424,25 @@ func TestBrandingInvalidFormFile(t *testing.T) { assert.EqualError(t, err, http.ErrNotMultipart.Error()) } +func TestTokenDuration(t *testing.T) { + assert.Equal(t, shareTokenDuration, getTokenDuration(tokenAudienceWebShare)) + assert.Equal(t, apiTokenDuration, getTokenDuration(tokenAudienceAPI)) + assert.Equal(t, apiTokenDuration, getTokenDuration(tokenAudienceAPIUser)) + assert.Equal(t, cookieTokenDuration, getTokenDuration(tokenAudienceWebAdmin)) + assert.Equal(t, csrfTokenDuration, getTokenDuration(tokenAudienceCSRF)) + assert.Equal(t, 20*time.Minute, getTokenDuration("")) + + updateTokensDuration(30, 660, 360) + assert.Equal(t, 30*time.Minute, apiTokenDuration) + assert.Equal(t, 11*time.Hour, cookieTokenDuration) + assert.Equal(t, 11*time.Hour, csrfTokenDuration) + assert.Equal(t, 6*time.Hour, shareTokenDuration) + assert.Equal(t, 11*time.Hour, getMaxCookieDuration()) + + csrfTokenDuration = 1 * time.Hour + assert.Equal(t, 11*time.Hour, getMaxCookieDuration()) +} + func TestVerifyCSRFToken(t *testing.T) { server := httpdServer{} server.initializeRouter() @@ -1270,7 +1289,7 @@ func TestOAuth2Token(t *testing.T) { claims[jwt.JwtIDKey] = xid.New().String() claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second) - claims[jwt.ExpirationKey] = now.Add(tokenDuration) + claims[jwt.ExpirationKey] = now.Add(getTokenDuration(tokenAudienceAPI)) claims[jwt.AudienceKey] = []string{tokenAudienceAPI} _, tokenString, err := server.csrfTokenAuth.Encode(claims) @@ -1295,7 +1314,7 @@ func TestOAuth2Token(t *testing.T) { claims = make(map[string]any) claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second) - claims[jwt.ExpirationKey] = now.Add(tokenDuration) + claims[jwt.ExpirationKey] = now.Add(getTokenDuration(tokenAudienceOAuth2)) claims[jwt.AudienceKey] = []string{tokenAudienceOAuth2, "127.1.1.4"} _, tokenString, err = server.csrfTokenAuth.Encode(claims) assert.NoError(t, err) @@ -1335,7 +1354,7 @@ func TestCSRFToken(t *testing.T) { claims[jwt.JwtIDKey] = xid.New().String() claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second) - claims[jwt.ExpirationKey] = now.Add(tokenDuration) + claims[jwt.ExpirationKey] = now.Add(getTokenDuration(tokenAudienceAPI)) claims[jwt.AudienceKey] = []string{tokenAudienceAPI} _, tokenString, err := server.csrfTokenAuth.Encode(claims) @@ -1361,7 +1380,7 @@ func TestCSRFToken(t *testing.T) { claims[jwt.JwtIDKey] = xid.New().String() claims[jwt.NotBeforeKey] = now.Add(-30 * time.Second) - claims[jwt.ExpirationKey] = now.Add(tokenDuration) + claims[jwt.ExpirationKey] = now.Add(getTokenDuration(tokenAudienceAPI)) claims[jwt.AudienceKey] = []string{tokenAudienceAPI} _, tokenString, err = server.csrfTokenAuth.Encode(claims) assert.NoError(t, err) @@ -1933,6 +1952,7 @@ func TestCookieExpiration(t *testing.T) { claims[claimUsernameKey] = admin.Username claims[claimPermissionsKey] = admin.Permissions claims[jwt.JwtIDKey] = tokenID + claims[jwt.IssuedAtKey] = time.Now() claims[jwt.SubjectKey] = admin.GetSignature() claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute) claims[jwt.AudienceKey] = []string{tokenAudienceAPI} @@ -1977,6 +1997,7 @@ func TestCookieExpiration(t *testing.T) { claims[claimUsernameKey] = user.Username claims[claimPermissionsKey] = user.Filters.WebClient claims[jwt.JwtIDKey] = tokenID + claims[jwt.IssuedAtKey] = time.Now() claims[jwt.SubjectKey] = user.GetSignature() claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute) claims[jwt.AudienceKey] = []string{tokenAudienceWebClient} @@ -2006,12 +2027,15 @@ func TestCookieExpiration(t *testing.T) { user, err = dataprovider.UserExists(user.Username, "") assert.NoError(t, err) + issuedAt := time.Now().Add(-1 * time.Minute) + expiresAt := time.Now().Add(1 * time.Minute) claims = make(map[string]any) claims[claimUsernameKey] = user.Username claims[claimPermissionsKey] = user.Filters.WebClient claims[jwt.JwtIDKey] = tokenID + claims[jwt.IssuedAtKey] = issuedAt claims[jwt.SubjectKey] = user.GetSignature() - claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute) + claims[jwt.ExpirationKey] = expiresAt claims[jwt.AudienceKey] = []string{tokenAudienceWebClient} token, _, err = server.tokenAuth.Encode(claims) assert.NoError(t, err) @@ -2033,7 +2057,21 @@ func TestCookieExpiration(t *testing.T) { token, err = jwtauth.VerifyRequest(server.tokenAuth, req, jwtauth.TokenFromCookie) if assert.NoError(t, err) { assert.Equal(t, tokenID, token.JwtID()) + assert.Equal(t, issuedAt.Unix(), token.IssuedAt().Unix()) + assert.NotEqual(t, expiresAt.Unix(), token.Expiration().Unix()) } + // test a cookie issued more that 12 hours ago + claims[jwt.IssuedAtKey] = time.Now().Add(-24 * time.Hour) + token, _, err = server.tokenAuth.Encode(claims) + assert.NoError(t, err) + + rr = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, webClientFilesPath, nil) + req.RemoteAddr = "172.16.4.16:6789" + ctx = jwtauth.NewContext(req.Context(), token, nil) + server.checkCookieExpiration(rr, req.WithContext(ctx)) + cookie = rr.Header().Get("Set-Cookie") + assert.Empty(t, cookie) // test a disabled user user.Status = 0 @@ -2046,6 +2084,7 @@ func TestCookieExpiration(t *testing.T) { claims[claimUsernameKey] = user.Username claims[claimPermissionsKey] = user.Filters.WebClient claims[jwt.JwtIDKey] = tokenID + claims[jwt.IssuedAtKey] = issuedAt claims[jwt.SubjectKey] = user.GetSignature() claims[jwt.ExpirationKey] = time.Now().Add(1 * time.Minute) claims[jwt.AudienceKey] = []string{tokenAudienceWebClient} @@ -2321,12 +2360,17 @@ func TestJWTTokenCleanup(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, versionPath, nil) assert.True(t, isTokenInvalidated(req)) + fakeToken := "abc" + invalidateTokenString(req, fakeToken, -100*time.Millisecond) + assert.True(t, invalidatedJWTTokens.Get(fakeToken)) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token)) - invalidatedJWTTokens.Add(token, time.Now().Add(-tokenDuration).UTC()) + invalidatedJWTTokens.Add(token, time.Now().Add(-getTokenDuration(tokenAudienceWebAdmin)).UTC()) require.True(t, isTokenInvalidated(req)) startCleanupTicker(100 * time.Millisecond) assert.Eventually(t, func() bool { return !isTokenInvalidated(req) }, 1*time.Second, 200*time.Millisecond) + assert.False(t, invalidatedJWTTokens.Get(fakeToken)) stopCleanupTicker() } @@ -2339,13 +2383,13 @@ func TestDbTokenManager(t *testing.T) { testToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiV2ViQWRtaW4iLCI6OjEiXSwiZXhwIjoxNjk4NjYwMDM4LCJqdGkiOiJja3ZuazVrYjF1aHUzZXRmZmhyZyIsIm5iZiI6MTY5ODY1ODgwOCwicGVybWlzc2lvbnMiOlsiKiJdLCJzdWIiOiIxNjk3ODIwNDM3NTMyIiwidXNlcm5hbWUiOiJhZG1pbiJ9.LXuFFksvnSuzHqHat6r70yR0jEulNRju7m7SaWrOfy8; csrftoken=mP0C7DqjwpAXsptO2gGCaYBkYw3oNMWB" key := dbTokenManager.getKey(testToken) require.Len(t, key, 64) - dbTokenManager.Add(testToken, time.Now().Add(-tokenDuration).UTC()) + dbTokenManager.Add(testToken, time.Now().Add(-getTokenDuration(tokenAudienceWebClient)).UTC()) isInvalidated := dbTokenManager.Get(testToken) assert.True(t, isInvalidated) dbTokenManager.Cleanup() isInvalidated = dbTokenManager.Get(testToken) assert.False(t, isInvalidated) - dbTokenManager.Add(testToken, time.Now().Add(tokenDuration).UTC()) + dbTokenManager.Add(testToken, time.Now().Add(getTokenDuration(tokenAudienceWebAdmin)).UTC()) isInvalidated = dbTokenManager.Get(testToken) assert.True(t, isInvalidated) dbTokenManager.Cleanup() diff --git a/internal/httpd/server.go b/internal/httpd/server.go index ae107723..51962844 100644 --- a/internal/httpd/server.go +++ b/internal/httpd/server.go @@ -758,7 +758,7 @@ func (s *httpdServer) loginUser( errorFunc(w, r, util.NewI18nError(err, util.I18nError500Message)) return } - invalidateToken(r, !isSecondFactorAuth) + invalidateToken(r) if audience == tokenAudienceWebClientPartial { redirectPath := webClientTwoFactorPath if next := r.URL.Query().Get("next"); strings.HasPrefix(next, webClientFilesPath) { @@ -806,7 +806,7 @@ func (s *httpdServer) loginAdmin( errorFunc(w, r, util.NewI18nError(err, util.I18nError500Message)) return } - invalidateToken(r, !isSecondFactorAuth) + invalidateToken(r) if audience == tokenAudienceWebAdminPartial { http.Redirect(w, r, webAdminTwoFactorPath, http.StatusFound) return @@ -822,7 +822,7 @@ func (s *httpdServer) loginAdmin( func (s *httpdServer) logout(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize) - invalidateToken(r, false) + invalidateToken(r) sendAPIResponse(w, r, nil, "Your token has been invalidated", http.StatusOK) } @@ -1009,9 +1009,13 @@ func (s *httpdServer) checkCookieExpiration(w http.ResponseWriter, r *http.Reque if tokenClaims.Username == "" || tokenClaims.Signature == "" { return } - if time.Until(token.Expiration()) > tokenRefreshThreshold { + if time.Until(token.Expiration()) > cookieRefreshThreshold { return } + if (time.Since(token.IssuedAt()) + cookieTokenDuration) > maxTokenDuration { + return + } + tokenClaims.JwtIssuedAt = token.IssuedAt() if slices.Contains(token.Audience(), tokenAudienceWebClient) { s.refreshClientToken(w, r, &tokenClaims) } else { diff --git a/internal/httpd/webclient.go b/internal/httpd/webclient.go index ba7301b9..5cb58ac8 100644 --- a/internal/httpd/webclient.go +++ b/internal/httpd/webclient.go @@ -1894,7 +1894,7 @@ func (s *httpdServer) handleClientShareLoginPost(w http.ResponseWriter, r *http. s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF)) return } - invalidateToken(r, true) + invalidateToken(r) shareID := getURLParam(r, "id") share, err := dataprovider.ShareExists(shareID, "") if err != nil { @@ -1931,13 +1931,13 @@ func (s *httpdServer) handleClientShareLogout(w http.ResponseWriter, r *http.Req r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize) shareID := getURLParam(r, "id") - claims, err := s.getShareClaims(r, shareID) + ctx, 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) + removeCookie(w, r.WithContext(ctx), webBaseClientPath) redirectURL := path.Join(webClientPubSharesPath, shareID, fmt.Sprintf("login?next=%s", url.QueryEscape(claims.Ref))) http.Redirect(w, r, redirectURL, http.StatusFound) diff --git a/sftpgo.json b/sftpgo.json index fb2508e2..a5c6e2c8 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -355,6 +355,9 @@ "signing_passphrase": "", "signing_passphrase_file": "", "token_validation": 0, + "cookie_lifetime": 20, + "share_cookie_lifetime": 120, + "jwt_lifetime": 20, "max_upload_file_size": 0, "cors": { "enabled": false,