package web import ( "context" "crypto/rand" "errors" "fmt" "net/http" "golang.org/x/crypto/bcrypt" "gitea.d-ma.be/mathias/tapir/internal/adapters/dex" "gitea.d-ma.be/mathias/tapir/internal/adapters/store" ) // InvitationStore is the narrow store surface the public invite flow needs: // PeekInvitation validates a token without consuming it (the GET form preview); // ClaimInvitation consumes it atomically (the POST). *store.Store satisfies it. // Deliberately separate from Store (the user-scoped surface) — invites run with no // authenticated user (the user does not exist yet). type InvitationStore interface { PeekInvitation(ctx context.Context, token string) (email string, err error) ClaimInvitation(ctx context.Context, token string) (email string, err error) } // DexPasswordCreator creates a Dex local-password account from a bcrypt hash. // *dex.PasswordClient satisfies it; tests substitute a fake. A nil App.Dex means // the process is not in-cluster (dev) and account creation is unavailable. type DexPasswordCreator interface { CreatePassword(ctx context.Context, email, bcryptHash, userID string) error } // bcryptCost is the work factor for hashing invite passwords. 12 is a sensible // 2020s default — noticeably slow to brute-force, fast enough for a single login. const bcryptCost = 12 // minPasswordLen is the floor for an invite password. Length beats composition // rules; 8 is the practical minimum we accept. const minPasswordLen = 8 // handleInviteForm renders the set-password form for a valid invite token, or a // clear "expired / already used" page otherwise. It only previews the token // (PeekInvitation) — the token is consumed on submit, not on view, so a refresh // or a link-preview fetch never burns the invite. func (a *App) handleInviteForm(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") if a.Invitations == nil { a.renderStatus(w, r, http.StatusOK, InviteInvalidPage()) return } email, err := a.Invitations.PeekInvitation(r.Context(), token) if errors.Is(err, store.ErrNotFound) { a.renderStatus(w, r, http.StatusOK, InviteInvalidPage()) return } if err != nil { a.serverError(w, r, "peek invitation", err) return } a.render(w, r, InvitePage(email, token, "")) } // handleInviteSubmit validates the chosen password, consumes the invite, and // creates the Dex local-password account. Order matters (see inline): password is // validated first (no token burned on a typo), then the invite is claimed exactly // once, then the Dex account is created. On success the visitor is sent to the Dex // login to sign in with the email + new password. func (a *App) handleInviteSubmit(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") if a.Invitations == nil { a.renderStatus(w, r, http.StatusOK, InviteInvalidPage()) return } if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } password := r.FormValue("password") confirm := r.FormValue("password_confirm") // 1. Validate before consuming the token, so a mismatch/typo is retryable. if len(password) < minPasswordLen { a.reshowInvite(w, r, token, "Password must be at least 8 characters.") return } if password != confirm { a.reshowInvite(w, r, token, "Passwords do not match.") return } // Off-cluster (dev): we cannot create a Dex account. Degrade clearly WITHOUT // consuming the invite, so it still works once deployed. if a.Dex == nil { a.render(w, r, InviteNoticePage("Account creation only works in the deployed environment.", false)) return } // 2. Consume the invite exactly once. If the token vanished between GET and // POST (expired, replay, concurrent claim) this is where it surfaces. email, err := a.Invitations.ClaimInvitation(r.Context(), token) if errors.Is(err, store.ErrNotFound) { a.renderStatus(w, r, http.StatusOK, InviteInvalidPage()) return } if err != nil { a.serverError(w, r, "claim invitation", err) return } // 3. Hash the password (cost 12). The Dex client base64-encodes it for the CR. hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) if err != nil { a.serverError(w, r, "hash password", err) return } // 4. Create the Dex local-password account. userID, err := newID() if err != nil { a.serverError(w, r, "new user id", err) return } switch err := a.Dex.CreatePassword(r.Context(), email, string(hash), userID); { case err == nil: // 5. Off to the Dex login — a flash surfaces on the first page after login. setFlash(w, flashAccountCreated) http.Redirect(w, r, loginPath, http.StatusSeeOther) case errors.Is(err, dex.ErrPasswordExists): a.render(w, r, InviteNoticePage("An account with this email already exists. Try logging in.", true)) case errors.Is(err, dex.ErrForbidden): a.render(w, r, InviteNoticePage("Unable to create your Dex account — please contact the administrator.", false)) default: a.serverError(w, r, "create dex password", err) } } // reshowInvite re-renders the password form with a validation message, re-fetching // the email from the (still-unconsumed) token. A token that became invalid in the // meantime falls back to the expired/used page. func (a *App) reshowInvite(w http.ResponseWriter, r *http.Request, token, errMsg string) { email, err := a.Invitations.PeekInvitation(r.Context(), token) if errors.Is(err, store.ErrNotFound) { a.renderStatus(w, r, http.StatusOK, InviteInvalidPage()) return } if err != nil { a.serverError(w, r, "peek invitation", err) return } a.renderStatus(w, r, http.StatusBadRequest, InvitePage(email, token, errMsg)) } // newID returns a fresh random RFC-4122 v4 UUID for the Dex userID field // (crypto/rand, no new dependency). Kept local rather than coupling web to the // store package's unexported generator. func newID() (string, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", fmt.Errorf("web: new id: %w", err) } b[6] = (b[6] & 0x0f) | 0x40 // version 4 b[8] = (b[8] & 0x3f) | 0x80 // variant 10 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil }