package auth import ( "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "net/http" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/pkg/response" ) // Reference: P2E §3 + P2B M12 — PlatformApp & AgentBot authentication // Uses AccessToken model for authentication (replaces old direct APIKey on PlatformApp). // Auth flow: header → extract prefix → lookup AccessToken by prefix → verify SHA-256 hash → load owner. // PlatformAuthService manages PlatformApp and AgentBot authentication // using the AccessToken model (AccessTokenable concern pattern from Chatwoot). type PlatformAuthService struct { accessTokenRepo *repository.AccessTokenRepo platformAppRepo *repository.PlatformAppRepo db *gorm.DB } // NewPlatformAuthService creates a platform auth service. func NewPlatformAuthService( accessTokenRepo *repository.AccessTokenRepo, platformAppRepo *repository.PlatformAppRepo, db *gorm.DB, ) *PlatformAuthService { return &PlatformAuthService{ accessTokenRepo: accessTokenRepo, platformAppRepo: platformAppRepo, db: db, } } // AuthenticatePlatformApp validates an API key and returns the PlatformApp. // Key format: X-Platform-API-Key header → extract prefix → lookup AccessToken → verify hash → return owner. // Reference: Chatwoot AccessTokenable concern — prefix lookup + hash verification. func (s *PlatformAuthService) AuthenticatePlatformApp(apiKey string) (*model.PlatformApp, error) { if apiKey == "" { return nil, fmt.Errorf("api key is required") } // Extract prefix (first 8 chars) for fast DB lookup prefix := authTokenPrefix(apiKey) if len(prefix) < 8 { return nil, fmt.Errorf("api key too short") } // Lookup AccessToken by prefix + owner_type=PlatformApp token, err := s.accessTokenRepo.FindByTokenPrefix(nil, prefix, model.AccessTokenOwnerTypePlatformApp) if err != nil { return nil, fmt.Errorf("invalid platform api key") } // Verify full key by comparing SHA-256 hash expectedHash := hashAuthToken(apiKey) if token.Token != expectedHash { return nil, fmt.Errorf("invalid platform api key") } // Load the owning PlatformApp app, err := s.platformAppRepo.GetByID(nil, token.OwnerID) if err != nil { return nil, fmt.Errorf("platform app not found") } // Update LastUsedAt on the token (non-blocking, don't block auth on this) go func() { s.accessTokenRepo.UpdateLastUsedAt(nil, token.ID) }() return app, nil } // AuthenticateAgentBot validates a bot token and returns the AgentBot. // Token format: X-Agent-Bot-Token header → lookup by prefix → verify hash. // AgentBot still uses inline token storage (not AccessToken model yet — may migrate later). func (s *PlatformAuthService) AuthenticateAgentBot(token string) (*AgentBot, error) { if token == "" { return nil, fmt.Errorf("agent bot token is required") } prefix := authTokenPrefix(token) if len(prefix) < 8 { return nil, fmt.Errorf("agent bot token too short") } var bot AgentBot if err := s.db.Where("token_prefix = ? AND status = ?", prefix, "active").First(&bot).Error; err != nil { return nil, fmt.Errorf("invalid agent bot token") } // Verify full token by comparing SHA-256 hash expectedHash := hashAuthToken(token) if bot.Token != expectedHash { return nil, fmt.Errorf("invalid agent bot token") } return &bot, nil } // CreatePlatformAppWithToken creates a new PlatformApp with a generated AccessToken. // This is the Chatwoot "AccessTokenable" auto-create pattern. // Returns the app, plaintext token (shown only once), and any error. func (s *PlatformAuthService) CreatePlatformAppWithToken(name string, accountID uint) (*model.PlatformApp, string, error) { plainToken := generatePlatformAPIKey() prefix := authTokenPrefix(plainToken) hashedToken := hashAuthToken(plainToken) active := true var accountIDPtr *uint if accountID != 0 { accountIDPtr = &accountID } app := &model.PlatformApp{ Name: name, AccountID: accountIDPtr, Type: "api", Status: "active", Active: &active, } if err := s.db.Create(app).Error; err != nil { return nil, "", fmt.Errorf("failed to create platform app: %w", err) } // Auto-create AccessToken (AccessTokenable concern) accessToken := &model.AccessToken{ OwnerType: model.AccessTokenOwnerTypePlatformApp, OwnerID: app.ID, Token: hashedToken, TokenPrefix: prefix, Name: fmt.Sprintf("PlatformApp: %s", name), } if err := s.accessTokenRepo.Create(nil, accessToken); err != nil { // Rollback: delete the app we just created s.db.Delete(app) return nil, "", fmt.Errorf("failed to create access token: %w", err) } return app, plainToken, nil } // CreateAgentBot creates a new AgentBot with a generated token. func (s *PlatformAuthService) CreateAgentBot(name string, accountID uint) (*AgentBot, string, error) { rawToken := generateAgentBotToken() prefix := authTokenPrefix(rawToken) hashedToken := hashAuthToken(rawToken) bot := &AgentBot{ Name: name, AccountID: accountID, Token: hashedToken, TokenPrefix: prefix, Status: "active", } if err := s.db.Create(bot).Error; err != nil { return nil, "", fmt.Errorf("failed to create agent bot: %w", err) } return bot, rawToken, nil } // PlatformAppAuthMiddleware is a Gin middleware that authenticates PlatformApp requests. // Validates X-Platform-API-Key header → AccessToken lookup → inject platform_app_id into context. // Ref: Chatwoot's platform API authentication filter. func PlatformAppAuthMiddleware(svc *PlatformAuthService) gin.HandlerFunc { return func(c *gin.Context) { apiKey := c.GetHeader("X-Platform-API-Key") if apiKey == "" { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "X-Platform-API-Key header required") return } app, err := svc.AuthenticatePlatformApp(apiKey) if err != nil { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Invalid platform API key") return } // Inject platform_app_id into Gin context for downstream handlers c.Set("platform_app_id", app.ID) c.Set("platform_app_name", app.Name) c.Next() } } // AgentBotAuthMiddleware is a Gin middleware that authenticates AgentBot requests. // Validates X-Agent-Bot-Token header and injects agent_bot_id into context. func AgentBotAuthMiddleware(svc *PlatformAuthService) gin.HandlerFunc { return func(c *gin.Context) { token := c.GetHeader("X-Agent-Bot-Token") if token == "" { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "X-Agent-Bot-Token header required") return } bot, err := svc.AuthenticateAgentBot(token) if err != nil { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Invalid agent bot token") return } // Inject agent bot info into context c.Set("agent_bot_id", bot.ID) c.Set("agent_bot_account_id", bot.AccountID) c.Set("agent_bot_name", bot.Name) c.Next() } } // --- AgentBot model (inline, not migrated to AccessToken yet) --- // AgentBot uses direct token storage — may be migrated to AccessToken later. // AgentBot represents a bot agent that authenticates via API token. // Ref: Chatwoot AgentBot model (used in automation/integrations). type AgentBot struct { ID uint `gorm:"primaryKey" json:"id"` Name string `gorm:"size:255;not null" json:"name"` AccountID uint `gorm:"not null;index" json:"account_id"` Token string `gorm:"size:255;uniqueIndex;not null" json:"-"` // hashed in DB TokenPrefix string `gorm:"size:20;not null" json:"token_prefix"` // first 8 chars Status string `gorm:"size:50;default:active" json:"status"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } func (AgentBot) TableName() string { return "agent_bots" } // --- Helper functions --- // authTokenPrefix extracts the first 8 characters of a token for DB lookup. func authTokenPrefix(key string) string { if len(key) >= 8 { return key[:8] } return key } // hashAuthToken hashes a token using SHA-256 for secure storage. func hashAuthToken(key string) string { h := sha256.Sum256([]byte(key)) return hex.EncodeToString(h[:]) } // generatePlatformAPIKey creates a random platform API key. // Format: "gochat_pa_" + 32 random bytes as hex string. func generatePlatformAPIKey() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { panic("crypto/rand failed: " + err.Error()) } return "gochat_pa_" + hex.EncodeToString(b) } // generateAgentBotToken creates a random agent bot token. // Format: "gochat_ab_" + 32 random bytes as hex string. func generateAgentBotToken() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { panic("crypto/rand failed: " + err.Error()) } return "gochat_ab_" + hex.EncodeToString(b) }