277 lines
9.0 KiB
Go
277 lines
9.0 KiB
Go
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)
|
|
} |