gitmote

gitmote / internal / webui / session.go  ·  history  ·  branches

2607 bytes · raw

package webui

import (
	"crypto/hmac"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"net/http"
	"strconv"
	"strings"
	"time"
)

// Session state is a signed cookie, not a server-side table: the value is
// "<userID>.<expiryUnix>.<hmac>", where the HMAC-SHA256 over "<userID>.<expiry>"
// is keyed by GITMOTE_COOKIE_KEY. This keeps the UI stateless — no sessions
// table, no new source of truth — at the cost of not being able to revoke a
// single cookie before it expires; the short TTL bounds that, and the admin
// middleware re-checks the user (and is_admin) on every request.
const (
	cookieName = "gm_session"
	sessionTTL = 12 * time.Hour
)

type sessions struct {
	key []byte
}

// issue signs a session for userID and sets it as the response cookie. Secure is
// set only on TLS requests so plain-HTTP local development still works while a
// real (HTTPS) deployment gets a Secure cookie.
func (s *sessions) issue(w http.ResponseWriter, r *http.Request, userID int64, now time.Time) {
	exp := now.Add(sessionTTL).Unix()
	payload := strconv.FormatInt(userID, 10) + "." + strconv.FormatInt(exp, 10)
	value := payload + "." + s.sign(payload)
	http.SetCookie(w, &http.Cookie{
		Name:     cookieName,
		Value:    value,
		Path:     "/",
		Expires:  now.Add(sessionTTL),
		HttpOnly: true,
		Secure:   r.TLS != nil,
		SameSite: http.SameSiteLaxMode,
	})
}

// verify returns the userID carried by a valid, unexpired session cookie.
func (s *sessions) verify(r *http.Request, now time.Time) (int64, bool) {
	c, err := r.Cookie(cookieName)
	if err != nil {
		return 0, false
	}
	uid, expStr, mac, ok := splitCookie(c.Value)
	if !ok {
		return 0, false
	}
	payload := uid + "." + expStr
	if subtle.ConstantTimeCompare([]byte(mac), []byte(s.sign(payload))) != 1 {
		return 0, false
	}
	exp, err := strconv.ParseInt(expStr, 10, 64)
	if err != nil || now.Unix() >= exp {
		return 0, false
	}
	id, err := strconv.ParseInt(uid, 10, 64)
	if err != nil {
		return 0, false
	}
	return id, true
}

// clear expires the session cookie on logout.
func (s *sessions) clear(w http.ResponseWriter) {
	http.SetCookie(w, &http.Cookie{
		Name:     cookieName,
		Value:    "",
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	})
}

func (s *sessions) sign(payload string) string {
	mac := hmac.New(sha256.New, s.key)
	mac.Write([]byte(payload))
	return hex.EncodeToString(mac.Sum(nil))
}

func splitCookie(v string) (uid, exp, mac string, ok bool) {
	parts := strings.Split(v, ".")
	if len(parts) != 3 {
		return "", "", "", false
	}
	return parts[0], parts[1], parts[2], true
}