Files
gochat/backend/internal/auth/platform_auth.go
T
Rogeeandrogee 2b182f9956 H-300: wire Captain Skills into Web runtime (#48)
* H-300: wire Captain Skills into Web runtime

* H-300: enforce effective model and conservative skill budget

* H-300: fix CI gosec step

* ci: extend golangci-lint timeout

* fix lint findings across backend

* fix(push): resolve delivery protocol blockers

* test(repository): close SQLite test databases

* test(repository): reuse SQLite schema per package

* H-307: restore backend Go cache in CI

* H-307: prefetch modules before cold lint

* H-307: resolve govulncheck security gate

* H-307: build lint with patched Go toolchain

* H-307: clear remaining security scan findings

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-19 07:08:14 +08:00

282 lines
9.2 KiB
Go

package auth
import (
"context"
"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"
applogger "github.com/gochat/gochat/pkg/logger"
"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(context.Background(), 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(context.Background(), 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() {
if err := s.accessTokenRepo.UpdateLastUsedAt(context.Background(), token.ID); err != nil {
applogger.L().Warn("failed to update platform token last-used time", "token_id", token.ID, "error", err)
}
}()
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(context.Background(), 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)
}