package idear import ( "context" "errors" "fmt" "net/http" "strconv" "time" "github.com/carlosframework/rastrillo/password" ) // refusedCopy is THE message every refusal renders, for every refused // address, on every path. // // It is a constant and not a format string, and that is a security // property rather than a style choice. password.Signup answers a // refusal 403 and a duplicate email 422, so a refused address is // already distinguishable from a registered one — an existence bit the // old always-say-duplicate behaviour hid. The design accepts that // trade because the duplicate message was simply FALSE. What it does // not accept is making the 403 a finer oracle than the status code // alone: copy that named the address, or that differed between "you // were invited but hold no token" and "you were never invited", would // answer questions the visitor is not entitled to ask. // // Which address was refused, and why, is logged instead. The framework // deliberately does not log refusals; that is idear's job. const refusedCopy = "Sign-up here is by invitation." // authorizeTimeout bounds one keymail admission. It sits above // rastrillo/db's busy_timeout(5000) on purpose: a request that waits // out a normal lock contention must still be allowed to succeed, and // only a writer that is genuinely stuck should be abandoned. Expiring // it is refused-and-logged like any other storage failure, because // Authorize has no way to say "try again". const authorizeTimeout = 10 * time.Second // refused builds the one refusal this package ever returns. // password.Signup renders the refusal's OWN message (errors.As, not // Error()), so nothing wrapped around it can reach the page. func refused() error { return password.Refuse(refusedCopy) } // inviteTokenCtxKey is the context key CarryToken stashes the posted // invitation token under — a private struct type, so nothing outside // this package can plant one. type inviteTokenCtxKey struct{} // CarryToken reads the "invite" field from a posted form and stashes // it in the request context, where Admitting reads it back. // // It exists because password.Config.Create is // func(ctx, email, hash) (int64, error): it receives NO *http.Request, // so admission cannot read the token off the form itself. It does // receive r.Context(). This middleware is the whole bridge: // // mux.Handle("POST /signup", rs.CarryToken(http.HandlerFunc(ph.Signup))) // // MOUNTING IT IS MANDATORY on the password path. Without it the token // never reaches admission, every invited signup is refused, and the // instance is effectively closed — which is loud and safe rather than // quiet and permissive, and is exactly why admission refuses a // token-less signup instead of falling back to the address. // // It parses the form so it can read one field, and http.Request // caches that parse, so password.Signup's own ParseForm downstream is // a no-op rather than a second read of an already-consumed body. One // consequence is worth knowing: a body that FAILS to parse fails here, // and downstream ParseForm then returns nil against the empty form it // left behind — the request proceeds as a signup with no email, which // password re-renders as "Enter a valid email address." func (rs *Roster) CarryToken(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { rs.cfg.Logger.Warn("idear: could not parse the signup form to carry its invitation token", "path", r.URL.Path, "err", err) next.ServeHTTP(w, r) return } if token := r.PostFormValue("invite"); token != "" { r = r.WithContext(context.WithValue(r.Context(), inviteTokenCtxKey{}, token)) } next.ServeHTTP(w, r) }) } // inviteToken reads back what CarryToken stashed, or "". func inviteToken(ctx context.Context) string { token, _ := ctx.Value(inviteTokenCtxKey{}).(string) return token } // TokenFrom returns the invitation token CarryToken lifted off this // request's form, or "". // // It exists for one caller — the app's password.Config.RenderSignup — // and it closes a gap that is otherwise silent. password re-renders // the signup page on a validation failure (a password under eight // characters, say) with a PageData carrying Error, Email and ReturnTo // and NOWHERE to put a token. A hidden "invite" field seeded from that // page data comes back empty, so the invitee's SECOND attempt is // refused for holding no token — and the symptom, "invited people can // never join", shows up only on the second try. Seed it from here // instead: // // func renderSignup(w http.ResponseWriter, r *http.Request, d password.PageData) { // render(w, r, "signup", signupView{PageData: d, Invite: idear.TokenFrom(r)}) // } // // It reads what CarryToken stashed and nothing else. That keeps the // field name inside the package that chose it, and it means the app // never has to reason about whether the body has already been parsed. // // On a request that did not pass through CarryToken — the GET signup // page, or a POST where the middleware is not mounted — it is "". The // second case is deliberate: a fallback that re-read the body here // would paper over a missing CarryToken, which is the one // misconfiguration that closes the instance to every invitee. func TokenFrom(r *http.Request) string { return inviteToken(r.Context()) } // subjectForID is the Subject a Member row must carry for an app whose // identity plugin is password. // // password mints its session as // sessions.Session{Subject: strconv.FormatInt(id, 10), ...} — // signInAndRedirect in password/handlers.go, the one place both Signin // and Signup pass through. Any other spelling here produces a member // row that no session this app ever mints can resolve: the person // signs in successfully and then 404s on every guarded route forever. func subjectForID(id int64) string { return strconv.FormatInt(id, 10) } // Admitting wraps the app's own user-creating function with idear's // admission policy, for password.Config.Create: // // password.New(password.Config{Create: rs.Admitting(createUser(d.G)), ...}) // // It decides the ROLE BEFORE it reads anything else the form says — // the role is never read from the form on any path — in this order: // // 1. the roster has ZERO ROWS: the first arrival claims the instance // as Owner; // 2. the request carries a VALID invitation token (unexpired, // unrevoked, unaccepted) whose Email equals the submitted address: // the invitation's role; // 3. Config.OpenSignUp: RoleMember; // 4. otherwise: refused. // // POSSESSION OF THE TOKEN IS REQUIRED. An email match alone is not // enough, and this is the vulnerability the whole design was revised // around: password.Signup never verifies an address, so admitting on // the address alone would let anyone who learns that admin@corp.test // was invited register that address with their OWN password first and // land at the invited role. The token arrives only via CarryToken; see // its doc comment for what happens when that is not mounted. // // Rule 2 compares NORMALISED addresses on both sides. Invitation // emails are stored trimmed and lowercased, and password lowercases // and trims before calling Create; comparing anything else would make // an invitation match only when the invitee retyped the exact // capitalisation they were sent. // // On success it calls the app's create, then writes the Member with // Subject = subjectForID(id), CAS-accepting the invitation in the same // transaction as the member write. // // THE ORPHAN, which is designed and not an accident: create and the // member write cannot be one transaction, because create is the app's // opaque function over the app's own tables. A failure between them // leaves a user row with no membership. That is the fail-closed // direction — the person can sign in and 404s, rather than being // admitted unmembered — and a plain retry does NOT heal it (the retry's // create fails on the now-duplicate email and never reaches the member // write). The signed-in reconciliation route, POST /invitations/{token}, // is what heals it, and it is a designed path. // // A losing racer in a first-signup claim is the same orphan by another // route, and is refused with the same copy. func (rs *Roster) Admitting(create func(ctx context.Context, email, hash string) (int64, error)) func(ctx context.Context, email, hash string) (int64, error) { return func(ctx context.Context, email, hash string) (int64, error) { if create == nil { // A wiring bug, refused as a storage failure rather than // as policy: it must not read to a visitor as "you are not // invited", and it must not create anybody either. return 0, errors.New("idear: Admitting was given a nil create function") } addr := normalizeEmail(email) if addr == "" { return 0, fmt.Errorf("%w: admission needs a non-empty email", ErrInvalidEmail) } token := inviteToken(ctx) // 1. The claim. Advisory: IsEmpty reads the read pool and can // lag a concurrent Claim by a WAL snapshot. Both ways of being // wrong are fail-closed — a stale "not empty" refuses a first // signup that could have claimed, and a stale "empty" reaches // Claim, whose own transaction is where the answer binds. empty, err := rs.IsEmpty(ctx) if err != nil { rs.cfg.Logger.Error("idear: admission could not count the roster", "email", addr, "err", err) return 0, fmt.Errorf("idear: admission: %w", err) } if empty { // Deliberately unconditional, ahead of any token: an // unclaimed instance's first account is its Owner, which // outranks any role an invitation could carry. An // invitation presented here simply stays pending. id, err := create(ctx, addr, hash) if err != nil { return 0, err } if _, err := rs.Claim(ctx, subjectForID(id), addr, ""); err != nil { if errors.Is(err, ErrOwnerExists) { rs.cfg.Logger.Warn("idear: admission lost the claim race; the app user is an ORPHAN until the reconciliation route heals it", "email", addr, "app_id", id) return 0, refused() } rs.cfg.Logger.Error("idear: admission could not claim the instance; the app user is an ORPHAN", "email", addr, "app_id", id, "err", err) return 0, fmt.Errorf("idear: admission: %w", err) } rs.cfg.Logger.Info("idear: admitted the first account as owner", "email", addr, "app_id", id) return id, nil } // 2. The token, PLUS an email match. The lookup is advisory // too — Accept's CAS is what actually consumes the invitation, // and it re-checks unexpired/unrevoked/unaccepted inside its // own transaction. Only the email match is decided here, and // an invitation's Email is never updated after it is written, // so there is nothing for a racing writer to change under it. if token != "" { inv, err := rs.pendingInvitation(ctx, token) switch { case err == nil && normalizeEmail(inv.Email) == addr: id, err := create(ctx, addr, hash) if err != nil { return 0, err } if _, err := rs.Accept(ctx, token, subjectForID(id), ""); err != nil { rs.cfg.Logger.Error("idear: admission could not redeem an invitation it had just validated; the app user is an ORPHAN", "email", addr, "app_id", id, "err", err) return 0, fmt.Errorf("idear: admission: %w", err) } rs.cfg.Logger.Info("idear: admitted an invited address", "email", addr, "app_id", id, "role", string(inv.Role)) return id, nil case err == nil: // The token is real and live, but it is not this // address's. Never admitted on that basis, and never // told apart from a bad token in the response. rs.cfg.Logger.Warn("idear: signup presented an invitation token issued to another address", "email", addr) case errors.Is(err, ErrNoInvitation): rs.cfg.Logger.Warn("idear: signup presented an invitation token that is not redeemable", "email", addr) default: rs.cfg.Logger.Error("idear: admission could not look up an invitation", "email", addr, "err", err) return 0, fmt.Errorf("idear: admission: %w", err) } // Falls through: an unusable token is worth no more than // no token at all, so an OPEN instance still admits at // RoleMember and a closed one refuses. What it can never // do is contribute a role. } // 3. Open sign-up. if rs.cfg.OpenSignUp { id, err := create(ctx, addr, hash) if err != nil { return 0, err } if _, err := rs.addMember(ctx, subjectForID(id), addr, "", RoleMember); err != nil { rs.cfg.Logger.Error("idear: admission could not write an open-signup member; the app user is an ORPHAN", "email", addr, "app_id", id, "err", err) return 0, fmt.Errorf("idear: admission: %w", err) } rs.cfg.Logger.Info("idear: admitted an open sign-up", "email", addr, "app_id", id) return id, nil } // 4. Refused. The address is logged because the copy cannot // carry it; had_token says whether CarryToken delivered // anything at all, which is how a mis-mounted CarryToken is // told apart from a genuinely uninvited visitor. rs.cfg.Logger.Info("idear: refused a signup", "email", addr, "had_token", token != "") return 0, refused() } } // Authorize is the keymail adapter: it fills auth.Config.Authorize, // whose contract is "given a VERIFIED address, may it have a session?" // // auth.New(auth.Config{Sessions: sess, Authorize: rs.Authorize}) // // In order: an ACTIVE member is admitted; an empty roster is claimed by // the caller as Owner; otherwise a pending invitation FOR THAT ADDRESS // is accepted, writing the member with Subject = the address, which is // what auth mints as the session subject (auth/handlers.go's admit: // sessions.Session{Subject: id.Address}). Anything else is refused. // // WHY THIS MAY ADMIT ON THE ADDRESS ALONE, where Admitting may not: // auth calls this only after delivering a link to that address and // seeing it come back, so the address IS the verified credential here. // password verifies nothing, which is why its path demands the token // as well. Never call Authorize with an address a visitor merely // typed. // // It returns a bool with NO ERROR CHANNEL, so a database failure is // indistinguishable from a policy denial to the visitor — auth renders // both as the same 403. idear logs the distinction it cannot render; // an operator watching for "This address is verified but not admitted // here." should read the log before believing it is policy. // // Two further asymmetries with the password path, stated so nobody // reads the guarantee as uniform. Authorize runs BEFORE auth's // SecondFactor hook, so a Member row can be written for a sign-in a // 2FA gate never completes — self-healing on the next attempt. And // only keymail's admission consults this at all: password's Signin // runs Lookup, Verify and mint with no idear involvement, so a // deactivated member can still MINT a session under password. What // stops them there is Require on every route, the landing page // included. func (rs *Roster) Authorize(address string) bool { // auth hands over an address and nothing else: there is no request // context to inherit, so there is nothing to cancel this when the // visitor gives up or the server shuts down. A background context // with no deadline would park auth's sign-in handler on a stuck // writer indefinitely, so the deadline is invented here. ctx, cancel := context.WithTimeout(context.Background(), authorizeTimeout) defer cancel() addr := normalizeEmail(address) if addr == "" { rs.cfg.Logger.Warn("idear: keymail admission asked about an empty address") return false } // Under keymail the session Subject IS the verified address, so // the member lookup is by address. m, err := rs.BySubject(ctx, addr) switch { case err == nil && m.Active(): return true case err == nil: rs.cfg.Logger.Info("idear: keymail admission refused a deactivated member", "address", addr, "member_id", m.ID) return false case errors.Is(err, ErrNotFound): // Not a member yet. Carry on to the claim and the invitation. default: rs.cfg.Logger.Error("idear: keymail admission could not resolve the address; refusing, though this is NOT a policy denial", "address", addr, "err", err) return false } empty, err := rs.IsEmpty(ctx) if err != nil { rs.cfg.Logger.Error("idear: keymail admission could not count the roster; refusing, though this is NOT a policy denial", "address", addr, "err", err) return false } if empty { switch _, err := rs.Claim(ctx, addr, addr, ""); { case err == nil: rs.cfg.Logger.Info("idear: keymail admission claimed the instance", "address", addr) return true case errors.Is(err, ErrOwnerExists): // Lost the race to another first arrival. They may still // hold an invitation of their own, so this is not the end // of the road. rs.cfg.Logger.Info("idear: keymail admission lost the claim race", "address", addr) default: rs.cfg.Logger.Error("idear: keymail admission could not claim the instance; refusing, though this is NOT a policy denial", "address", addr, "err", err) return false } } switch m, err := rs.acceptByAddress(ctx, addr, addr, ""); { case err == nil: rs.cfg.Logger.Info("idear: keymail admission redeemed an invitation", "address", addr, "role", string(m.Role)) return true case errors.Is(err, ErrNoInvitation): rs.cfg.Logger.Info("idear: keymail admission refused an address with no member row and no redeemable invitation", "address", addr) default: rs.cfg.Logger.Error("idear: keymail admission could not redeem an invitation; refusing, though this is NOT necessarily a policy denial", "address", addr, "err", err) } return false }