~bigbes/sr-ht-ecore

ref: 43ad9287cc063fc6a74e1398a3a8b642631cbcae sr-ht-ecore/login/login.go -rw-r--r-- 17.6 KiB
43ad9287 — Eugene Blikh bearer: say what IsRefusal does not answer for 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
// Package login decodes the unified-login cookie of a self-hosted SourceHut
// instance into the username it names, and is the one copy of that decode for
// every custom service on it (compare, spec, dolt, cover, bench, tokens).
//
// There is exactly one session on the instance. meta.sr.ht sets one cookie,
// sr.ht.unified-login.v1, on the parent domain, sealed as a fernet token with
// the instance-wide [sr.ht] network-key — which is also why a custom service has
// to be served from under that shared domain: from anywhere else the cookie is
// simply never sent, every viewer looks anonymous, and on a service where every
// page needs an identity that means nobody can get in at all, because there is
// no second way in.
//
// One session, but six decoders. Each of the six services wrote its own, and
// each of them makes the same five decisions:
//
//  1. decrypt with crypto.DecryptWithoutExpiration and not crypto.Decrypt*;
//  2. unmarshal the payload into auth.AuthCookie and take the name;
//  3. strip the leading '~';
//  4. treat every failure as anonymity rather than as an error;
//  5. validate the name before it reaches path construction, a log line or a
//     SQL parameter.
//
// Five services deciding (1) or (5) independently is five chances for one of
// them to get it wrong invisibly — and two of the six donors had already dropped
// (5) entirely. That is the reason this is shared, and it is the same reason
// grants is: a rule on the security path that exists in six copies is a rule
// that holds in five.
//
// # Why DecryptWithoutExpiration
//
// The unified-login cookie carries no service-side TTL, and it must not be given
// one here. Its lifetime is the browser cookie's own Expires plus meta.sr.ht's
// ability to rotate the network key — both instance-wide facts. A service that
// added an expiry to the decrypt would log a viewer out of that one service on a
// schedule no sibling shares, and a viewer who is still logged in everywhere
// else does not read that as "I was logged out": they read it as "this service
// is broken". core-go's own (unexported) cookieAuth decrypts without expiration
// for the same reason, and the whole point of the shared decoder is that nobody
// has to rediscover this.
//
// A service-side TTL is right in exactly one place, and it is the opposite case:
// the short-lived Internal authorization a service seals for a service-to-service
// call, which is a credential this instance issues and can therefore bound.
//
// # Why every failure is anonymity
//
// No cookie, a forged or truncated one, a well-sealed payload that is not the
// JSON core-go writes, a payload with no name, a name that could not be a
// username — all of them return "". None returns an error, and none is logged.
//
// The reasons are indistinguishable to the viewer: every one of them means "log
// in again", and the browser has no way to act on the difference. Reporting them
// would turn a tab left open across a key rotation into a broken site rather
// than a logged-out one, and — on the services where public browsing and public
// clones must keep working without credentials — would break anonymous reads for
// everybody the moment anything about the cookie went wrong. Anonymous is the
// ordinary state of a first-time visitor; it is not a failure to be reported.
//
// They are not logged either, and that is deliberate: the cookie value is
// attacker-supplied, arrives on every request, and a warning per failed decrypt
// is a log-flood anybody on the internet can turn on.
//
// # The validator
//
// A decoded name is attacker-influenced text that goes straight on to be joined
// into a filesystem path, interpolated into a log line, put in a WHERE clause,
// and — on a miss — sent to meta.sr.ht inside a GraphQL query. The cheapest place
// to say "this could not be a username" is before any of those sees it.
//
// So this package ships a default rule, ValidName, rather than requiring one.
// The grammar is not a per-service policy: these are meta.sr.ht account names,
// the same on every service of the instance, so a per-service answer to "what
// may a username look like" is six answers to a question that has one. Requiring
// a validator would also put the safe path behind an extra argument, and the two
// donors that validate nothing at all are precisely the two that would go on
// passing nothing.
//
// WithValidator is for a service that wants to narrow further, or that already
// owns the rule (core.ValidateOwner) and wants one definition in its own tests
// as well as here. It cannot widen the rule to nothing: WithValidator(nil)
// restores the default, and there is deliberately no spelling of "accept
// anything" in this API.
//
// ValidName is a little wider than meta's registration grammar on purpose. meta
// is the authority on which names exist and this package is not; the job here is
// only to exclude what could not be a username *and* could hurt something
// downstream — a path separator, a NUL, a non-ASCII byte, a leading '-', "." and
// "..".
//
// # Optional and Required
//
// The split is the load-bearing decision in this package, and the reason the six
// copies diverged in the first place. Every donor wrote a middleware, and every
// donor folded its own gating policy into it: dolt's never refuses because
// public browsing and public clones have to work uncredentialed; compare's never
// 401s because git.sr.ht decides visibility downstream; tokens.sr.ht is the
// opposite and needs a viewer sent to meta's login page, since every page there
// is somebody's own token list. Those are three different policies over one
// decode — so shipping a single middleware here would just force two thirds of
// the services to write the other half again, which is how six copies happened.
//
// Optional resolves the identity and never refuses: it stores what it found,
// anonymous included, and calls the next handler. Required refuses through a
// deny handler the service supplies, so the refusal looks like the rest of that
// service's surface. deny is a handler and not a status code because the right
// refusal for a browser surface is usually a redirect to
// {meta}/login?return_to=..., and this package holds no configuration literal —
// it does not know meta's origin or the service's own, and chrome, which does,
// builds that URL.
//
// Install one or the other, not both: Required does everything Optional does.
//
// # What did not move here
//
// The other half of what the donors call "authn" stays in each service: turning
// the username into a local row — auth.LookupUser, the mirror of meta's profile
// into the service's own user table, the id every ownership row keys on. That
// touches each service's own schema, its own db.User, and its own answer for a
// user it has never seen, and a shared package should have no opinion about any
// of it. The line is exactly here: a name is instance-wide, a row is not.
//
// # Usage
//
//	r.Use(login.Optional())                       // public surface
//	r.Use(login.Required(s.redirectToLogin))      // surface where every page needs a viewer
//
//	username := login.FromContext(r.Context())    // "" is anonymous
//	page := svc.Page(r, "Title", username)
//
// crypto.InitCrypto must have run before any of this: the network key lives in
// that package's globals. It is not checked at request time, because there is
// nothing useful this package could do about it there — a process that skipped
// it decodes no cookie at all and every viewer is anonymous.
package login

import (
	"context"
	"encoding/json"
	"net/http"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
)

// CookieName is the unified-login cookie meta.sr.ht sets on the parent domain,
// and the only session any service on the instance has.
const CookieName = "sr.ht.unified-login.v1"

// MaxUsernameLen bounds a decoded name. meta.sr.ht already bounds usernames at
// registration, so this is a sanity cap on what arrives in a cookie rather than
// the authority on the question — it is here so that a name whose length alone
// makes it absurd never reaches a query or a path.
const MaxUsernameLen = 64

// Message is what Required's default refusal says. A service that renders its
// own error page should pass its own deny handler and may still want this
// sentence, so that the six services answer the same one.
const Message = "You have to be logged in to view this page."

// ValidName reports whether name could be a meta.sr.ht account name: non-empty,
// at most MaxUsernameLen bytes, ASCII letters, digits and the three separators
// meta allows ('.', '_', '-'), not beginning with '-', and neither "." nor "..".
//
// It is the default validator, and it is deliberately conservative rather than
// exact. Being wider than meta's registration grammar costs nothing — meta
// decides which names exist, and a name that passes here but belongs to nobody
// simply fails to resolve — while being narrower would log real accounts out of
// every service at once. What it must catch is the other direction: '/' and '\'
// so that a name cannot escape a repository root through filepath.Join, control
// bytes and NUL so that it cannot forge a line in a log, non-ASCII so that two
// spellings of one name cannot compare unequal in Go and equal in Postgres, a
// leading '-' so that it cannot become a flag to something exec'd, and "." /
// ".." because those are directories rather than people.
//
// It is exported so that a service can build its own rule on top of it, and so
// that a service whose own core package owns the rule can assert the two agree.
func ValidName(name string) bool {
	if name == "" || len(name) > MaxUsernameLen {
		return false
	}
	if name == "." || name == ".." {
		return false
	}
	if name[0] == '-' {
		return false
	}
	for i := 0; i < len(name); i++ {
		if !isNameByte(name[i]) {
			return false
		}
	}
	return true
}

// isNameByte reports whether c may appear in an account name.
func isNameByte(c byte) bool {
	switch {
	case c >= 'a' && c <= 'z':
		return true
	case c >= 'A' && c <= 'Z':
		return true
	case c >= '0' && c <= '9':
		return true
	case c == '.' || c == '_' || c == '-':
		return true
	default:
		return false
	}
}

// Option adjusts how a cookie is decoded. The zero set of options is the one
// every service wants; see WithValidator for the only thing there is to vary.
type Option func(*options)

// options is the resolved configuration of one decode.
type options struct {
	valid func(string) bool
}

// WithValidator supplies the service's own username rule, replacing ValidName.
//
// Use it to narrow — a service that keeps a directory per owner may want a
// tighter character set than the shared one, and a service whose core package
// already owns the rule should pass that rule rather than keep two.
//
// A nil validator restores the default rather than switching validation off.
// That is not tidiness: "no validator" is the one setting that would quietly put
// a hostile cookie payload into a path, a log line and a query, and it should
// not be reachable by passing a zero value, a nil field or a func that a
// refactor stopped assigning.
func WithValidator(valid func(string) bool) Option {
	return func(o *options) {
		if valid == nil {
			return
		}
		o.valid = valid
	}
}

// resolve applies opts over the defaults. The middlewares call it once, when
// they are built, so that a per-request path never rebuilds configuration.
func resolve(opts []Option) options {
	o := options{valid: ValidName}
	for _, opt := range opts {
		if opt != nil {
			opt(&o)
		}
	}
	return o
}

// Username decodes a unified-login cookie value into the bare username it
// carries, or "" for anything that is not a usable identity.
//
// It takes the sealed value rather than a request because not every caller has
// one: an RPC that forwards the cookie, a hook, a test. UsernameFromRequest is
// this over an *http.Request.
//
// Every failure is "" and none is an error; see the package doc for why that is
// the whole error contract of this package.
func Username(value string, opts ...Option) string {
	return decode(value, resolve(opts))
}

// UsernameFromRequest is Username over the request's cookie, returning "" when
// the header is absent — a request without our cookie is a first-time visitor,
// which is the ordinary anonymous state and not a problem.
func UsernameFromRequest(r *http.Request, opts ...Option) string {
	return fromRequest(r, resolve(opts))
}

// decode is the decode itself, against options already resolved.
func decode(value string, o options) string {
	if value == "" {
		return ""
	}

	// DecryptWithoutExpiration, never a decrypt with a TTL: see the package doc.
	payload := crypto.DecryptWithoutExpiration([]byte(value))
	if payload == nil {
		// Forged, truncated, or sealed with a key this instance no longer holds.
		// Indistinguishable from here, and all three mean "log in again".
		return ""
	}

	var claims auth.AuthCookie
	if err := json.Unmarshal(payload, &claims); err != nil {
		// Well-sealed but not the JSON core-go writes.
		return ""
	}

	// Cookies carry the bare username; strip a leading '~' defensively in case
	// something upstream stored the canonical "~user" form. Only one, and only
	// at the front: "~~x" is not a name and must not be repaired into one.
	name := strings.TrimPrefix(claims.Name, "~")
	if !o.valid(name) {
		// A name that could not be a username is not an identity. Refusing it
		// here is what keeps a hostile payload out of path construction, log
		// lines and SQL parameters alike.
		return ""
	}
	return name
}

// fromRequest is UsernameFromRequest against options already resolved.
func fromRequest(r *http.Request, o options) string {
	cookie, err := r.Cookie(CookieName)
	if err != nil {
		return ""
	}
	return decode(cookie.Value, o)
}

// ctxKey is this package's private context key. A struct{} rather than an int
// so that no other package can collide with it even by accident.
type ctxKey struct{}

// NewContext returns a copy of ctx carrying username. Optional and Required call
// it; it is exported for the callers that resolved an identity some other way —
// an RPC that was handed a cookie value, a test that wants a request as if it
// had come through the middleware.
func NewContext(ctx context.Context, username string) context.Context {
	return context.WithValue(ctx, ctxKey{}, username)
}

// FromContext returns the username stored by Optional, Required or NewContext,
// and "" when there is none.
//
// "" means anonymous, and it also means "no middleware ran". They are the same
// answer on purpose: neither request has an identity this process can show, and
// distinguishing them would invite a handler to treat a missing middleware as a
// special case — which is a fail-open branch waiting to be written.
func FromContext(ctx context.Context) string {
	username, _ := ctx.Value(ctxKey{}).(string)
	return username
}

// Optional resolves the viewer and never refuses: it stores the identity —
// anonymous included — in the request context and calls next.
//
// This is the middleware for a surface where anonymous is a legitimate viewer:
// public repositories, public clones, anything a bookmark or a probe has to keep
// reaching. Handlers read the answer with FromContext and decide for themselves
// what an empty username may see.
//
// The value is stored even when it is empty, so that a handler behind this
// middleware always reads a resolved answer rather than "nobody looked yet".
//
// The returned value has the shape every net/http middleware chain expects,
// including chi's Use.
func Optional(opts ...Option) func(http.Handler) http.Handler {
	o := resolve(opts)
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			username := fromRequest(r, o)
			next.ServeHTTP(w, r.WithContext(NewContext(r.Context(), username)))
		})
	}
}

// Required resolves the viewer and refuses an anonymous one through deny.
//
// It is Optional for a surface where every page belongs to somebody — a token
// list, a settings page, a service whose whole UI is the viewer's own — and the
// gating is a parameter rather than a policy of this package precisely because
// the six donors each had a different one.
//
// deny renders the refusal. For a browser surface that is nearly always a 302 to
// {meta}/login?return_to=<this page>, which is why deny is a handler: the URL is
// built from configuration this package deliberately does not hold. A nil deny
// answers a plain 401 carrying Message — a usable floor so that a missing
// handler is not a nil dereference on the login path, not an invitation to leave
// it nil on a surface humans use.
//
// A request that gets through carries its identity in the context exactly as
// under Optional, so handlers behind either middleware read FromContext and
// nothing else. Install one or the other, not both.
func Required(deny http.HandlerFunc, opts ...Option) func(http.Handler) http.Handler {
	o := resolve(opts)
	if deny == nil {
		deny = denyPlain
	}
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			username := fromRequest(r, o)
			if username == "" {
				deny(w, r)
				return
			}
			next.ServeHTTP(w, r.WithContext(NewContext(r.Context(), username)))
		})
	}
}

// denyPlain is the refusal Required uses when the caller supplies none.
//
// 401 rather than 403: what is missing is a session, and the viewer's move is to
// log in. It carries no WWW-Authenticate header because there is no HTTP
// authentication scheme to name — the session is a cookie meta.sr.ht sets — and
// a browser must not be shown a basic-auth prompt for it.
func denyPlain(w http.ResponseWriter, _ *http.Request) {
	http.Error(w, Message, http.StatusUnauthorized)
}