Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions internal/domain/api_token_secret.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package domain

import (
"crypto/sha256"
"encoding/hex"
"strings"
)

const (
APITokenPrefix = "maxx_"
APITokenPrefixDisplayLen = 12
)

// HashAPIToken returns the stable SHA-256 digest used for stored API tokens.
func HashAPIToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}

// NormalizeStoredAPIToken converts plaintext tokens into the stored digest form.
func NormalizeStoredAPIToken(token string) string {
if token == "" {
return ""
}
if strings.HasPrefix(token, APITokenPrefix) {
return HashAPIToken(token)
}
return token
}
2 changes: 1 addition & 1 deletion internal/domain/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ type BackupRoutingStrategy struct {
// BackupAPIToken represents an API token for backup
type BackupAPIToken struct {
Name string `json:"name"`
Token string `json:"token,omitempty"` // plaintext token for import
Token string `json:"token,omitempty"` // redacted placeholder; import rotates a new token
TokenPrefix string `json:"tokenPrefix,omitempty"` // display prefix
Description string `json:"description"`
ProjectSlug string `json:"projectSlug"` // empty = global
Expand Down
4 changes: 2 additions & 2 deletions internal/domain/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -766,8 +766,8 @@ type APIToken struct {
// 所属租户
TenantID uint64 `json:"tenantID"`

// Token 明文(直接存储)
Token string `json:"token"`
// Token 仅用于内部校验,不通过 API 暴露
Token string `json:"-"`

// Token 前缀(用于显示,如 "maxx_abc1...")
TokenPrefix string `json:"tokenPrefix"`
Expand Down
4 changes: 2 additions & 2 deletions internal/handler/token_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ import (

const (
// TokenPrefix is the prefix for all API tokens
TokenPrefix = "maxx_"
TokenPrefix = domain.APITokenPrefix
// TokenPrefixDisplayLen is the length of token prefix to display (including "maxx_")
TokenPrefixDisplayLen = 12
TokenPrefixDisplayLen = domain.APITokenPrefixDisplayLen
)

var (
Expand Down
22 changes: 14 additions & 8 deletions internal/repository/cached/api_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func (r *APITokenRepository) Create(t *domain.APIToken) error {
}
r.mu.Lock()
r.cache[t.ID] = t
r.tokenCache[t.Token] = t
r.tokenCache[tokenCacheKey(t.Token)] = t
r.mu.Unlock()
return nil
}
Expand All @@ -46,10 +46,10 @@ func (r *APITokenRepository) Update(t *domain.APIToken) error {
}
r.mu.Lock()
if exists && old != nil && old.Token != t.Token {
delete(r.tokenCache, old.Token)
delete(r.tokenCache, tokenCacheKey(old.Token))
}
r.cache[t.ID] = t
r.tokenCache[t.Token] = t
r.tokenCache[tokenCacheKey(t.Token)] = t
r.mu.Unlock()
return nil
}
Expand All @@ -67,7 +67,7 @@ func (r *APITokenRepository) Delete(tenantID uint64, id uint64) error {
r.mu.Lock()
delete(r.cache, id)
if exists && t != nil {
delete(r.tokenCache, t.Token)
delete(r.tokenCache, tokenCacheKey(t.Token))
}
r.mu.Unlock()
return nil
Expand All @@ -88,14 +88,16 @@ func (r *APITokenRepository) GetByID(tenantID uint64, id uint64) (*domain.APITok

r.mu.Lock()
r.cache[t.ID] = t
r.tokenCache[t.Token] = t
r.tokenCache[tokenCacheKey(t.Token)] = t
r.mu.Unlock()
return t, nil
}

func (r *APITokenRepository) GetByToken(tenantID uint64, token string) (*domain.APIToken, error) {
cacheKey := tokenCacheKey(token)

r.mu.RLock()
if t, ok := r.tokenCache[token]; ok && (tenantID == domain.TenantIDAll || t.TenantID == tenantID) {
if t, ok := r.tokenCache[cacheKey]; ok && (tenantID == domain.TenantIDAll || t.TenantID == tenantID) {
r.mu.RUnlock()
return t, nil
}
Expand All @@ -108,7 +110,7 @@ func (r *APITokenRepository) GetByToken(tenantID uint64, token string) (*domain.

r.mu.Lock()
r.cache[t.ID] = t
r.tokenCache[t.Token] = t
r.tokenCache[tokenCacheKey(t.Token)] = t
r.mu.Unlock()
return t, nil
}
Expand Down Expand Up @@ -153,7 +155,11 @@ func (r *APITokenRepository) Load() error {
r.tokenCache = make(map[string]*domain.APIToken, len(tokens))
for _, t := range tokens {
r.cache[t.ID] = t
r.tokenCache[t.Token] = t
r.tokenCache[tokenCacheKey(t.Token)] = t
}
return nil
}

func tokenCacheKey(token string) string {
return domain.NormalizeStoredAPIToken(token)
}
5 changes: 4 additions & 1 deletion internal/repository/sqlite/api_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func (r *APITokenRepository) Create(t *domain.APIToken) error {
now := time.Now()
t.CreatedAt = now
t.UpdatedAt = now
t.Token = domain.NormalizeStoredAPIToken(t.Token)

model := r.toModel(t)
if err := r.db.gorm.Create(model).Error; err != nil {
Expand Down Expand Up @@ -67,7 +68,9 @@ func (r *APITokenRepository) GetByID(tenantID uint64, id uint64) (*domain.APITok

func (r *APITokenRepository) GetByToken(tenantID uint64, token string) (*domain.APIToken, error) {
var model APIToken
if err := tenantScope(r.db.gorm, tenantID).Where("token = ? AND deleted_at = 0", token).First(&model).Error; err != nil {
if err := tenantScope(r.db.gorm, tenantID).
Where("token = ? AND deleted_at = 0", domain.NormalizeStoredAPIToken(token)).
First(&model).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrNotFound
}
Expand Down
56 changes: 56 additions & 0 deletions internal/repository/sqlite/api_token_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package sqlite

import (
"path/filepath"
"testing"

"github.com/awsl-project/maxx/internal/domain"
)

func TestAPITokenRepository_CreateStoresTokenDigest(t *testing.T) {
db, err := NewDB(filepath.Join(t.TempDir(), "token.db"))
if err != nil {
t.Fatalf("create db: %v", err)
}
t.Cleanup(func() {
_ = db.Close()
})

repo := NewAPITokenRepository(db)
plain := domain.APITokenPrefix + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
token := &domain.APIToken{
TenantID: domain.DefaultTenantID,
Token: plain,
TokenPrefix: plain[:domain.APITokenPrefixDisplayLen] + "...",
Name: "hashed-token",
IsEnabled: true,
}

if err := repo.Create(token); err != nil {
t.Fatalf("create token: %v", err)
}

hashed := domain.HashAPIToken(plain)
if token.Token != hashed {
t.Fatalf("token stored in domain = %q, want hash %q", token.Token, hashed)
}

var stored APIToken
if err := db.gorm.First(&stored, token.ID).Error; err != nil {
t.Fatalf("load stored token: %v", err)
}
if stored.Token != hashed {
t.Fatalf("db token = %q, want hash %q", stored.Token, hashed)
}

loaded, err := repo.GetByToken(domain.DefaultTenantID, plain)
if err != nil {
t.Fatalf("get by plain token: %v", err)
}
if loaded.ID != token.ID {
t.Fatalf("loaded id = %d, want %d", loaded.ID, token.ID)
}
if loaded.Token != hashed {
t.Fatalf("loaded token = %q, want hash %q", loaded.Token, hashed)
}
}
43 changes: 42 additions & 1 deletion internal/repository/sqlite/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"time"

"github.com/awsl-project/maxx/internal/domain"
mysqlDriver "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
)
Expand Down Expand Up @@ -119,7 +120,7 @@ var migrations = []Migration{

// 2. Update all existing rows to belong to default tenant
for _, table := range tenantScopedTables {
result := db.Exec("UPDATE "+table+" SET tenant_id = 1 WHERE tenant_id = 0 OR tenant_id IS NULL")
result := db.Exec("UPDATE " + table + " SET tenant_id = 1 WHERE tenant_id = 0 OR tenant_id IS NULL")
if result.Error != nil {
log.Printf("[Migration] Warning: Failed to update tenant_id for %s: %v", table, result.Error)
// Continue with other tables
Expand Down Expand Up @@ -183,6 +184,46 @@ var migrations = []Migration{
return errors.New("migration v5 is irreversible: hard-deleted users cannot be restored")
},
},
{
Version: 6,
Description: "Hash stored API tokens so plaintext secrets are not kept at rest",
Up: func(db *gorm.DB) error {
type tokenRow struct {
ID uint64
Token string
}

var rows []tokenRow
if err := db.Table("api_tokens").
Select("id, token").
Where("token LIKE ?", domain.APITokenPrefix+"%").
Find(&rows).Error; err != nil {
return err
}

if len(rows) == 0 {
return nil
}

now := time.Now().UnixMilli()
for _, row := range rows {
if err := db.Table("api_tokens").
Where("id = ?", row.ID).
Updates(map[string]any{
"token": domain.NormalizeStoredAPIToken(row.Token),
"updated_at": now,
}).Error; err != nil {
return err
}
}

log.Printf("[Migration] Hashed %d stored API tokens", len(rows))
return nil
},
Down: func(db *gorm.DB) error {
return errors.New("migration v6 is irreversible: hashed API tokens cannot be restored to plaintext")
},
},
}

func isMySQLDuplicateIndexError(err error) bool {
Expand Down
16 changes: 8 additions & 8 deletions internal/service/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -639,8 +639,8 @@ func (s *AdminService) DeleteAPIToken(tenantID uint64, id uint64) error {
// generateAPIToken creates a new random token
// Returns: plain token, prefix for display, error if generation fails
func generateAPIToken() (plain string, prefix string, err error) {
const tokenPrefix = "maxx_"
const tokenPrefixDisplayLen = 12
const tokenPrefix = domain.APITokenPrefix
const tokenPrefixDisplayLen = domain.APITokenPrefixDisplayLen

// Generate 32 random bytes (64 hex chars)
bytes := make([]byte, 32)
Expand Down Expand Up @@ -708,7 +708,7 @@ func (s *AdminService) ResetModelMappingsToDefaults(tenantID uint64) error {
// GetAvailableClientTypes returns all available client types for model mapping
func (s *AdminService) GetAvailableClientTypes() []domain.ClientType {
return []domain.ClientType{
"", // Empty means applies to all
"", // Empty means applies to all
domain.ClientTypeClaude,
domain.ClientTypeOpenAI,
domain.ClientTypeGemini,
Expand Down Expand Up @@ -776,11 +776,11 @@ type RecalculateCostsResult struct {

// RecalculateCostsProgress represents progress update for cost recalculation
type RecalculateCostsProgress struct {
Phase string `json:"phase"` // "calculating", "updating_attempts", "updating_requests", "aggregating_stats", "completed"
Current int `json:"current"` // Current item being processed
Total int `json:"total"` // Total items to process
Percentage int `json:"percentage"` // 0-100
Message string `json:"message"` // Human-readable message
Phase string `json:"phase"` // "calculating", "updating_attempts", "updating_requests", "aggregating_stats", "completed"
Current int `json:"current"` // Current item being processed
Total int `json:"total"` // Total items to process
Percentage int `json:"percentage"` // 0-100
Message string `json:"message"` // Human-readable message
}

// RecalculateCosts recalculates cost for all attempts using the current price table
Expand Down
Loading