Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user