Go · 7299 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 // ImpersonatedUserID — when non-zero, the admin (UserID above) is
46 // currently viewing as this user. ImpersonateWriteOK gates writes
47 // (defaults false; admin opts in via the typed-name confirm step
48 // in /admin/impersonate). ImpersonationStartedAt drives the
49 // inactivity timeout (S34: 1 hour idle).
50 ImpersonatedUserID int64 `json:"imp,omitempty"`
51 ImpersonateWriteOK bool `json:"impw,omitempty"`
52 ImpersonationStartedAt int64 `json:"impt,omitempty"`
53 }
54
55 // IsAnonymous returns true when no user is bound to the session.
56 func (s *Session) IsAnonymous() bool { return s == nil || s.UserID == 0 }
57
58 // AddFlash appends a flash message; it'll be rendered + cleared on the next
59 // page load.
60 func (s *Session) AddFlash(msg string) {
61 if s == nil {
62 return
63 }
64 s.Flashes = append(s.Flashes, msg)
65 }
66
67 // PopFlashes returns and clears any pending flash messages.
68 func (s *Session) PopFlashes() []string {
69 if s == nil {
70 return nil
71 }
72 out := s.Flashes
73 s.Flashes = nil
74 return out
75 }
76
77 // Store is the abstraction every handler uses. The cookie implementation
78 // stores everything client-side; future implementations may persist
79 // server-side rows for revocation.
80 type Store interface {
81 Load(r *http.Request) (*Session, error)
82 Save(w http.ResponseWriter, r *http.Request, s *Session) error
83 Clear(w http.ResponseWriter)
84 }
85
86 // CookieStore is the AEAD-encrypted-cookie implementation.
87 type CookieStore struct {
88 aead cipher.AEAD
89 maxAge time.Duration
90 secure bool // set Secure attribute on the cookie
91 domain string // optional cookie domain
92 path string // cookie path; defaults to "/"
93 sameSite http.SameSite
94 clock func() time.Time
95 }
96
97 // CookieStoreConfig holds the ergonomic options. Key MUST be 32 bytes.
98 type CookieStoreConfig struct {
99 Key []byte
100 MaxAge time.Duration
101 Secure bool
102 Domain string
103 Path string
104 SameSite http.SameSite
105 }
106
107 // NewCookieStore constructs a store. Returns an error if the key is wrong
108 // size — this is a configuration error and must surface at startup.
109 func NewCookieStore(cfg CookieStoreConfig) (*CookieStore, error) {
110 if len(cfg.Key) != chacha20poly1305.KeySize {
111 return nil, fmt.Errorf("session: key must be %d bytes, got %d", chacha20poly1305.KeySize, len(cfg.Key))
112 }
113 aead, err := chacha20poly1305.New(cfg.Key)
114 if err != nil {
115 return nil, fmt.Errorf("session: aead init: %w", err)
116 }
117 if cfg.MaxAge <= 0 {
118 cfg.MaxAge = DefaultMaxAge
119 }
120 if cfg.Path == "" {
121 cfg.Path = "/"
122 }
123 if cfg.SameSite == 0 {
124 cfg.SameSite = http.SameSiteLaxMode
125 }
126 return &CookieStore{
127 aead: aead,
128 maxAge: cfg.MaxAge,
129 secure: cfg.Secure,
130 domain: cfg.Domain,
131 path: cfg.Path,
132 sameSite: cfg.SameSite,
133 clock: time.Now,
134 }, nil
135 }
136
137 // Load returns the session bound to the cookie, or a fresh empty session
138 // when none is present or the cookie is invalid (expired / wrong key /
139 // tampered). Errors are surfaced for malformed input but a missing or
140 // invalid cookie yields an empty session, not an error — the caller can
141 // proceed with anonymous handling.
142 func (c *CookieStore) Load(r *http.Request) (*Session, error) {
143 cookie, err := r.Cookie(CookieName)
144 if err != nil {
145 return &Session{}, nil
146 }
147 raw, err := base64.RawURLEncoding.DecodeString(cookie.Value)
148 if err != nil || len(raw) < c.aead.NonceSize() {
149 return &Session{}, nil
150 }
151 nonce, ct := raw[:c.aead.NonceSize()], raw[c.aead.NonceSize():]
152 plain, err := c.aead.Open(nil, nonce, ct, nil)
153 if err != nil {
154 return &Session{}, nil
155 }
156 var s Session
157 if err := json.Unmarshal(plain, &s); err != nil {
158 return &Session{}, nil
159 }
160 if s.IssuedAt > 0 && c.clock().Unix()-s.IssuedAt > int64(c.maxAge.Seconds()) {
161 return &Session{}, nil
162 }
163 return &s, nil
164 }
165
166 // Save serializes, encrypts, and sets the session cookie.
167 func (c *CookieStore) Save(w http.ResponseWriter, _ *http.Request, s *Session) error {
168 if s == nil {
169 s = &Session{}
170 }
171 s.IssuedAt = c.clock().Unix()
172 plain, err := json.Marshal(s)
173 if err != nil {
174 return fmt.Errorf("session: marshal: %w", err)
175 }
176 nonce := make([]byte, c.aead.NonceSize())
177 if _, err := rand.Read(nonce); err != nil {
178 return fmt.Errorf("session: nonce: %w", err)
179 }
180 sealed := c.aead.Seal(nonce, nonce, plain, nil)
181 encoded := base64.RawURLEncoding.EncodeToString(sealed)
182 http.SetCookie(w, c.cookie(encoded, int(c.maxAge.Seconds())))
183 return nil
184 }
185
186 // Clear deletes the session cookie.
187 func (c *CookieStore) Clear(w http.ResponseWriter) {
188 http.SetCookie(w, c.cookie("", -1))
189 }
190
191 // cookie builds a *http.Cookie with the store's policy. Concentrating the
192 // construction here gives us a single point where Secure-flag policy is
193 // enforced. Secure is operator-controlled per cfg.Secure (S37 enables it
194 // under TLS).
195 //
196 //nolint:gosec // G124: Secure attribute is intentionally configurable via cfg.Secure.
197 func (c *CookieStore) cookie(value string, maxAge int) *http.Cookie {
198 return &http.Cookie{
199 Name: CookieName,
200 Value: value,
201 Path: c.path,
202 Domain: c.domain,
203 MaxAge: maxAge,
204 Secure: c.secure,
205 HttpOnly: true,
206 SameSite: c.sameSite,
207 }
208 }
209
210 // GenerateKey returns a new random 32-byte key suitable for NewCookieStore.
211 // Used by the operator's first-run setup; production keys come from config.
212 func GenerateKey() ([]byte, error) {
213 key := make([]byte, chacha20poly1305.KeySize)
214 if _, err := rand.Read(key); err != nil {
215 return nil, fmt.Errorf("session: generate key: %w", err)
216 }
217 return key, nil
218 }
219
220 // Sentinel errors callers may check for.
221 var (
222 ErrInvalidKey = errors.New("session: invalid key size")
223 )
224