| 1 |
|
package authn |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"log/slog" |
| 5 |
|
"net/http" |
| 6 |
|
|
| 7 |
|
"go.bigb.es/auxilia/scribe" |
| 8 |
|
|
| 9 |
|
"sourcecraft.dev/bigbes/sr-ht-core/auth" |
| 10 |
|
|
| 11 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/login" |
| 12 |
|
) |
| 13 |
|
|
| 14 |
|
// OptionalCookieMiddleware reads the unified-login cookie and, when it names a |
| 15 |
|
// user this service can resolve, attaches the resolved caller to the request |
| 16 |
|
// context (retrievable with CallerFromContext). It NEVER rejects a request: a |
| 17 |
|
// missing, malformed, undecryptable, or unresolvable cookie leaves the request |
| 18 |
|
// anonymous. This is what allows public browsing and public clones to work |
| 19 |
|
// without credentials — unlike core-go's auth.Middleware, which 401s any |
| 20 |
|
// request lacking a cookie or Authorization header. |
| 21 |
|
// |
| 22 |
|
// The two halves of that sentence are two packages, and the split is the point. |
| 23 |
|
// Decoding the cookie is instance-wide — one session, one seal, one grammar for |
| 24 |
|
// the name inside it — and lives in sr-ht-ecore's login. Turning the name into a |
| 25 |
|
// row is dolt.sr.ht's alone: our user table, our mirror-on-first-sight, our |
| 26 |
|
// answer for a user meta has but we have never seen. Only the second half is |
| 27 |
|
// here, which is also why this is not simply login.Optional: what the rest of |
| 28 |
|
// the service reads out of the context is an *auth.AuthContext with a UserID, |
| 29 |
|
// not a username. |
| 30 |
|
// |
| 31 |
|
// Requires crypto.InitCrypto to have run (server.New does this at startup) and, |
| 32 |
|
// for the user lookup, config.Middleware + database.Middleware installed |
| 33 |
|
// upstream so the context carries the config and database. |
| 34 |
10 |
func OptionalCookieMiddleware() func(http.Handler) http.Handler { |
| 35 |
10 |
return func(next http.Handler) http.Handler { |
| 36 |
10 |
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 37 |
10 |
if ac := resolveCookie(r); ac != nil { |
| 38 |
3 |
r = r.WithContext(WithCaller(r.Context(), ac)) |
| 39 |
3 |
} |
| 40 |
10 |
next.ServeHTTP(w, r) |
| 41 |
|
}) |
| 42 |
|
} |
| 43 |
|
} |
| 44 |
|
|
| 45 |
|
// resolveCookie returns the caller authenticated by the request's unified-login |
| 46 |
|
// cookie, or nil if there is no cookie or it cannot be resolved for any reason. |
| 47 |
|
// Every failure path returns nil (anonymous) — none is fatal. |
| 48 |
|
// |
| 49 |
|
// Suspended users are resolved normally; the suspension flag is carried on the |
| 50 |
|
// caller (via AsCoreCaller) and gates writes at the access-control layer rather |
| 51 |
|
// than being rejected here. |
| 52 |
10 |
func resolveCookie(r *http.Request) *auth.AuthContext { |
| 53 |
10 |
// No cookie, a forged one, a payload that is not core-go's JSON, or a name |
| 54 |
10 |
// that could not be an account name: all "" and all anonymous. Nothing is |
| 55 |
10 |
// logged, because the cookie value is attacker-supplied and arrives on every |
| 56 |
10 |
// request — a warning per bad decode is a log flood anyone can turn on. |
| 57 |
10 |
username := login.UsernameFromRequest(r) |
| 58 |
10 |
if username == "" { |
| 59 |
5 |
return nil |
| 60 |
5 |
} |
| 61 |
|
|
| 62 |
5 |
var ac auth.AuthContext |
| 63 |
5 |
if err := meta.LookupUser(r.Context(), username, &ac); err != nil { |
| 64 |
2 |
// meta/database unreachable or unknown user: degrade to anonymous |
| 65 |
2 |
// rather than failing the request (browsing must keep working). This |
| 66 |
2 |
// one *is* logged: the name has already passed login's grammar, so it |
| 67 |
2 |
// is bounded text, and an unreachable meta is an operator's problem. |
| 68 |
2 |
slog.WarnContext(r.Context(), "resolving the login cookie's user failed", |
| 69 |
2 |
"component", "authn", "username", username, scribe.Err(err)) |
| 70 |
2 |
return nil |
| 71 |
2 |
} |
| 72 |
3 |
ac.AuthMethod = auth.AUTH_COOKIE |
| 73 |
3 |
return &ac |
| 74 |
|
} |