diff --git a/admin/internal/auth/cloud.go b/admin/internal/auth/cloud.go new file mode 100644 index 0000000..5975f28 --- /dev/null +++ b/admin/internal/auth/cloud.go @@ -0,0 +1,94 @@ +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/db" + adminmodels "github.com/mrhid6/vantage/admin/internal/models" + sharedmodels "github.com/mrhid6/vantage/shared/models" + "go.mongodb.org/mongo-driver/v2/bson" + "golang.org/x/crypto/bcrypt" +) + +// HandleCloudLogin authenticates a cloud customer against the CONTROL PLANE's +// users collection, with the credentials they already have. +// +// Two consequences worth stating plainly, because they are real and were +// accepted deliberately: +// +// 1. A cloud user's control-plane password now also unlocks billing. Any +// password change or compromise has a wider blast radius than before. +// 2. Only control-plane role "owner" may sign in here. admin and member are +// refused — billing is an owner concern. +// +// Mitigations: rate limits, an identical error for every failure, and an audit +// entry for every attempt. +func HandleCloudLogin(c *gin.Context) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"}) + return + } + email := strings.ToLower(strings.TrimSpace(body.Email)) + ctx := c.Request.Context() + + if !allowAttempt(email, c.ClientIP()) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"}) + return + } + + reject := func(reason string) { + audit.Write(ctx, adminmodels.AuditEntry{ + Actor: email, Action: "cloud.login_failed", IP: c.ClientIP(), Detail: reason}) + c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError}) + } + + // A self-hosted customer_users row wins over a control-plane user with the + // same address. Documented so the behaviour is chosen rather than emergent. + if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 { + HandleCustomerLogin(c) + return + } + + var u sharedmodels.User + if err := db.Control("users").FindOne(ctx, bson.M{"email": email}).Decode(&u); err != nil { + bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password)) + reject("unknown email") + return + } + if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil { + reject("bad password") + return + } + if u.Role != sharedmodels.RoleOwner { + reject("role " + u.Role + " is not permitted") + return + } + + // Resolve the admin-side account that owns this user's instance. + var inst adminmodels.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": u.InstanceID}).Decode(&inst); err != nil { + reject("no account for instance " + u.InstanceID) + return + } + + id, err := Save(ctx, Session{ + UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: inst.AccountID, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"}) + return + } + SetCookie(c, id) + clearAttempts(email) + audit.Write(ctx, adminmodels.AuditEntry{ + Actor: email, Action: "cloud.login", AccountID: inst.AccountID, IP: c.ClientIP()}) + c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email}) +} diff --git a/admin/internal/auth/customer.go b/admin/internal/auth/customer.go new file mode 100644 index 0000000..0e31e84 --- /dev/null +++ b/admin/internal/auth/customer.go @@ -0,0 +1,12 @@ +package auth + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// HandleCustomerLogin is replaced by the real self-hosted login in Task 7. +func HandleCustomerLogin(c *gin.Context) { + c.JSON(http.StatusNotImplemented, gin.H{"error": "not implemented yet"}) +}