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:
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user