rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1package idear
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "strconv"
9 "time"
10
11 "github.com/carlosframework/rastrillo/password"
12)
13
14// refusedCopy is THE message every refusal renders, for every refused
15// address, on every path.
16//
17// It is a constant and not a format string, and that is a security
18// property rather than a style choice. password.Signup answers a
19// refusal 403 and a duplicate email 422, so a refused address is
20// already distinguishable from a registered one — an existence bit the
21// old always-say-duplicate behaviour hid. The design accepts that
22// trade because the duplicate message was simply FALSE. What it does
23// not accept is making the 403 a finer oracle than the status code
24// alone: copy that named the address, or that differed between "you
25// were invited but hold no token" and "you were never invited", would
26// answer questions the visitor is not entitled to ask.
27//
28// Which address was refused, and why, is logged instead. The framework
29// deliberately does not log refusals; that is idear's job.
30const refusedCopy = "Sign-up here is by invitation."
31
32// authorizeTimeout bounds one keymail admission. It sits above
33// rastrillo/db's busy_timeout(5000) on purpose: a request that waits
34// out a normal lock contention must still be allowed to succeed, and
35// only a writer that is genuinely stuck should be abandoned. Expiring
36// it is refused-and-logged like any other storage failure, because
37// Authorize has no way to say "try again".
38const authorizeTimeout = 10 * time.Second
39
40// refused builds the one refusal this package ever returns.
41// password.Signup renders the refusal's OWN message (errors.As, not
42// Error()), so nothing wrapped around it can reach the page.
43func refused() error { return password.Refuse(refusedCopy) }
44
45// inviteTokenCtxKey is the context key CarryToken stashes the posted
46// invitation token under — a private struct type, so nothing outside
47// this package can plant one.
48type inviteTokenCtxKey struct{}
49
50// CarryToken reads the "invite" field from a posted form and stashes
51// it in the request context, where Admitting reads it back.
52//
53// It exists because password.Config.Create is
54// func(ctx, email, hash) (int64, error): it receives NO *http.Request,
55// so admission cannot read the token off the form itself. It does
56// receive r.Context(). This middleware is the whole bridge:
57//
58// mux.Handle("POST /signup", rs.CarryToken(http.HandlerFunc(ph.Signup)))
59//
60// MOUNTING IT IS MANDATORY on the password path. Without it the token
61// never reaches admission, every invited signup is refused, and the
62// instance is effectively closed — which is loud and safe rather than
63// quiet and permissive, and is exactly why admission refuses a
64// token-less signup instead of falling back to the address.
65//
66// It parses the form so it can read one field, and http.Request
67// caches that parse, so password.Signup's own ParseForm downstream is
68// a no-op rather than a second read of an already-consumed body. One
69// consequence is worth knowing: a body that FAILS to parse fails here,
70// and downstream ParseForm then returns nil against the empty form it
71// left behind — the request proceeds as a signup with no email, which
72// password re-renders as "Enter a valid email address."
73func (rs *Roster) CarryToken(next http.Handler) http.Handler {
74 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75 if err := r.ParseForm(); err != nil {
76 rs.cfg.Logger.Warn("idear: could not parse the signup form to carry its invitation token",
77 "path", r.URL.Path, "err", err)
78 next.ServeHTTP(w, r)
79 return
80 }
81 if token := r.PostFormValue("invite"); token != "" {
82 r = r.WithContext(context.WithValue(r.Context(), inviteTokenCtxKey{}, token))
83 }
84 next.ServeHTTP(w, r)
85 })
86}
87
88// inviteToken reads back what CarryToken stashed, or "".
89func inviteToken(ctx context.Context) string {
90 token, _ := ctx.Value(inviteTokenCtxKey{}).(string)
91 return token
92}
93
94// TokenFrom returns the invitation token CarryToken lifted off this
95// request's form, or "".
96//
97// It exists for one caller — the app's password.Config.RenderSignup —
98// and it closes a gap that is otherwise silent. password re-renders
99// the signup page on a validation failure (a password under eight
100// characters, say) with a PageData carrying Error, Email and ReturnTo
101// and NOWHERE to put a token. A hidden "invite" field seeded from that
102// page data comes back empty, so the invitee's SECOND attempt is
103// refused for holding no token — and the symptom, "invited people can
104// never join", shows up only on the second try. Seed it from here
105// instead:
106//
107// func renderSignup(w http.ResponseWriter, r *http.Request, d password.PageData) {
108// render(w, r, "signup", signupView{PageData: d, Invite: idear.TokenFrom(r)})
109// }
110//
111// It reads what CarryToken stashed and nothing else. That keeps the
112// field name inside the package that chose it, and it means the app
113// never has to reason about whether the body has already been parsed.
114//
115// On a request that did not pass through CarryToken — the GET signup
116// page, or a POST where the middleware is not mounted — it is "". The
117// second case is deliberate: a fallback that re-read the body here
118// would paper over a missing CarryToken, which is the one
119// misconfiguration that closes the instance to every invitee.
120func TokenFrom(r *http.Request) string { return inviteToken(r.Context()) }
121
122// subjectForID is the Subject a Member row must carry for an app whose
123// identity plugin is password.
124//
125// password mints its session as
126// sessions.Session{Subject: strconv.FormatInt(id, 10), ...} —
127// signInAndRedirect in password/handlers.go, the one place both Signin
128// and Signup pass through. Any other spelling here produces a member
129// row that no session this app ever mints can resolve: the person
130// signs in successfully and then 404s on every guarded route forever.
131func subjectForID(id int64) string { return strconv.FormatInt(id, 10) }
132
133// Admitting wraps the app's own user-creating function with idear's
134// admission policy, for password.Config.Create:
135//
136// password.New(password.Config{Create: rs.Admitting(createUser(d.G)), ...})
137//
138// It decides the ROLE BEFORE it reads anything else the form says —
139// the role is never read from the form on any path — in this order:
140//
141// 1. the roster has ZERO ROWS: the first arrival claims the instance
142// as Owner;
143// 2. the request carries a VALID invitation token (unexpired,
144// unrevoked, unaccepted) whose Email equals the submitted address:
145// the invitation's role;
146// 3. Config.OpenSignUp: RoleMember;
147// 4. otherwise: refused.
148//
149// POSSESSION OF THE TOKEN IS REQUIRED. An email match alone is not
150// enough, and this is the vulnerability the whole design was revised
151// around: password.Signup never verifies an address, so admitting on
152// the address alone would let anyone who learns that admin@corp.test
153// was invited register that address with their OWN password first and
154// land at the invited role. The token arrives only via CarryToken; see
155// its doc comment for what happens when that is not mounted.
156//
157// Rule 2 compares NORMALISED addresses on both sides. Invitation
158// emails are stored trimmed and lowercased, and password lowercases
159// and trims before calling Create; comparing anything else would make
160// an invitation match only when the invitee retyped the exact
161// capitalisation they were sent.
162//
163// On success it calls the app's create, then writes the Member with
164// Subject = subjectForID(id), CAS-accepting the invitation in the same
165// transaction as the member write.
166//
167// THE ORPHAN, which is designed and not an accident: create and the
168// member write cannot be one transaction, because create is the app's
169// opaque function over the app's own tables. A failure between them
170// leaves a user row with no membership. That is the fail-closed
171// direction — the person can sign in and 404s, rather than being
172// admitted unmembered — and a plain retry does NOT heal it (the retry's
173// create fails on the now-duplicate email and never reaches the member
174// write). The signed-in reconciliation route, POST /invitations/{token},
175// is what heals it, and it is a designed path.
176//
177// A losing racer in a first-signup claim is the same orphan by another
178// route, and is refused with the same copy.
179func (rs *Roster) Admitting(create func(ctx context.Context, email, hash string) (int64, error)) func(ctx context.Context, email, hash string) (int64, error) {
180 return func(ctx context.Context, email, hash string) (int64, error) {
181 if create == nil {
182 // A wiring bug, refused as a storage failure rather than
183 // as policy: it must not read to a visitor as "you are not
184 // invited", and it must not create anybody either.
185 return 0, errors.New("idear: Admitting was given a nil create function")
186 }
187 addr := normalizeEmail(email)
188 if addr == "" {
189 return 0, fmt.Errorf("%w: admission needs a non-empty email", ErrInvalidEmail)
190 }
191 token := inviteToken(ctx)
192
193 // 1. The claim. Advisory: IsEmpty reads the read pool and can
194 // lag a concurrent Claim by a WAL snapshot. Both ways of being
195 // wrong are fail-closed — a stale "not empty" refuses a first
196 // signup that could have claimed, and a stale "empty" reaches
197 // Claim, whose own transaction is where the answer binds.
198 empty, err := rs.IsEmpty(ctx)
199 if err != nil {
200 rs.cfg.Logger.Error("idear: admission could not count the roster", "email", addr, "err", err)
201 return 0, fmt.Errorf("idear: admission: %w", err)
202 }
203 if empty {
204 // Deliberately unconditional, ahead of any token: an
205 // unclaimed instance's first account is its Owner, which
206 // outranks any role an invitation could carry. An
207 // invitation presented here simply stays pending.
208 id, err := create(ctx, addr, hash)
209 if err != nil {
210 return 0, err
211 }
212 if _, err := rs.Claim(ctx, subjectForID(id), addr, ""); err != nil {
213 if errors.Is(err, ErrOwnerExists) {
214 rs.cfg.Logger.Warn("idear: admission lost the claim race; the app user is an ORPHAN until the reconciliation route heals it",
215 "email", addr, "app_id", id)
216 return 0, refused()
217 }
218 rs.cfg.Logger.Error("idear: admission could not claim the instance; the app user is an ORPHAN",
219 "email", addr, "app_id", id, "err", err)
220 return 0, fmt.Errorf("idear: admission: %w", err)
221 }
222 rs.cfg.Logger.Info("idear: admitted the first account as owner", "email", addr, "app_id", id)
223 return id, nil
224 }
225
226 // 2. The token, PLUS an email match. The lookup is advisory
227 // too — Accept's CAS is what actually consumes the invitation,
228 // and it re-checks unexpired/unrevoked/unaccepted inside its
229 // own transaction. Only the email match is decided here, and
230 // an invitation's Email is never updated after it is written,
231 // so there is nothing for a racing writer to change under it.
232 if token != "" {
233 inv, err := rs.pendingInvitation(ctx, token)
234 switch {
235 case err == nil && normalizeEmail(inv.Email) == addr:
236 id, err := create(ctx, addr, hash)
237 if err != nil {
238 return 0, err
239 }
240 if _, err := rs.Accept(ctx, token, subjectForID(id), ""); err != nil {
241 rs.cfg.Logger.Error("idear: admission could not redeem an invitation it had just validated; the app user is an ORPHAN",
242 "email", addr, "app_id", id, "err", err)
243 return 0, fmt.Errorf("idear: admission: %w", err)
244 }
245 rs.cfg.Logger.Info("idear: admitted an invited address", "email", addr, "app_id", id, "role", string(inv.Role))
246 return id, nil
247 case err == nil:
248 // The token is real and live, but it is not this
249 // address's. Never admitted on that basis, and never
250 // told apart from a bad token in the response.
251 rs.cfg.Logger.Warn("idear: signup presented an invitation token issued to another address",
252 "email", addr)
253 case errors.Is(err, ErrNoInvitation):
254 rs.cfg.Logger.Warn("idear: signup presented an invitation token that is not redeemable",
255 "email", addr)
256 default:
257 rs.cfg.Logger.Error("idear: admission could not look up an invitation", "email", addr, "err", err)
258 return 0, fmt.Errorf("idear: admission: %w", err)
259 }
260 // Falls through: an unusable token is worth no more than
261 // no token at all, so an OPEN instance still admits at
262 // RoleMember and a closed one refuses. What it can never
263 // do is contribute a role.
264 }
265
266 // 3. Open sign-up.
267 if rs.cfg.OpenSignUp {
268 id, err := create(ctx, addr, hash)
269 if err != nil {
270 return 0, err
271 }
272 if _, err := rs.addMember(ctx, subjectForID(id), addr, "", RoleMember); err != nil {
273 rs.cfg.Logger.Error("idear: admission could not write an open-signup member; the app user is an ORPHAN",
274 "email", addr, "app_id", id, "err", err)
275 return 0, fmt.Errorf("idear: admission: %w", err)
276 }
277 rs.cfg.Logger.Info("idear: admitted an open sign-up", "email", addr, "app_id", id)
278 return id, nil
279 }
280
281 // 4. Refused. The address is logged because the copy cannot
282 // carry it; had_token says whether CarryToken delivered
283 // anything at all, which is how a mis-mounted CarryToken is
284 // told apart from a genuinely uninvited visitor.
285 rs.cfg.Logger.Info("idear: refused a signup", "email", addr, "had_token", token != "")
286 return 0, refused()
287 }
288}
289
290// Authorize is the keymail adapter: it fills auth.Config.Authorize,
291// whose contract is "given a VERIFIED address, may it have a session?"
292//
293// auth.New(auth.Config{Sessions: sess, Authorize: rs.Authorize})
294//
295// In order: an ACTIVE member is admitted; an empty roster is claimed by
296// the caller as Owner; otherwise a pending invitation FOR THAT ADDRESS
297// is accepted, writing the member with Subject = the address, which is
298// what auth mints as the session subject (auth/handlers.go's admit:
299// sessions.Session{Subject: id.Address}). Anything else is refused.
300//
301// WHY THIS MAY ADMIT ON THE ADDRESS ALONE, where Admitting may not:
302// auth calls this only after delivering a link to that address and
303// seeing it come back, so the address IS the verified credential here.
304// password verifies nothing, which is why its path demands the token
305// as well. Never call Authorize with an address a visitor merely
306// typed.
307//
308// It returns a bool with NO ERROR CHANNEL, so a database failure is
309// indistinguishable from a policy denial to the visitor — auth renders
310// both as the same 403. idear logs the distinction it cannot render;
311// an operator watching for "This address is verified but not admitted
312// here." should read the log before believing it is policy.
313//
314// Two further asymmetries with the password path, stated so nobody
315// reads the guarantee as uniform. Authorize runs BEFORE auth's
316// SecondFactor hook, so a Member row can be written for a sign-in a
317// 2FA gate never completes — self-healing on the next attempt. And
318// only keymail's admission consults this at all: password's Signin
319// runs Lookup, Verify and mint with no idear involvement, so a
320// deactivated member can still MINT a session under password. What
321// stops them there is Require on every route, the landing page
322// included.
323func (rs *Roster) Authorize(address string) bool {
324 // auth hands over an address and nothing else: there is no request
325 // context to inherit, so there is nothing to cancel this when the
326 // visitor gives up or the server shuts down. A background context
327 // with no deadline would park auth's sign-in handler on a stuck
328 // writer indefinitely, so the deadline is invented here.
329 ctx, cancel := context.WithTimeout(context.Background(), authorizeTimeout)
330 defer cancel()
331
332 addr := normalizeEmail(address)
333 if addr == "" {
334 rs.cfg.Logger.Warn("idear: keymail admission asked about an empty address")
335 return false
336 }
337
338 // Under keymail the session Subject IS the verified address, so
339 // the member lookup is by address.
340 m, err := rs.BySubject(ctx, addr)
341 switch {
342 case err == nil && m.Active():
343 return true
344 case err == nil:
345 rs.cfg.Logger.Info("idear: keymail admission refused a deactivated member",
346 "address", addr, "member_id", m.ID)
347 return false
348 case errors.Is(err, ErrNotFound):
349 // Not a member yet. Carry on to the claim and the invitation.
350 default:
351 rs.cfg.Logger.Error("idear: keymail admission could not resolve the address; refusing, though this is NOT a policy denial",
352 "address", addr, "err", err)
353 return false
354 }
355
356 empty, err := rs.IsEmpty(ctx)
357 if err != nil {
358 rs.cfg.Logger.Error("idear: keymail admission could not count the roster; refusing, though this is NOT a policy denial",
359 "address", addr, "err", err)
360 return false
361 }
362 if empty {
363 switch _, err := rs.Claim(ctx, addr, addr, ""); {
364 case err == nil:
365 rs.cfg.Logger.Info("idear: keymail admission claimed the instance", "address", addr)
366 return true
367 case errors.Is(err, ErrOwnerExists):
368 // Lost the race to another first arrival. They may still
369 // hold an invitation of their own, so this is not the end
370 // of the road.
371 rs.cfg.Logger.Info("idear: keymail admission lost the claim race", "address", addr)
372 default:
373 rs.cfg.Logger.Error("idear: keymail admission could not claim the instance; refusing, though this is NOT a policy denial",
374 "address", addr, "err", err)
375 return false
376 }
377 }
378
379 switch m, err := rs.acceptByAddress(ctx, addr, addr, ""); {
380 case err == nil:
381 rs.cfg.Logger.Info("idear: keymail admission redeemed an invitation", "address", addr, "role", string(m.Role))
382 return true
383 case errors.Is(err, ErrNoInvitation):
384 rs.cfg.Logger.Info("idear: keymail admission refused an address with no member row and no redeemable invitation",
385 "address", addr)
386 default:
387 rs.cfg.Logger.Error("idear: keymail admission could not redeem an invitation; refusing, though this is NOT necessarily a policy denial",
388 "address", addr, "err", err)
389 }
390 return false
391}
392