From 4bb7400b8e67bf70058c6de49d44814f15b34a13 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sat, 25 Jul 2026 20:48:09 +0100 Subject: [PATCH] feat(admin): session probe, self-hosted signup and the relink cap Adds GET /auth/me so the admin site's route guards can know who is signed in, POST /auth/signup for self-hosted customers, and max_relinks on the account payload so the UI never hardcodes a rule the backend enforces. Signup follows sitesvc's proven shape: honeypot answered as success, a generic 201 when the address already exists, and nothing usable until the emailed link is opened. Also fixes a lockout found while verifying it. When the verification email failed, the account was rolled back but the customer_users row survived -- an orphan that can never be signed in to and that holds the unique index on email, so every later signup with that address got a cheerful 201 and the customer was locked out of their own address with no visible error. CreateCustomerUser now undoes its own insert. Co-Authored-By: Claude Opus 5 --- admin/internal/api/customer.go | 26 ++++++- admin/internal/api/routes.go | 2 + admin/internal/auth/customer.go | 78 ++++++++++++++++++- admin/internal/auth/middleware.go | 8 +- admin/internal/config/config.go | 8 +- .../plans/2026-07-25-admin-site.md | 48 ++++++++++-- 6 files changed, 153 insertions(+), 17 deletions(-) diff --git a/admin/internal/api/customer.go b/admin/internal/api/customer.go index eeebbcc..8c25056 100644 --- a/admin/internal/api/customer.go +++ b/admin/internal/api/customer.go @@ -37,6 +37,24 @@ func ownedInstance(c *gin.Context, instanceID string) (*models.Instance, bool) { return &inst, true } +// getMe reports who the caller is, for route guards in the UI. +// +// It is deliberately outside RequireCustomer/RequireStaff: the UI needs a +// truthful 401 to redirect on, not an error page. It reveals nothing a caller +// does not already possess, because it only ever describes their own cookie. +func getMe(c *gin.Context) { + s := auth.Load(c) + if s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not signed in"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "kind": s.Kind, + "email": s.Email, + "account_id": s.AccountID, + }) +} + func getAccount(c *gin.Context) { s := auth.Current(c) ctx := c.Request.Context() @@ -58,7 +76,13 @@ func getAccount(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"account": acct, "instances": instances}) + c.JSON(http.StatusOK, gin.H{ + "account": acct, + "instances": instances, + // Sent rather than mirrored in the UI: a hardcoded 3 in TypeScript is a + // second source of truth for a rule the backend enforces. + "max_relinks": models.MaxRelinksPerTerm, + }) } func linkInstance(c *gin.Context) { diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index 631cedb..eab72f8 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -30,6 +30,8 @@ func Routes(cfg config.Config) http.Handler { r.POST("/auth/login", auth.HandleCloudLogin) // falls through to customer login r.POST("/auth/logout", auth.HandleLogout) r.GET("/auth/verify", auth.HandleVerify) + r.GET("/auth/me", getMe) + r.POST("/auth/signup", auth.HandleSignup) cust := r.Group("/api") cust.Use(auth.RequireCustomer()) diff --git a/admin/internal/auth/customer.go b/admin/internal/auth/customer.go index 9081dfa..463e859 100644 --- a/admin/internal/auth/customer.go +++ b/admin/internal/auth/customer.go @@ -55,7 +55,83 @@ func CreateCustomerUser(ctx context.Context, accountID, email, password string) if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil { return err } - return mail.SendVerification(u.Email, token) + + if err := mail.SendVerification(u.Email, token); err != nil { + // Undo the insert. A row whose verification link was never delivered is + // worse than no row: it can never be signed in to, and it holds the + // unique index on email, so the customer cannot sign up again with the + // address they just used. + _, _ = db.Admin("customer_users").DeleteOne(ctx, bson.M{"user_id": u.UserID}) + return err + } + return nil +} + +// HandleSignup creates a self-hosted customer: an account, an unverified user, +// and a verification email. +// +// Nothing is usable until the emailed link is opened, the same rule sitesvc +// already proves — so an address nobody controls cannot occupy an email or +// produce an account that can sign in. +func HandleSignup(c *gin.Context) { + var body struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + Website string `json:"website"` // honeypot; real users never fill it + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and password are required"}) + return + } + + // Honeypot: answer exactly as success so a bot learns nothing. + if strings.TrimSpace(body.Website) != "" { + c.JSON(http.StatusCreated, gin.H{"pending": true}) + return + } + + email := strings.ToLower(strings.TrimSpace(body.Email)) + ctx := c.Request.Context() + + if email == "" || len(body.Password) < 12 || strings.TrimSpace(body.Name) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name, email and a password of at least 12 characters are required"}) + return + } + if !allowAttempt("signup:"+email, c.ClientIP()) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"}) + return + } + + if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 { + // Same response as success. Telling a stranger the address is taken + // confirms who has an account here. + c.JSON(http.StatusCreated, gin.H{"pending": true}) + return + } + + acct := models.Account{ + AccountID: uuid.NewString(), + Name: strings.TrimSpace(body.Name), + BillingEmail: email, + Status: models.AccountActive, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Admin("accounts").InsertOne(ctx, acct); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the account"}) + return + } + + if err := CreateCustomerUser(ctx, acct.AccountID, email, body.Password); err != nil { + // Roll the account back rather than strand one with no owner. + _, _ = db.Admin("accounts").DeleteOne(ctx, bson.M{"account_id": acct.AccountID}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the verification email"}) + return + } + + audit.Write(ctx, models.AuditEntry{ + Actor: email, Action: "customer.signup", AccountID: acct.AccountID, IP: c.ClientIP()}) + c.JSON(http.StatusCreated, gin.H{"pending": true}) } // HandleVerify consumes a verification token. diff --git a/admin/internal/auth/middleware.go b/admin/internal/auth/middleware.go index e1211df..b4750e2 100644 --- a/admin/internal/auth/middleware.go +++ b/admin/internal/auth/middleware.go @@ -8,7 +8,9 @@ import ( const ctxSession = "admin_session_obj" -func load(c *gin.Context) *Session { +// Load returns the caller's session, or nil. Exported because the session probe +// in api/ needs to read a session without requiring one. +func Load(c *gin.Context) *Session { id, err := c.Cookie(CookieName) if err != nil || id == "" { return nil @@ -32,7 +34,7 @@ func Current(c *gin.Context) *Session { func RequireStaff() gin.HandlerFunc { return func(c *gin.Context) { - s := load(c) + s := Load(c) if s == nil || s.Kind != KindStaff { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"}) return @@ -47,7 +49,7 @@ func RequireStaff() gin.HandlerFunc { // remembering to filter. func RequireCustomer() gin.HandlerFunc { return func(c *gin.Context) { - s := load(c) + s := Load(c) if s == nil || s.Kind != KindCustomer || s.AccountID == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"}) return diff --git a/admin/internal/config/config.go b/admin/internal/config/config.go index 16c7cef..49fce54 100644 --- a/admin/internal/config/config.go +++ b/admin/internal/config/config.go @@ -61,10 +61,10 @@ func Load() (Config, error) { // and must leave the username empty. RedisUsername: os.Getenv("REDIS_USERNAME"), RedisPassword: os.Getenv("REDIS_PASSWORD"), - SigningKey: os.Getenv("LICENSE_SIGNING_KEY"), - PublicURL: strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"), - TrustProxy: strings.EqualFold(os.Getenv("TRUST_PROXY"), "true"), - Addr: ":" + envOr("PORT", "8083"), + SigningKey: os.Getenv("LICENSE_SIGNING_KEY"), + PublicURL: strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"), + TrustProxy: strings.EqualFold(os.Getenv("TRUST_PROXY"), "true"), + Addr: ":" + envOr("PORT", "8083"), SMTPHost: os.Getenv("SMTP_HOST"), SMTPPort: envOr("SMTP_PORT", "587"), diff --git a/docs/superpowers/plans/2026-07-25-admin-site.md b/docs/superpowers/plans/2026-07-25-admin-site.md index 8c1f33b..b47238a 100644 --- a/docs/superpowers/plans/2026-07-25-admin-site.md +++ b/docs/superpowers/plans/2026-07-25-admin-site.md @@ -98,7 +98,7 @@ Without `GET /auth/me` no route guard can know who is signed in, and without sig - `POST /auth/signup` → `201 {"pending":true}` - `GET /api/account` gains `"max_relinks": 3` -- [ ] **Step 1: Add the session probe** +- [x] **Step 1: Add the session probe** Append to `admin/internal/api/customer.go`: @@ -122,7 +122,7 @@ func getMe(c *gin.Context) { } ``` -- [ ] **Step 2: Export the session loader** +- [x] **Step 2: Export the session loader** `load` in `admin/internal/auth/middleware.go` is unexported. Rename it to `Load` and update its two call sites in the same file: @@ -134,7 +134,7 @@ func Load(c *gin.Context) *Session { Both `RequireStaff` and `RequireCustomer` call `s := Load(c)`. -- [ ] **Step 3: Add signup** +- [x] **Step 3: Add signup** Append to `admin/internal/auth/customer.go`: @@ -209,7 +209,39 @@ func HandleSignup(c *gin.Context) { Add `"time"` and `"github.com/google/uuid"` to that file's imports. -- [ ] **Step 4: Expose the relink cap** +- [x] **Step 3b: Make `CreateCustomerUser` undo its own insert** + +Found while verifying Step 7: when the verification email fails, `HandleSignup` +rolls the account back but the `customer_users` row survives. That orphan can +never be signed in to *and* it holds the unique index on `email`, so the next +signup with that address hits the "already exists" branch and gets a cheerful +`201` forever — the customer is locked out of their own address with no error +anyone can see. + +In `admin/internal/auth/customer.go`, replace the tail of `CreateCustomerUser`: + +```go + if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil { + return err + } + + if err := mail.SendVerification(u.Email, token); err != nil { + // Undo the insert. A row whose verification link was never delivered is + // worse than no row: it can never be signed in to, and it holds the + // unique index on email, so the customer cannot sign up again with the + // address they just used. + _, _ = db.Admin("customer_users").DeleteOne(ctx, bson.M{"user_id": u.UserID}) + return err + } + return nil +} +``` + +Confirm by posting the same signup twice with SMTP unconfigured: both must +return `500`, and both collections must be empty afterwards. Before the fix the +second call returns `201`. + +- [x] **Step 4: Expose the relink cap** In `admin/internal/api/customer.go`, replace the final `c.JSON` of `getAccount`: @@ -223,7 +255,7 @@ In `admin/internal/api/customer.go`, replace the final `c.JSON` of `getAccount`: }) ``` -- [ ] **Step 5: Route them** +- [x] **Step 5: Route them** In `admin/internal/api/routes.go`, below the existing auth routes: @@ -232,7 +264,7 @@ In `admin/internal/api/routes.go`, below the existing auth routes: r.POST("/auth/signup", auth.HandleSignup) ``` -- [ ] **Step 6: Build** +- [x] **Step 6: Build** ```bash sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... @@ -240,7 +272,7 @@ sh /tmp/gorun.sh admin go build ./... && sh /tmp/gorun.sh admin go vet ./... Expected: no output. -- [ ] **Step 7: Confirm the probe and signup by hand** +- [x] **Step 7: Confirm the probe and signup by hand** Start admin as in the spec-3 plan Task 11 steps 2–4, then: @@ -256,7 +288,7 @@ curl -s -o /dev/null -w "honeypot: %{http_code}\n" -X POST localhost:8083/auth/s Expected: `401`; then `{"kind":"staff","email":"staff@example.com","account_id":""}`; then `201` with **no** account created (confirm with `db.accounts.countDocuments({billing_email:"bot@example.com"})` returning `0`). -- [ ] **Step 8: Commit** +- [x] **Step 8: Commit** ```bash git add admin/