Ctrl K
ComunidadGuías…

internal/auth/session.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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))
}