Files
mesh-catalog/modules/mesh-mail-web/internal/web/sessions.go
T
jochen f24a4d858a issue 1859: move the mail page into internal/web, so the local run can serve it
A program cannot import a main package; the page's code moves to modules/mesh-mail-web/internal/web, and
cmd/mesh-mail-web is a two-line program calling it, so cmd/mail-page-dev (1859.5) serves the same handlers.
Nothing the module does changes.
2026-10-22 19:58:43 +02:00

144 lines
4.1 KiB
Go

package web
// Sessions and the pending authorisations in flight (novox/hq ADR 0440 §3, issue 1263): both kept in memory
// only, never on disk — a restart signs everyone out, accepted (design 59 milestone 1's prepare step).
import (
"crypto/rand"
"encoding/base64"
"sync"
"time"
)
// SessionCookie is the cookie's own name.
const SessionCookie = "mesh_mail_web_session"
// SessionLifetime is how long a session lasts after it is made (novox/hq ADR 0440 §3, issue 1263/AC2, AC5).
const SessionLifetime = 12 * time.Hour
// PendingLifetime is how long a sign-in may take between the redirect to the issuer and its callback, before
// the state is no longer honoured.
const PendingLifetime = 10 * time.Minute
func randomID() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
// Session is one signed-in operator's own session: its identity, when it was made (SessionLifetime decides
// when it ends), and the per-session token issue 1284 binds every changing form to.
type Session struct {
Operator string
Made time.Time
Token string
}
// Sessions keeps every session in memory, by its cookie's own id.
type Sessions struct {
mu sync.Mutex
now func() time.Time
byID map[string]Session
}
// NewSessions is an empty Sessions; now is time.Now unless a test gives its own.
func NewSessions(now func() time.Time) *Sessions {
if now == nil {
now = time.Now
}
return &Sessions{now: now, byID: map[string]Session{}}
}
// Create starts a new session for operator, answering its id (the cookie's own value) and its own per-session
// token (issue 1284).
func (s *Sessions) Create(operator string) (id string, err error) {
id, err = randomID()
if err != nil {
return "", err
}
token, err := randomID()
if err != nil {
return "", err
}
s.mu.Lock()
defer s.mu.Unlock()
s.byID[id] = Session{Operator: operator, Made: s.now(), Token: token}
return id, nil
}
// Get is the session at id, or false when there is none or it ended SessionLifetime ago (1263/AC5): an expired
// session reads as no session at all, dropped on the same look (no background sweep is needed: every read
// already checks the time).
func (s *Sessions) Get(id string) (Session, bool) {
if id == "" {
return Session{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.byID[id]
if !ok {
return Session{}, false
}
if s.now().Sub(sess.Made) > SessionLifetime {
delete(s.byID, id)
return Session{}, false
}
return sess, true
}
// PendingAuth is one sign-in asked of the issuer, remembered between the redirect and its callback: the PKCE
// verifier the challenge was made from, and the page the browser first asked for.
type PendingAuth struct {
Verifier string
Return string
Created time.Time
}
// PendingAuths keeps every sign-in in flight, by its own state, in memory only.
type PendingAuths struct {
mu sync.Mutex
now func() time.Time
byState map[string]PendingAuth
}
// NewPendingAuths is an empty PendingAuths; now is time.Now unless a test gives its own.
func NewPendingAuths(now func() time.Time) *PendingAuths {
if now == nil {
now = time.Now
}
return &PendingAuths{now: now, byState: map[string]PendingAuth{}}
}
// Create remembers one sign-in in flight, answering the state it is kept under.
func (p *PendingAuths) Create(verifier, returnTo string) (state string, err error) {
state, err = randomID()
if err != nil {
return "", err
}
p.mu.Lock()
defer p.mu.Unlock()
p.byState[state] = PendingAuth{Verifier: verifier, Return: returnTo, Created: p.now()}
return state, nil
}
// Take reads back and removes the sign-in kept under state — once; a state never issued, already taken, or
// older than PendingLifetime answers false (1263/AC4's "a state it did not issue").
func (p *PendingAuths) Take(state string) (PendingAuth, bool) {
if state == "" {
return PendingAuth{}, false
}
p.mu.Lock()
defer p.mu.Unlock()
pa, ok := p.byState[state]
if !ok {
return PendingAuth{}, false
}
delete(p.byState, state)
if p.now().Sub(pa.Created) > PendingLifetime {
return PendingAuth{}, false
}
return pa, true
}