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>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user