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.
144 lines
4.1 KiB
Go
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
|
|
}
|