| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | // Package session owns the cookie-based session pipeline. S02 ships the |
| 4 | // AEAD-encrypted cookie store; the Store interface lets later sprints add |
| 5 | // a server-side store for revocation without touching handlers. |
| 6 | package session |
| 7 | |
| 8 | import ( |
| 9 | "crypto/cipher" |
| 10 | "crypto/rand" |
| 11 | "encoding/base64" |
| 12 | "encoding/json" |
| 13 | "errors" |
| 14 | "fmt" |
| 15 | "net/http" |
| 16 | "time" |
| 17 | |
| 18 | "golang.org/x/crypto/chacha20poly1305" |
| 19 | ) |
| 20 | |
| 21 | // CookieName is the canonical session cookie name. Prefixed with an |
| 22 | // underscore so it doesn't collide with any future user-controllable name. |
| 23 | const CookieName = "_shithub_session" |
| 24 | |
| 25 | // DefaultMaxAge is the default cookie lifetime. |
| 26 | const DefaultMaxAge = 30 * 24 * time.Hour |
| 27 | |
| 28 | // Session is the data carried in a cookie. The shape is intentionally |
| 29 | // small; anything that doesn't fit a few hundred bytes belongs server-side. |
| 30 | type Session struct { |
| 31 | UserID int64 `json:"uid,omitempty"` |
| 32 | Pre2FAUserID int64 `json:"p2,omitempty"` // set after password OK, before TOTP step |
| 33 | Recent2FAAt int64 `json:"r2,omitempty"` // unix-seconds of last successful 2FA challenge |
| 34 | // Epoch is the users.session_epoch value at issue time. The session |
| 35 | // loader compares it against the current DB value on every request; |
| 36 | // "log out everywhere" bumps the column, invalidating every cookie |
| 37 | // that still carries the old epoch. Zero is the unbumped baseline |
| 38 | // and matches the migration's column default. |
| 39 | Epoch int32 `json:"e,omitempty"` |
| 40 | CSRFToken string `json:"csrf,omitempty"` |
| 41 | Theme string `json:"theme,omitempty"` |
| 42 | Flashes []string `json:"flashes,omitempty"` |
| 43 | Extras map[string]string `json:"extras,omitempty"` |
| 44 | IssuedAt int64 `json:"iat,omitempty"` |
| 45 | } |
| 46 | |
| 47 | // IsAnonymous returns true when no user is bound to the session. |
| 48 | func (s *Session) IsAnonymous() bool { return s == nil || s.UserID == 0 } |
| 49 | |
| 50 | // AddFlash appends a flash message; it'll be rendered + cleared on the next |
| 51 | // page load. |
| 52 | func (s *Session) AddFlash(msg string) { |
| 53 | if s == nil { |
| 54 | return |
| 55 | } |
| 56 | s.Flashes = append(s.Flashes, msg) |
| 57 | } |
| 58 | |
| 59 | // PopFlashes returns and clears any pending flash messages. |
| 60 | func (s *Session) PopFlashes() []string { |
| 61 | if s == nil { |
| 62 | return nil |
| 63 | } |
| 64 | out := s.Flashes |
| 65 | s.Flashes = nil |
| 66 | return out |
| 67 | } |
| 68 | |
| 69 | // Store is the abstraction every handler uses. The cookie implementation |
| 70 | // stores everything client-side; future implementations may persist |
| 71 | // server-side rows for revocation. |
| 72 | type Store interface { |
| 73 | Load(r *http.Request) (*Session, error) |
| 74 | Save(w http.ResponseWriter, r *http.Request, s *Session) error |
| 75 | Clear(w http.ResponseWriter) |
| 76 | } |
| 77 | |
| 78 | // CookieStore is the AEAD-encrypted-cookie implementation. |
| 79 | type CookieStore struct { |
| 80 | aead cipher.AEAD |
| 81 | maxAge time.Duration |
| 82 | secure bool // set Secure attribute on the cookie |
| 83 | domain string // optional cookie domain |
| 84 | path string // cookie path; defaults to "/" |
| 85 | sameSite http.SameSite |
| 86 | clock func() time.Time |
| 87 | } |
| 88 | |
| 89 | // CookieStoreConfig holds the ergonomic options. Key MUST be 32 bytes. |
| 90 | type CookieStoreConfig struct { |
| 91 | Key []byte |
| 92 | MaxAge time.Duration |
| 93 | Secure bool |
| 94 | Domain string |
| 95 | Path string |
| 96 | SameSite http.SameSite |
| 97 | } |
| 98 | |
| 99 | // NewCookieStore constructs a store. Returns an error if the key is wrong |
| 100 | // size — this is a configuration error and must surface at startup. |
| 101 | func NewCookieStore(cfg CookieStoreConfig) (*CookieStore, error) { |
| 102 | if len(cfg.Key) != chacha20poly1305.KeySize { |
| 103 | return nil, fmt.Errorf("session: key must be %d bytes, got %d", chacha20poly1305.KeySize, len(cfg.Key)) |
| 104 | } |
| 105 | aead, err := chacha20poly1305.New(cfg.Key) |
| 106 | if err != nil { |
| 107 | return nil, fmt.Errorf("session: aead init: %w", err) |
| 108 | } |
| 109 | if cfg.MaxAge <= 0 { |
| 110 | cfg.MaxAge = DefaultMaxAge |
| 111 | } |
| 112 | if cfg.Path == "" { |
| 113 | cfg.Path = "/" |
| 114 | } |
| 115 | if cfg.SameSite == 0 { |
| 116 | cfg.SameSite = http.SameSiteLaxMode |
| 117 | } |
| 118 | return &CookieStore{ |
| 119 | aead: aead, |
| 120 | maxAge: cfg.MaxAge, |
| 121 | secure: cfg.Secure, |
| 122 | domain: cfg.Domain, |
| 123 | path: cfg.Path, |
| 124 | sameSite: cfg.SameSite, |
| 125 | clock: time.Now, |
| 126 | }, nil |
| 127 | } |
| 128 | |
| 129 | // Load returns the session bound to the cookie, or a fresh empty session |
| 130 | // when none is present or the cookie is invalid (expired / wrong key / |
| 131 | // tampered). Errors are surfaced for malformed input but a missing or |
| 132 | // invalid cookie yields an empty session, not an error — the caller can |
| 133 | // proceed with anonymous handling. |
| 134 | func (c *CookieStore) Load(r *http.Request) (*Session, error) { |
| 135 | cookie, err := r.Cookie(CookieName) |
| 136 | if err != nil { |
| 137 | return &Session{}, nil |
| 138 | } |
| 139 | raw, err := base64.RawURLEncoding.DecodeString(cookie.Value) |
| 140 | if err != nil || len(raw) < c.aead.NonceSize() { |
| 141 | return &Session{}, nil |
| 142 | } |
| 143 | nonce, ct := raw[:c.aead.NonceSize()], raw[c.aead.NonceSize():] |
| 144 | plain, err := c.aead.Open(nil, nonce, ct, nil) |
| 145 | if err != nil { |
| 146 | return &Session{}, nil |
| 147 | } |
| 148 | var s Session |
| 149 | if err := json.Unmarshal(plain, &s); err != nil { |
| 150 | return &Session{}, nil |
| 151 | } |
| 152 | if s.IssuedAt > 0 && c.clock().Unix()-s.IssuedAt > int64(c.maxAge.Seconds()) { |
| 153 | return &Session{}, nil |
| 154 | } |
| 155 | return &s, nil |
| 156 | } |
| 157 | |
| 158 | // Save serializes, encrypts, and sets the session cookie. |
| 159 | func (c *CookieStore) Save(w http.ResponseWriter, _ *http.Request, s *Session) error { |
| 160 | if s == nil { |
| 161 | s = &Session{} |
| 162 | } |
| 163 | s.IssuedAt = c.clock().Unix() |
| 164 | plain, err := json.Marshal(s) |
| 165 | if err != nil { |
| 166 | return fmt.Errorf("session: marshal: %w", err) |
| 167 | } |
| 168 | nonce := make([]byte, c.aead.NonceSize()) |
| 169 | if _, err := rand.Read(nonce); err != nil { |
| 170 | return fmt.Errorf("session: nonce: %w", err) |
| 171 | } |
| 172 | sealed := c.aead.Seal(nonce, nonce, plain, nil) |
| 173 | encoded := base64.RawURLEncoding.EncodeToString(sealed) |
| 174 | http.SetCookie(w, c.cookie(encoded, int(c.maxAge.Seconds()))) |
| 175 | return nil |
| 176 | } |
| 177 | |
| 178 | // Clear deletes the session cookie. |
| 179 | func (c *CookieStore) Clear(w http.ResponseWriter) { |
| 180 | http.SetCookie(w, c.cookie("", -1)) |
| 181 | } |
| 182 | |
| 183 | // cookie builds a *http.Cookie with the store's policy. Concentrating the |
| 184 | // construction here gives us a single point where Secure-flag policy is |
| 185 | // enforced. Secure is operator-controlled per cfg.Secure (S37 enables it |
| 186 | // under TLS). |
| 187 | // |
| 188 | //nolint:gosec // G124: Secure attribute is intentionally configurable via cfg.Secure. |
| 189 | func (c *CookieStore) cookie(value string, maxAge int) *http.Cookie { |
| 190 | return &http.Cookie{ |
| 191 | Name: CookieName, |
| 192 | Value: value, |
| 193 | Path: c.path, |
| 194 | Domain: c.domain, |
| 195 | MaxAge: maxAge, |
| 196 | Secure: c.secure, |
| 197 | HttpOnly: true, |
| 198 | SameSite: c.sameSite, |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | // GenerateKey returns a new random 32-byte key suitable for NewCookieStore. |
| 203 | // Used by the operator's first-run setup; production keys come from config. |
| 204 | func GenerateKey() ([]byte, error) { |
| 205 | key := make([]byte, chacha20poly1305.KeySize) |
| 206 | if _, err := rand.Read(key); err != nil { |
| 207 | return nil, fmt.Errorf("session: generate key: %w", err) |
| 208 | } |
| 209 | return key, nil |
| 210 | } |
| 211 | |
| 212 | // Sentinel errors callers may check for. |
| 213 | var ( |
| 214 | ErrInvalidKey = errors.New("session: invalid key size") |
| 215 | ) |
| 216 |