// Copyright (c) 2026 Zeni Kim // 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 }