refactor(sitesvc): remove signup and verification
Account creation moved to admin, which owns accounts, and the marketing form now posts there. sitesvc keeps the contact mailer only. DEPLOY LAST: sitesvc's verify endpoint must stay live until every outstanding pending signup has expired, or an in-flight verification link breaks. Do not roll this out until the site change has been live 24 hours and site_pending_signups is empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,31 +23,22 @@ const (
|
||||
type Server struct {
|
||||
mail mail.Config
|
||||
limiter *limiter
|
||||
signups *limiter
|
||||
allowOrigin map[string]bool
|
||||
trustProxy bool
|
||||
publicURL string
|
||||
appLoginURL string
|
||||
}
|
||||
|
||||
func New(mailCfg mail.Config) *Server {
|
||||
return &Server{
|
||||
mail: mailCfg,
|
||||
limiter: newLimiter(perIPLimit, perIPWindow),
|
||||
signups: newLimiter(signupPerIPLimit, signupPerIPWindow),
|
||||
allowOrigin: parseOrigins(os.Getenv("SITE_ORIGIN")),
|
||||
trustProxy: os.Getenv("TRUST_PROXY") == "true",
|
||||
publicURL: os.Getenv("PUBLIC_URL"),
|
||||
appLoginURL: os.Getenv("APP_LOGIN_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/contact", s.handleContact)
|
||||
mux.HandleFunc("POST /api/signup", s.handleSignup)
|
||||
|
||||
mux.HandleFunc("GET /api/verify", s.handleVerify)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
minPasswordLength = 12
|
||||
signupPerIPLimit = 3
|
||||
signupPerIPWindow = time.Hour
|
||||
)
|
||||
|
||||
type signupBody struct {
|
||||
InstanceName string `json:"instance_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Website string `json:"website"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
var body signupBody
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(body.Website) != "" {
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "check_email"})
|
||||
return
|
||||
}
|
||||
|
||||
var problems []fieldError
|
||||
|
||||
instanceName, nameErr := text("instance_name", body.InstanceName, true, maxShort)
|
||||
if nameErr != nil {
|
||||
problems = append(problems, *nameErr)
|
||||
}
|
||||
|
||||
addr, emailErr := email("email", body.Email)
|
||||
if emailErr != nil {
|
||||
problems = append(problems, *emailErr)
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(body.Password) < minPasswordLength {
|
||||
problems = append(problems, fieldError{
|
||||
Field: "password",
|
||||
Message: fmt.Sprintf("Use at least %d characters.", minPasswordLength),
|
||||
})
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
writeFieldErrors(w, problems)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.signups.allow(s.clientIP(r)) {
|
||||
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(signupPerIPWindow.Seconds())))
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]string{
|
||||
"error": "Too many organisations created from here recently. Try again later.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !s.mail.Enabled() {
|
||||
log.Println("signup refused: smtp is not configured, so no verification email could be sent")
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Signup is unavailable right now. Email support@hostxtra.co.uk and we will set you up.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
token, err := store.CreatePending(ctx, instanceName, addr, body.Password)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrEmailTaken):
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": "That email already has an account.",
|
||||
"fields": []fieldError{{
|
||||
Field: "email",
|
||||
Message: "This address is already registered. Sign in instead.",
|
||||
}},
|
||||
})
|
||||
return
|
||||
case errors.Is(err, store.ErrNameRejected):
|
||||
writeFieldErrors(w, []fieldError{{
|
||||
Field: "instance_name",
|
||||
Message: strings.TrimPrefix(err.Error(), store.ErrNameRejected.Error()+": "),
|
||||
}})
|
||||
return
|
||||
case err != nil:
|
||||
log.Printf("signup: create pending: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "We could not start that signup. Try again in a moment.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
link := s.verifyURL(token)
|
||||
if err := s.mail.SendVerification(addr, instanceName, link, store.PendingTTL); err != nil {
|
||||
|
||||
log.Printf("signup: send verification to %s: %v", addr, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "We could not send the confirmation email. Check the address, or email support@hostxtra.co.uk.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "check_email"})
|
||||
}
|
||||
|
||||
func (s *Server) verifyURL(token string) string {
|
||||
base := strings.TrimSuffix(s.publicURL, "/")
|
||||
return fmt.Sprintf("%s/api/verify?token=%s", base, url.QueryEscape(token))
|
||||
}
|
||||
|
||||
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
s.verifyPage(w, http.StatusBadRequest, "Link incomplete",
|
||||
"That link is missing its token. Copy the whole address from the email and try again.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
inst, err := store.Verify(ctx, token)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrBadToken):
|
||||
s.verifyPage(w, http.StatusGone, "Link expired",
|
||||
"This link has already been used or has expired. Start the signup again and we will send a new one.")
|
||||
return
|
||||
case errors.Is(err, store.ErrEmailTaken):
|
||||
s.verifyPage(w, http.StatusConflict, "Already registered",
|
||||
"That address already has an account. Sign in instead.")
|
||||
return
|
||||
case errors.Is(err, store.ErrNameRejected):
|
||||
s.verifyPage(w, http.StatusUnprocessableEntity, "Name unavailable",
|
||||
"We could not use that organisation name. Start the signup again with a different one.")
|
||||
return
|
||||
case err != nil:
|
||||
log.Printf("verify: %v", err)
|
||||
s.verifyPage(w, http.StatusInternalServerError, "Something went wrong",
|
||||
"We could not finish creating your organisation. Email support@hostxtra.co.uk and we will sort it out.")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("verify: provisioned instance %s (%s)", inst.Slug, inst.InstanceID)
|
||||
|
||||
if login := s.loginURL(inst.Slug); login != "" {
|
||||
http.Redirect(w, r, login, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
s.verifyPage(w, http.StatusOK, "Organisation ready",
|
||||
fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", inst.Name))
|
||||
}
|
||||
|
||||
// loginURL is the instance-specific sign-in URL a verified owner is sent to. Each
|
||||
// instance lives on its own subdomain (<slug>.vantage.hostxtra.co.uk), so the slug
|
||||
// must be substituted per signup rather than pointing at one shared address.
|
||||
//
|
||||
// APP_LOGIN_URL is a template. A "{slug}" placeholder is replaced with the
|
||||
// instance's slug; a value without one is treated as a literal (a single shared
|
||||
// login page) so a plain URL still works. An empty value falls back to the
|
||||
// confirmation page.
|
||||
func (s *Server) loginURL(slug string) string {
|
||||
if s.appLoginURL == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(s.appLoginURL, "{slug}", url.PathEscape(slug))
|
||||
}
|
||||
|
||||
func (s *Server) verifyPage(w http.ResponseWriter, status int, heading, detail string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.WriteHeader(status)
|
||||
|
||||
page := fmt.Sprintf(`<!doctype html>
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>%s Vantage</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
||||
background: #eaedf3; color: #0a1b33; padding: 2rem;
|
||||
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
main { max-width: 46ch; }
|
||||
h1 { font-size: 1.6rem; letter-spacing: -0.03em; margin: 0 0 0.6rem; }
|
||||
p { margin: 0; color: #41556f; }
|
||||
a { color: #0b2a58; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #071628; color: #e4ecf6; }
|
||||
p { color: #9fb3ca; }
|
||||
a { color: #5b9be8; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`, html.EscapeString(heading), html.EscapeString(heading), html.EscapeString(detail))
|
||||
|
||||
if _, err := w.Write([]byte(page)); err != nil {
|
||||
log.Printf("write verify page: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFieldErrors(w http.ResponseWriter, problems []fieldError) {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
||||
"error": "Some fields need another look.",
|
||||
"fields": problems,
|
||||
})
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// PendingSignup lives here rather than in the shared module because only
|
||||
// sitesvc writes site_pending_signups. The control plane does not know the
|
||||
// collection exists.
|
||||
//
|
||||
// Org and User used to be mirrored here by hand. They now come from
|
||||
// github.com/mrhid6/vantage/shared/models, which is the only copy.
|
||||
type PendingSignup struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
PendingID string `bson:"pending_id"`
|
||||
InstanceName string `bson:"instance_name"`
|
||||
Email string `bson:"email"`
|
||||
PasswordHash string `bson:"password_hash"`
|
||||
TokenHash string `bson:"token_hash"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
ExpiresAt time.Time `bson:"expires_at"`
|
||||
}
|
||||
@@ -2,35 +2,15 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/shared/indexes"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
"go.mongodb.org/mongo-driver/v2/x/mongo/driver/connstring"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const PendingTTL = 24 * time.Hour
|
||||
|
||||
// ErrEmailTaken and ErrNameRejected are aliases of the shared errors so
|
||||
// errors.Is keeps working for existing callers in internal/api.
|
||||
var (
|
||||
ErrEmailTaken = provision.ErrEmailTaken
|
||||
ErrBadToken = errors.New("verification link is invalid or has expired")
|
||||
ErrNameRejected = provision.ErrNameRejected
|
||||
)
|
||||
|
||||
var database *mongo.Database
|
||||
@@ -74,26 +54,7 @@ func EnsureIndexes() error {
|
||||
// users.email and instances.slug are declared in the shared module so both
|
||||
// services agree. Re-declaring at boot means sitesvc does not depend on the
|
||||
// control plane having started first.
|
||||
if err := indexes.EnsureCoreIndexes(ctx, database); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := col("site_pending_signups").Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{{Key: "token_hash", Value: 1}},
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{Keys: bson.D{{Key: "email", Value: 1}}},
|
||||
|
||||
{
|
||||
Keys: bson.D{{Key: "expires_at", Value: 1}},
|
||||
Options: options.Index().SetExpireAfterSeconds(0),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("pending signup indexes: %w", err)
|
||||
}
|
||||
return nil
|
||||
return indexes.EnsureCoreIndexes(ctx, database)
|
||||
}
|
||||
|
||||
// RequireMigratedDatabase refuses to start against a control-plane database
|
||||
@@ -129,96 +90,3 @@ func RequireMigratedDatabase(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func EmailTaken(ctx context.Context, email string) (bool, error) {
|
||||
n, err := col("users").CountDocuments(ctx, bson.M{"email": email})
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func CreatePending(ctx context.Context, instanceName, email, password string) (string, error) {
|
||||
if _, err := provision.BaseSlug(instanceName); err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
|
||||
}
|
||||
|
||||
taken, err := EmailTaken(ctx, email)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if taken {
|
||||
return "", ErrEmailTaken
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), provision.BcryptCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
raw, err := randomToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := col("site_pending_signups").DeleteMany(ctx, bson.M{"email": email}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
pending := models.PendingSignup{
|
||||
PendingID: uuid.NewString(),
|
||||
InstanceName: strings.TrimSpace(instanceName),
|
||||
Email: email,
|
||||
PasswordHash: string(hash),
|
||||
TokenHash: hashToken(raw),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(PendingTTL),
|
||||
}
|
||||
if _, err := col("site_pending_signups").InsertOne(ctx, pending); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func Verify(ctx context.Context, rawToken string) (*sharedmodels.Instance, error) {
|
||||
var pending models.PendingSignup
|
||||
err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{
|
||||
"token_hash": hashToken(rawToken),
|
||||
"expires_at": bson.M{"$gt": time.Now().UTC()},
|
||||
}).Decode(&pending)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrBadToken
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inst, err := provision.CreateInstance(ctx, database, pending.InstanceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The password was hashed when the signup was recorded; only the hash
|
||||
// survives to this point.
|
||||
_, err = provision.CreateUserWithHash(ctx, database, inst.InstanceID, pending.Email,
|
||||
pending.PasswordHash, sharedmodels.RoleOwner, "local")
|
||||
if err != nil {
|
||||
// Leaving an instance behind would permanently occupy a slug nobody owns.
|
||||
if rbErr := provision.RollbackInstance(ctx, database, inst.InstanceID); rbErr != nil {
|
||||
log.Printf("verify: failed to roll back instance %s: %v", inst.InstanceID, rbErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func hashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user