This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user