| 1 | package idear |
| 2 | |
| 3 | import ( |
| 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. |
| 30 | const 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". |
| 38 | const 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. |
| 43 | func 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. |
| 48 | type 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." |
| 73 | func (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 "". |
| 89 | func 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. |
| 120 | func 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. |
| 131 | func 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. |
| 179 | func (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. |
| 323 | func (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 | |