check / check (push) Waiting to run
The state directory bullet in README.md and the session package doc now say that the time of the latest logout is kept in the database, which a restart without a persistent volume loses. Model: opus-5-5
244 lines
6.2 KiB
Go
244 lines
6.2 KiB
Go
// Package session provides encrypted session cookie management, and keeps the
|
|
// time of the latest logout in the database so that a logout ends every
|
|
// session made before it.
|
|
package session
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/securecookie"
|
|
|
|
"sneak.berlin/go/pixa/internal/seal"
|
|
)
|
|
|
|
// Session configuration constants.
|
|
const (
|
|
CookieName = "pixa_session"
|
|
SessionTTL = 30 * 24 * time.Hour // 30 days
|
|
|
|
// HKDF salts for key derivation
|
|
hashKeySalt = "pixa-session-hash-v1"
|
|
blockKeySalt = "pixa-session-block-v1"
|
|
)
|
|
|
|
// Errors returned by session operations.
|
|
var (
|
|
ErrInvalidSession = errors.New("invalid or expired session")
|
|
ErrNoSession = errors.New("no session cookie present")
|
|
)
|
|
|
|
// Data contains the session payload stored in the encrypted cookie.
|
|
type Data struct {
|
|
Authenticated bool `json:"auth"`
|
|
CreatedAt time.Time `json:"created"`
|
|
ExpiresAt time.Time `json:"expires"`
|
|
}
|
|
|
|
// Manager handles session creation and validation using encrypted cookies.
|
|
//
|
|
// Every login is made with the one signing key, so a logout ends every
|
|
// session made before it, in every browser. The Manager keeps the time of
|
|
// the latest logout in memory, so checking a session reads no database; a
|
|
// Manager from NewManagerWithDatabase also saves it in the database, so that
|
|
// it outlasts a restart.
|
|
type Manager struct {
|
|
sc *securecookie.SecureCookie
|
|
db *sql.DB
|
|
|
|
// mu guards latestLogout, and is held while a logout is saved, so that
|
|
// the database and latestLogout agree.
|
|
mu sync.Mutex
|
|
latestLogout time.Time
|
|
}
|
|
|
|
// NewManager creates a session manager with keys derived from the signing key.
|
|
// It keeps logouts in memory only, so a restart forgets them;
|
|
// NewManagerWithDatabase keeps them in the database.
|
|
//
|
|
// Session cookies always carry the Secure, HttpOnly, and SameSite=Strict
|
|
// attributes; this cannot be configured. Browsers treat http://localhost as a
|
|
// trustworthy origin and accept Secure cookies there, so local development
|
|
// keeps working.
|
|
func NewManager(signingKey string) (*Manager, error) {
|
|
masterKey := []byte(signingKey)
|
|
|
|
// Derive separate keys for HMAC (hash) and encryption (block)
|
|
hashKey, err := seal.DeriveKey(masterKey, hashKeySalt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
blockKey, err := seal.DeriveKey(masterKey, blockKeySalt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sc := securecookie.New(hashKey[:], blockKey[:])
|
|
sc.MaxAge(int(SessionTTL.Seconds()))
|
|
|
|
return &Manager{
|
|
sc: sc,
|
|
}, nil
|
|
}
|
|
|
|
// NewManagerWithDatabase creates a session manager like NewManager that saves
|
|
// each logout in db, which must have pixa's schema, and starts from the
|
|
// latest logout saved there.
|
|
func NewManagerWithDatabase(
|
|
ctx context.Context, signingKey string, db *sql.DB,
|
|
) (*Manager, error) {
|
|
m, err := NewManager(signingKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = db.QueryRowContext(ctx,
|
|
"SELECT logged_out_at FROM logout WHERE id = 1",
|
|
).Scan(&m.latestLogout)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("failed to read the latest logout: %w", err)
|
|
}
|
|
|
|
m.db = db
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// CreateSession creates a new authenticated session and sets the cookie.
|
|
func (m *Manager) CreateSession(w http.ResponseWriter) error {
|
|
now := time.Now()
|
|
data := &Data{
|
|
Authenticated: true,
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(SessionTTL),
|
|
}
|
|
|
|
encoded, err := m.sc.Encode(CookieName, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: CookieName,
|
|
Value: encoded,
|
|
Path: "/",
|
|
MaxAge: int(SessionTTL.Seconds()),
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateSession checks if the request has a valid session cookie.
|
|
// Returns the session data if valid, or an error if invalid/missing.
|
|
func (m *Manager) ValidateSession(r *http.Request) (*Data, error) {
|
|
cookie, err := r.Cookie(CookieName)
|
|
if err != nil {
|
|
if errors.Is(err, http.ErrNoCookie) {
|
|
return nil, ErrNoSession
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
var data Data
|
|
|
|
err = m.sc.Decode(CookieName, cookie.Value, &data)
|
|
if err != nil {
|
|
return nil, ErrInvalidSession
|
|
}
|
|
|
|
// Check if session has expired (defense in depth - cookie MaxAge should handle this)
|
|
if time.Now().After(data.ExpiresAt) {
|
|
return nil, ErrInvalidSession
|
|
}
|
|
|
|
if !data.Authenticated {
|
|
return nil, ErrInvalidSession
|
|
}
|
|
|
|
// A session made before the latest logout has ended.
|
|
if !data.CreatedAt.After(m.latestLogoutTime()) {
|
|
return nil, ErrInvalidSession
|
|
}
|
|
|
|
return &data, nil
|
|
}
|
|
|
|
// Logout removes the session cookie and, when r has a valid session, ends
|
|
// every session made until now. A request without one ends no session:
|
|
// another site can make a browser request /logout, but the browser leaves
|
|
// the SameSite=Strict session cookie out of that request.
|
|
func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) error {
|
|
if m.IsAuthenticated(r) {
|
|
err := m.saveLogout(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
m.ClearSession(w)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ClearSession removes the session cookie.
|
|
func (m *Manager) ClearSession(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: CookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1, // Delete immediately
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
// IsAuthenticated is a convenience method that returns true if the request
|
|
// has a valid authenticated session.
|
|
func (m *Manager) IsAuthenticated(r *http.Request) bool {
|
|
data, err := m.ValidateSession(r)
|
|
|
|
return err == nil && data != nil && data.Authenticated
|
|
}
|
|
|
|
// saveLogout makes now the time of the latest logout, saving it in the
|
|
// database first when the Manager has one.
|
|
func (m *Manager) saveLogout(ctx context.Context) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
now := time.Now().UTC()
|
|
|
|
if m.db != nil {
|
|
_, err := m.db.ExecContext(ctx,
|
|
"INSERT OR REPLACE INTO logout (id, logged_out_at) VALUES (1, ?)",
|
|
now)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save the logout: %w", err)
|
|
}
|
|
}
|
|
|
|
m.latestLogout = now
|
|
|
|
return nil
|
|
}
|
|
|
|
// latestLogoutTime returns the time of the latest logout, or the zero time
|
|
// when there has been none.
|
|
func (m *Manager) latestLogoutTime() time.Time {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
return m.latestLogout
|
|
}
|