- Cookie lifetime with sliding expiration - Fix bug single global expiresAt in JWT, now each token has unique ExpiresAt
271 lines
8.9 KiB
Go
271 lines
8.9 KiB
Go
// Copyright (c) 2026 Zeni Kim <zenik@smarteching.com>
|
|
// Use of this source code is governed by MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package core
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// SessionUser handles user session data management with thread-safe operations.
|
|
// Sessions are stored in the cache (Redis) and are tied to an authenticated user
|
|
// via JWT tokens stored in cookies.
|
|
type SessionUser struct {
|
|
mu sync.RWMutex
|
|
context *Context
|
|
userID uint
|
|
hashedSessionKey string
|
|
authenticated bool
|
|
sessionStart time.Time
|
|
values map[string]interface{}
|
|
}
|
|
|
|
// Init initializes the session by validating the user's JWT token from the cookie.
|
|
// It checks the cached token, verifies it matches, and loads any existing session data.
|
|
// Returns true if the session was successfully established.
|
|
func (s *SessionUser) Init(c *Context) bool {
|
|
s.context = c
|
|
|
|
// get cookie
|
|
usercookie, err := c.GetCookie()
|
|
if err != nil || usercookie.Token == "" {
|
|
return false
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
userID := uint(c.CastToInt(payload["userID"]))
|
|
userAgent := c.GetUserAgent()
|
|
|
|
// verify token against cached value
|
|
hashedCacheKey := CreateAuthTokenHashedCacheKey(userID, userAgent)
|
|
cachedToken, err := c.GetCache().Get(hashedCacheKey)
|
|
if err != nil || cachedToken != currentToken {
|
|
return false
|
|
}
|
|
|
|
// session established - load session data from cache
|
|
userAgent = c.GetUserAgent()
|
|
sessionKey := fmt.Sprintf("sess_%v", userAgent)
|
|
s.hashedSessionKey = CreateAuthTokenHashedCacheKey(userID, sessionKey)
|
|
s.values = make(map[string]interface{})
|
|
s.authenticated = true
|
|
s.userID = userID
|
|
|
|
value, _ := c.GetCache().Get(s.hashedSessionKey)
|
|
if len(value) > 0 {
|
|
_ = 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()
|
|
s.values[key] = value
|
|
s.mu.Unlock()
|
|
return s.Save()
|
|
}
|
|
|
|
// Get retrieves a value from the session. Returns the value and a boolean indicating if the key exists.
|
|
func (s *SessionUser) Get(key string) (interface{}, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
val, ok := s.values[key]
|
|
return val, ok
|
|
}
|
|
|
|
// Delete removes a specific key from the session and persists the change.
|
|
// Returns the deleted value if it existed.
|
|
func (s *SessionUser) Delete(key string) interface{} {
|
|
s.mu.RLock()
|
|
v, ok := s.values[key]
|
|
s.mu.RUnlock()
|
|
if ok {
|
|
s.mu.Lock()
|
|
delete(s.values, key)
|
|
s.mu.Unlock()
|
|
}
|
|
s.Save()
|
|
return v
|
|
}
|
|
|
|
// Flush deletes all session data from the cache.
|
|
func (s *SessionUser) Flush() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.hashedSessionKey != "" {
|
|
_ = s.context.GetCache().Delete(s.hashedSessionKey)
|
|
}
|
|
s.values = make(map[string]interface{})
|
|
s.authenticated = false
|
|
return nil
|
|
}
|
|
|
|
// Save persists the current session values to the cache.
|
|
func (s *SessionUser) Save() error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if len(s.values) > 0 {
|
|
buf, err := json.Marshal(&s.values)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.context.GetCache().Set(s.hashedSessionKey, string(buf))
|
|
}
|
|
|
|
_ = s.context.GetCache().Delete(s.hashedSessionKey)
|
|
return nil
|
|
}
|
|
|
|
// GetUserID returns the user ID of the authenticated session user.
|
|
func (s *SessionUser) GetUserID() uint {
|
|
return s.userID
|
|
}
|
|
|
|
// IsAuthenticated returns whether the session has been successfully authenticated.
|
|
func (s *SessionUser) IsAuthenticated() bool {
|
|
return s.authenticated
|
|
}
|
|
|
|
// CreateAuthTokenHashedCacheKey generates a hashed cache key used to store JWT tokens and session data.
|
|
// The key is based on the user ID and user agent, hashed with MD5.
|
|
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
|
|
}
|