All checks were successful
check / check (push) Successful in 2m4s
Resolve the two remaining gosec G124 findings (internal/session/ session.go:84 and :128): session cookies are now unconditionally Secure, HttpOnly, and SameSite=Strict on both the CreateSession set-cookie path and the ClearSession delete-cookie path. gosec requires these attributes to be constant, and there is no legitimate configuration in which the authentication cookie should be weaker, so the former secure toggle (wired to !config.Debug) is removed rather than kept as a variable. The toggle parameter on NewManager is retained as an ignored blank parameter so existing call sites (including tests) keep compiling; removing it is tracked as a Future Step in TODO.md. Local development over http://localhost keeps working because browsers treat localhost as a trustworthy origin and accept Secure cookies there. Update TODO.md per its Workflow section: record this step as completed, promote the manual auth/URL-flow test pass to Next Step, and correct the stale Status text (make check is now green).
148 lines
3.7 KiB
Go
148 lines
3.7 KiB
Go
// Package session provides encrypted session cookie management.
|
|
package session
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"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.
|
|
type Manager struct {
|
|
sc *securecookie.SecureCookie
|
|
}
|
|
|
|
// NewManager creates a session manager with keys derived from the signing key.
|
|
//
|
|
// 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. The second parameter is the former secure toggle: it is
|
|
// ignored and retained only so existing call sites keep compiling; it will be
|
|
// removed in a follow-up change.
|
|
func NewManager(signingKey string, _ bool) (*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
|
|
}
|
|
|
|
// 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
|
|
if err := m.sc.Decode(CookieName, cookie.Value, &data); 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
|
|
}
|
|
|
|
return &data, 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
|
|
}
|