Public source validation / validate (push) Failing after 3m8s
209 lines
7.1 KiB
Go
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
|
|
}
|