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
+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