diff --git a/admin/cmd/main.go b/admin/cmd/main.go index 8c800b1..f55b4fb 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -16,6 +16,7 @@ import ( "github.com/mrhid6/vantage/admin/internal/db" "github.com/mrhid6/vantage/admin/internal/inject" "github.com/mrhid6/vantage/admin/internal/licensing" + "github.com/mrhid6/vantage/admin/internal/mail" "github.com/mrhid6/vantage/admin/internal/models" ) @@ -29,6 +30,15 @@ func main() { licensing.SetSigningKey(cfg.SigningKey) + mail.Init(mail.Config{ + Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom, + Username: cfg.SMTPUsername, Password: cfg.SMTPPassword, + PublicURL: cfg.PublicURL, + }) + if !mail.Enabled() { + log.Println("warning: SMTP not configured; verification and licence emails will fail") + } + auth.InitRedis(cfg.RedisAddr) pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second) if err := auth.Ping(pingCtx); err != nil { diff --git a/admin/internal/auth/customer.go b/admin/internal/auth/customer.go index 0e31e84..9081dfa 100644 --- a/admin/internal/auth/customer.go +++ b/admin/internal/auth/customer.go @@ -1,12 +1,141 @@ package auth import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" "net/http" + "strings" + "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/mail" + "github.com/mrhid6/vantage/admin/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "golang.org/x/crypto/bcrypt" ) -// 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"}) +// BcryptCost matches the control plane and sitesvc. Changing it here alone would +// make hashes inconsistent across services that may one day compare them. +const BcryptCost = 12 + +// VerifyWindow mirrors sitesvc's proven pattern: 32 random bytes, only the +// SHA-256 hash stored, 24-hour expiry. +const VerifyWindow = 24 * time.Hour + +// CreateCustomerUser creates an unverified self-hosted customer login and emails +// the verification link. Called during purchase (spec 5) and by staff. +func CreateCustomerUser(ctx context.Context, accountID, email, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) + if err != nil { + return err + } + + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return err + } + token := hex.EncodeToString(raw) + sum := sha256.Sum256([]byte(token)) + expiry := time.Now().UTC().Add(VerifyWindow) + + u := models.CustomerUser{ + UserID: uuid.NewString(), + AccountID: accountID, + Email: strings.ToLower(strings.TrimSpace(email)), + PasswordHash: string(hash), + VerifyTokenHash: hex.EncodeToString(sum[:]), + VerifyTokenExpiry: &expiry, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil { + return err + } + return mail.SendVerification(u.Email, token) +} + +// HandleVerify consumes a verification token. +func HandleVerify(c *gin.Context) { + token := c.Query("token") + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"}) + return + } + sum := sha256.Sum256([]byte(token)) + now := time.Now().UTC() + + res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(), + bson.M{ + "verify_token_hash": hex.EncodeToString(sum[:]), + "verify_token_expiry": bson.M{"$gt": now}, + }, + bson.M{ + "$set": bson.M{"verified_at": now}, + "$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""}, + }) + if err != nil || res.MatchedCount == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"}) + return + } + c.JSON(http.StatusOK, gin.H{"verified": true}) +} + +// HandleCustomerLogin authenticates a self-hosted customer. +func HandleCustomerLogin(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, models.AuditEntry{ + Actor: email, Action: "customer.login_failed", IP: c.ClientIP(), Detail: reason}) + c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError}) + } + + var u models.CustomerUser + if err := db.Admin("customer_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.VerifiedAt == nil { + // Distinct from genericAuthError on purpose: the address is already + // known to be theirs, so there is nothing to disclose, and "check your + // email" is the only useful thing to say. + c.JSON(http.StatusForbidden, gin.H{"error": "verify your email address first"}) + return + } + + id, err := Save(ctx, Session{ + UserID: u.UserID, Kind: KindCustomer, Email: u.Email, AccountID: u.AccountID, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"}) + return + } + SetCookie(c, id) + clearAttempts(email) + audit.Write(ctx, models.AuditEntry{ + Actor: email, Action: "customer.login", AccountID: u.AccountID, IP: c.ClientIP()}) + c.JSON(http.StatusOK, gin.H{"kind": KindCustomer, "email": u.Email}) } diff --git a/admin/internal/mail/mail.go b/admin/internal/mail/mail.go new file mode 100644 index 0000000..2e69207 --- /dev/null +++ b/admin/internal/mail/mail.go @@ -0,0 +1,55 @@ +// Package mail delivers verification links and licence files. +package mail + +import ( + "fmt" + "net/smtp" + "strings" +) + +type Config struct { + Host, Port, From, Username, Password string + PublicURL string +} + +var cfg Config + +func Init(c Config) { cfg = c } + +func Enabled() bool { return cfg.Host != "" && cfg.From != "" } + +func send(to, subject, body string) error { + if !Enabled() { + return fmt.Errorf("SMTP is not configured") + } + msg := strings.Join([]string{ + "From: " + cfg.From, + "To: " + to, + "Subject: " + subject, + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=utf-8", + "", body, + }, "\r\n") + + var auth smtp.Auth + if cfg.Username != "" { + auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) + } + return smtp.SendMail(cfg.Host+":"+cfg.Port, auth, cfg.From, []string{to}, []byte(msg)) +} + +func SendVerification(to, token string) error { + link := fmt.Sprintf("%s/verify?token=%s", cfg.PublicURL, token) + return send(to, "Verify your Vantage account", + "Confirm your email address to finish setting up your Vantage account:\n\n"+ + link+"\n\nThis link expires in 24 hours.\n") +} + +// SendLicense delivers the blob inline. It is signed public data, not a secret — +// it is useless on any instance other than the one it names. +func SendLicense(to, instanceName, blob string) error { + return send(to, "Your Vantage licence key", + fmt.Sprintf("Your licence for %s is below.\n\n"+ + "Paste it into Settings → Licence on your Vantage install:\n\n%s\n", + instanceName, blob)) +}