internal/auth/session.go
Rama main · réplica de lectura del repositorio original.
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const SessionCookie = "spinoza_session"
const maxCookieBytes = 3800
type sessionClaims struct {
Expires int64 `json:"e"`
Groups []string `json:"g,omitempty"`
Issued int64 `json:"i"`
Role string `json:"r"`
Session string `json:"s"`
User string `json:"u"`
}
type stashClaims struct {
Expires int64 `json:"e"`
Payload json.RawMessage `json:"p"`
}
type session struct {
who Identity
issued time.Time
expires time.Time
}
type sessions struct {
secret []byte
ttl time.Duration
maxAge time.Duration
secure bool
now func() time.Time
}
func newSessions(secret []byte, ttl, maxAge time.Duration, secure bool) *sessions {
return &sessions{secret: secret, ttl: ttl, maxAge: maxAge, secure: secure, now: time.Now}
}
func NewSecret() []byte {
return newSecret(rand.Reader)
}
func newSecret(source io.Reader) []byte {
secret, err := secretFrom(source)
if err != nil {
panic(err)
}
return secret
}
func secretFrom(source io.Reader) ([]byte, error) {
secret := make([]byte, 32)
if _, err := io.ReadFull(source, secret); err != nil {
return nil, fmt.Errorf("generate session secret: %w", err)
}
return secret, nil
}
func newSessionID() string {
return strings.ToLower(rand.Text())
}
func (ss *sessions) sign(payload []byte) string {
mac := hmac.New(sha256.New, ss.secret)
_, _ = mac.Write(payload)
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (ss *sessions) seal(payload any) (string, error) {
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
encoded := base64.RawURLEncoding.EncodeToString(body)
value := encoded + "." + ss.sign([]byte(encoded))
if len(value) > maxCookieBytes {
return "", errCookieTooBig
}
return value, nil
}
func (ss *sessions) unseal(value string, into any) bool {
body, signature, found := strings.Cut(value, ".")
if !found {
return false
}
if !hmac.Equal([]byte(signature), []byte(ss.sign([]byte(body)))) {
return false
}
payload, err := base64.RawURLEncoding.DecodeString(body)
if err != nil {
return false
}
return json.Unmarshal(payload, into) == nil
}
func (ss *sessions) encode(who Identity, issued time.Time) (string, error) {
now := ss.now()
return ss.encodeUntil(who, issued, ss.expiry(issued, now))
}
func (ss *sessions) encodeUntil(who Identity, issued, expires time.Time) (string, error) {
return ss.seal(sessionClaims{
User: who.User,
Groups: who.Groups,
Role: who.Role,
Session: who.Session,
Issued: issued.Unix(),
Expires: expires.Unix(),
})
}
func (ss *sessions) expiry(issued, now time.Time) time.Time {
expires := now.Add(ss.ttl)
maximum := issued.Add(ss.maxAge)
if maximum.Before(expires) {
return maximum
}
return expires
}
func (ss *sessions) decode(value string) (session, bool) {
var claims sessionClaims
if !ss.unseal(value, &claims) {
return session{}, false
}
now := ss.now()
issued := time.Unix(claims.Issued, 0)
if !now.Before(time.Unix(claims.Expires, 0)) || !now.Before(issued.Add(ss.maxAge)) {
return session{}, false
}
who := Identity{
User: claims.User,
Groups: claims.Groups,
Role: claims.Role,
Session: claims.Session,
}
if who.Anonymous() {
return session{}, false
}
return session{
who: who,
issued: issued,
expires: time.Unix(claims.Expires, 0),
}, true
}
func (ss *sessions) issue(w http.ResponseWriter, who Identity, issued time.Time) error {
now := ss.now()
expires := ss.expiry(issued, now)
value, err := ss.encodeUntil(who, issued, expires)
if err != nil {
return err
}
http.SetCookie(w, ss.cookie(SessionCookie, value, expires.Sub(now)))
return nil
}
//nolint:gosec // Secure follows the public url, so a lab served over plain http still keeps its session
func (ss *sessions) cookie(name, value string, age time.Duration) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: "/",
HttpOnly: true,
Secure: ss.secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(age.Seconds()),
}
}
func (ss *sessions) read(r *http.Request) (session, bool) {
cookie, err := r.Cookie(SessionCookie)
if err != nil {
return session{}, false
}
return ss.decode(cookie.Value)
}
func (ss *sessions) halfSpent(expires time.Time) bool {
return expires.Sub(ss.now()) < ss.ttl/2
}
func (ss *sessions) renewable(issued time.Time) bool {
return ss.now().Sub(issued) < ss.maxAge
}
func (ss *sessions) stash(w http.ResponseWriter, name string, payload any, age time.Duration) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
value, err := ss.seal(stashClaims{Expires: ss.now().Add(age).Unix(), Payload: body})
if err != nil {
return err
}
http.SetCookie(w, ss.cookie(name, value, age))
return nil
}
func (ss *sessions) unstash(r *http.Request, into any) bool {
return ss.unstashNamed(r, flowCookie, into)
}
func (ss *sessions) unstashNamed(r *http.Request, name string, into any) bool {
cookie, err := r.Cookie(name)
if err != nil {
return false
}
var claims stashClaims
if !ss.unseal(cookie.Value, &claims) {
return false
}
if !ss.now().Before(time.Unix(claims.Expires, 0)) {
return false
}
return json.Unmarshal(claims.Payload, into) == nil
}
func (ss *sessions) drop(w http.ResponseWriter, name string) {
http.SetCookie(w, ss.cookie(name, "", -time.Hour))
}