Go · 6828 bytes Raw Blame History
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