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 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 20:48:09 +01:00
co-authored by Claude Opus 5
parent 79afcc2e16
commit 4bb7400b8e
6 changed files with 153 additions and 17 deletions
+25 -1
View File
@@ -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) {
+2
View File
@@ -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())
+77 -1
View File
@@ -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.
+5 -3
View File
@@ -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
+4 -4
View File
@@ -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"),
@@ -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 24, 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/