Files
tapir/internal/web/invite.go
T
mathiasandClaude Opus 4.8 dece5dec44 feat(web): public /invite/{token} set-password + account-creation flow
The Stage-1 onboarding path: an invited user opens their emailed link,
sets a password, and Tapir creates their Dex local-password account so
they can log in. Mounted on root OUTSIDE Auth.Middleware — the visitor
has no Dex session yet; the token in the path is the capability.

handleInviteForm previews the token (no consume) and shows the form, or
a clear "expired / already used" page. handleInviteSubmit validates the
password BEFORE consuming the token (a typo is retryable), then claims
the invite exactly once, bcrypt-hashes (cost 12), and creates the Dex
account — mapping ErrPasswordExists -> "log in instead" and ErrForbidden
-> "contact the administrator". Off-cluster (App.Dex nil) it degrades to
a "deployed-only" message without burning the token. On success it sets
an account_created flash and redirects to /auth/login.

Welcome sub-text now states access is invite-only. Handlers depend on
narrow ports (InvitationStore, DexPasswordCreator) so tests use fakes;
cmdServe wires the store + an in-cluster dex.PasswordClient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:19:22 +02:00

165 lines
6.0 KiB
Go

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
}