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>
165 lines
6.0 KiB
Go
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
|
|
}
|