- Refactory cookie and API session handle. Insolated y routes and controllers (api-auth.go, app-auth.go).

- Cookie lifetime with sliding expiration
- Fix bug single global expiresAt in JWT, now each token has unique ExpiresAt
This commit is contained in:
Zeni Kim 2026-09-12 21:26:31 -05:00
parent ab7c00575c
commit 0e20a17ce9
5 changed files with 241 additions and 86 deletions

View file

@ -5,9 +5,12 @@
package core
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"os"
"strconv"
"sync"
"time"
)
@ -37,7 +40,16 @@ func (s *SessionUser) Init(c *Context) bool {
return false
}
payload, err := c.GetJWT().DecodeToken(usercookie.Token)
// The request cookie is immutable, so if the session was already renewed earlier
// in this same request, the request still carries the previous token. Use the
// token that was renewed during this request (if any) so multiple Init calls in
// the same request remain consistent.
currentToken := usercookie.Token
if renewed := renewedTokenForRequest(c); renewed != "" {
currentToken = renewed
}
payload, err := c.GetJWT().DecodeToken(currentToken)
if err != nil {
return false
}
@ -48,7 +60,7 @@ func (s *SessionUser) Init(c *Context) bool {
// verify token against cached value
hashedCacheKey := CreateAuthTokenHashedCacheKey(userID, userAgent)
cachedToken, err := c.GetCache().Get(hashedCacheKey)
if err != nil || cachedToken != usercookie.Token {
if err != nil || cachedToken != currentToken {
return false
}
@ -65,9 +77,111 @@ func (s *SessionUser) Init(c *Context) bool {
_ = json.Unmarshal([]byte(value), &s.values)
}
// sliding expiration: transparently renew the cookie + JWT if enough time elapsed
s.maybeRenew(c, usercookie.Email, userID, hashedCacheKey, currentToken)
return true
}
// slidingRenewedTokenContextKey is the request-context key under which the token
// renewed during the current request is stored. This keeps the sliding session
// logic consistent if Init is called more than once within the same request.
const slidingRenewedTokenContextKey = "goffeeSlidingRenewedToken"
// renewedTokenForRequest returns the token that was issued by sliding renewal during
// the current request, or an empty string if no renewal happened yet.
func renewedTokenForRequest(c *Context) string {
if c.Request == nil || c.Request.httpRequest == nil {
return ""
}
if token, ok := c.Request.httpRequest.Context().Value(slidingRenewedTokenContextKey).(string); ok {
return token
}
return ""
}
// markRenewedTokenForRequest stores the token issued by sliding renewal in the request
// context so subsequent Init calls within the same request can use it.
func markRenewedTokenForRequest(c *Context, token string) {
if c.Request == nil || c.Request.httpRequest == nil {
return
}
ctx := context.WithValue(c.Request.httpRequest.Context(), slidingRenewedTokenContextKey, token)
*c.Request.httpRequest = *c.Request.httpRequest.WithContext(ctx)
}
// slidingRenewThresholdPercent is the fraction of the token lifetime that must
// elapse before a web session cookie is renewed (sliding expiration).
// Renewing only past this percentage keeps the renewal "moderate" — avoiding a
// cookie/JWT write on every single request.
const slidingRenewThresholdPercent = 0.25
// maybeRenew performs the sliding expiration renewal for cookie/template based sessions.
// It reads the token expiration without validating it (at this point the token has already
// been verified as valid and matched against the cache), derives the issuance moment and,
// if more than slidingRenewThresholdPercent of the lifetime has elapsed, it issues a brand
// new JWT (with a fresh expiration) and rewrites both the cookie and the cached token.
//
// Renewal errors are logged and ignored: a renewal failure must never break the current
// request nor invalidate an otherwise valid session.
func (s *SessionUser) maybeRenew(c *Context, email string, userID uint, hashedCacheKey string, currentToken string) {
// Sliding renewal only applies to cookie/template based sessions.
// Without templates there is no cookie session to slide.
if !templateEngineEnabled() {
return
}
jwtObj := c.GetJWT()
lifetimeMinutes := jwtObj.LifetimeMinutes()
if lifetimeMinutes <= 0 {
return
}
// Read the token expiration without validating it. The token may be close to
// expiring but is still valid (it was decoded successfully above).
expiresAt, err := jwtObj.ExpiresAtIgnoreExpiry(currentToken)
if err != nil {
c.GetLogger().Error(fmt.Sprintf("sliding session: error reading token expiration: %v", err))
return
}
// Tokens are issued with exp = issuedAt + lifetime, so the issuance moment can
// be derived from the expiration carried by the token.
issuedAt := expiresAt.Add(-time.Duration(lifetimeMinutes) * time.Minute)
elapsed := time.Since(issuedAt)
threshold := time.Duration(float64(lifetimeMinutes)*slidingRenewThresholdPercent) * time.Minute
if elapsed < threshold {
// Not enough time has elapsed yet — skip renewal to keep it moderate.
return
}
// Issue a fresh JWT and refresh the cached token.
newToken, err := jwtObj.GenerateToken(map[string]interface{}{
"userID": userID,
})
if err != nil {
c.GetLogger().Error(fmt.Sprintf("sliding session: error generating new token: %v", err))
return
}
if err := c.GetCache().Set(hashedCacheKey, newToken); err != nil {
c.GetLogger().Error(fmt.Sprintf("sliding session: error caching renewed token: %v", err))
return
}
// Refresh the cookie with a full new lifetime.
maxAgeSeconds := lifetimeMinutes * 60
if err := SetCookieWithMaxAge(c.Response.HttpResponseWriter, email, newToken, maxAgeSeconds); err != nil {
c.GetLogger().Error(fmt.Sprintf("sliding session: error writing renewed cookie: %v", err))
return
}
// Remember the renewed token for the rest of this request so that any further
// Init call in the same request validates against the renewed token.
markRenewedTokenForRequest(c, newToken)
}
// Set stores a value in the session and persists it to the cache.
func (s *SessionUser) Set(key string, value interface{}) error {
s.mu.Lock()
@ -144,3 +258,14 @@ func CreateAuthTokenHashedCacheKey(userID uint, userAgent string) string {
cacheKey := fmt.Sprintf("userid:_%v_useragent:_%v_jwt_token", userID, userAgent)
return fmt.Sprintf("%x", md5.Sum([]byte(cacheKey)))
}
// templateEngineEnabled reports whether the template (cookie based) engine is enabled.
// Sliding session renewal only applies to cookie/template based sessions.
func templateEngineEnabled() bool {
templateEnableStr := os.Getenv("TEMPLATE_ENABLE")
if templateEnableStr == "" {
return false
}
enabled, _ := strconv.ParseBool(templateEnableStr)
return enabled
}