Files
ITWorx-Pulse-Public/internal/auth/session.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

209 lines
7.1 KiB
Go

package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"sync"
"time"
)
type session struct {
principal Principal
issuedAt time.Time
expiresAt time.Time
absoluteExpiresAt time.Time
context context.Context
cancel context.CancelFunc
}
// SessionAuthentication carries the principal and the revocable lifetime of
// the authenticated browser session. Long-lived transports must derive their
// lifecycle from Context so logout and the absolute deadline remain effective
// after an HTTP upgrade.
type SessionAuthentication struct {
Principal Principal
Context context.Context
}
type SessionManager struct {
mu sync.Mutex
sessions map[string]session
CookieName string
TTL time.Duration
AbsoluteTTL time.Duration
RenewBefore time.Duration
Secure bool
MaxSessions int
MaxSessionsPerSubject int
}
func NewSessionManager(cookieName string, ttl time.Duration, secure bool) *SessionManager {
if cookieName == "" {
cookieName = "pulse_session"
}
if ttl <= 0 {
ttl = 8 * time.Hour
}
return &SessionManager{sessions: make(map[string]session), CookieName: cookieName, TTL: ttl, AbsoluteTTL: ttl, Secure: secure, MaxSessions: 4096, MaxSessionsPerSubject: 8}
}
// NewSlidingSessionManager creates an idle-expiring browser session with a
// separate absolute lifetime. Successful authenticated requests renew the idle
// deadline once half of the idle lifetime has elapsed, but never beyond the
// absolute deadline. Both lifetimes remain finite and the opaque token stays in
// an HttpOnly cookie.
func NewSlidingSessionManager(cookieName string, idleTTL, absoluteTTL time.Duration, secure bool) *SessionManager {
manager := NewSessionManager(cookieName, idleTTL, secure)
if absoluteTTL < idleTTL {
absoluteTTL = idleTTL
}
manager.AbsoluteTTL = absoluteTTL
manager.RenewBefore = idleTTL / 2
return manager
}
func (manager *SessionManager) Issue(response http.ResponseWriter, principal Principal, now time.Time) error {
if principal.Subject == "" || !Allows(principal.Role, PermissionView) {
return errors.New("session principal is invalid")
}
token, err := randomToken()
if err != nil {
return err
}
absoluteExpiresAt := now.Add(manager.AbsoluteTTL)
expiresAt := earliest(now.Add(manager.TTL), absoluteExpiresAt)
sessionContext, cancel := context.WithDeadline(context.Background(), absoluteExpiresAt)
manager.mu.Lock()
manager.purgeExpiredLocked(now)
manager.enforceSubjectLimitLocked(principal.Subject)
if manager.MaxSessions > 0 && len(manager.sessions) >= manager.MaxSessions {
manager.mu.Unlock()
cancel()
return errors.New("session capacity reached")
}
manager.sessions[hashToken(token)] = session{principal: principal, issuedAt: now, expiresAt: expiresAt, absoluteExpiresAt: absoluteExpiresAt, context: sessionContext, cancel: cancel}
manager.mu.Unlock()
manager.setCookie(response, token, now, expiresAt)
return nil
}
func (manager *SessionManager) Principal(request *http.Request, now time.Time) (Principal, bool) {
authentication, ok := manager.authenticate(nil, request, now)
return authentication.Principal, ok
}
// Authenticate validates the session and renews an active sliding session when
// it enters its renewal window. The token is deliberately stable: concurrent
// API requests cannot invalidate each other, while Clear still revokes it
// immediately server-side.
func (manager *SessionManager) Authenticate(response http.ResponseWriter, request *http.Request, now time.Time) (Principal, bool) {
authentication, ok := manager.authenticate(response, request, now)
return authentication.Principal, ok
}
// AuthenticateSession validates and renews the cookie while exposing the
// revocable session context to middleware that serves long-lived transports.
func (manager *SessionManager) AuthenticateSession(response http.ResponseWriter, request *http.Request, now time.Time) (SessionAuthentication, bool) {
return manager.authenticate(response, request, now)
}
func (manager *SessionManager) authenticate(response http.ResponseWriter, request *http.Request, now time.Time) (SessionAuthentication, bool) {
cookie, err := request.Cookie(manager.CookieName)
if err != nil || cookie.Value == "" {
return SessionAuthentication{}, false
}
manager.mu.Lock()
defer manager.mu.Unlock()
manager.purgeExpiredLocked(now)
stored, ok := manager.sessions[hashToken(cookie.Value)]
if !ok {
return SessionAuthentication{}, false
}
if !now.Before(stored.expiresAt) || !now.Before(stored.absoluteExpiresAt) {
stored.cancel()
delete(manager.sessions, hashToken(cookie.Value))
return SessionAuthentication{}, false
}
if response != nil && manager.RenewBefore > 0 && stored.expiresAt.Sub(now) <= manager.RenewBefore {
renewed := earliest(now.Add(manager.TTL), stored.absoluteExpiresAt)
if renewed.After(stored.expiresAt) {
stored.expiresAt = renewed
manager.sessions[hashToken(cookie.Value)] = stored
manager.setCookie(response, cookie.Value, now, renewed)
}
}
return SessionAuthentication{Principal: stored.principal, Context: stored.context}, true
}
func (manager *SessionManager) Clear(response http.ResponseWriter, request *http.Request) {
if cookie, err := request.Cookie(manager.CookieName); err == nil {
manager.mu.Lock()
key := hashToken(cookie.Value)
if stored, ok := manager.sessions[key]; ok {
stored.cancel()
delete(manager.sessions, key)
}
manager.mu.Unlock()
}
http.SetCookie(response, &http.Cookie{Name: manager.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: manager.Secure, SameSite: http.SameSiteLaxMode})
}
func (manager *SessionManager) purgeExpiredLocked(now time.Time) {
for key, stored := range manager.sessions {
if !now.Before(stored.expiresAt) || !now.Before(stored.absoluteExpiresAt) {
stored.cancel()
delete(manager.sessions, key)
}
}
}
func (manager *SessionManager) enforceSubjectLimitLocked(subject string) {
if manager.MaxSessionsPerSubject <= 0 {
return
}
for {
count := 0
oldestKey := ""
var oldest time.Time
for key, stored := range manager.sessions {
if stored.principal.Subject != subject {
continue
}
count++
if oldestKey == "" || stored.issuedAt.Before(oldest) {
oldestKey = key
oldest = stored.issuedAt
}
}
if count < manager.MaxSessionsPerSubject || oldestKey == "" {
return
}
stored := manager.sessions[oldestKey]
stored.cancel()
delete(manager.sessions, oldestKey)
}
}
func hashToken(token string) string {
digest := sha256.Sum256([]byte(token))
return hex.EncodeToString(digest[:])
}
func (manager *SessionManager) setCookie(response http.ResponseWriter, token string, now, expiresAt time.Time) {
maxAge := int(expiresAt.Sub(now).Seconds())
if maxAge < 1 {
maxAge = 1
}
http.SetCookie(response, &http.Cookie{Name: manager.CookieName, Value: token, Path: "/", Expires: expiresAt, MaxAge: maxAge, HttpOnly: true, Secure: manager.Secure, SameSite: http.SameSiteLaxMode})
}
func earliest(first, second time.Time) time.Time {
if first.Before(second) {
return first
}
return second
}