feat(gateway): fix static token initialized in DB
This commit is contained in:
parent
9dea812a47
commit
5a9a6d54a3
4 changed files with 94 additions and 72 deletions
|
|
@ -90,13 +90,25 @@ func main() {
|
||||||
}
|
}
|
||||||
log.Printf("Registered %d models", len(cfg.Models))
|
log.Printf("Registered %d models", len(cfg.Models))
|
||||||
|
|
||||||
// Auth store
|
// Auth store (static tokens checked in-memory, not seeded to DB)
|
||||||
authStore := auth.NewStore(db.DB)
|
var staticTokens []auth.StaticToken
|
||||||
|
for _, t := range cfg.Tokens {
|
||||||
|
if t.Key != "" {
|
||||||
|
staticTokens = append(staticTokens, auth.StaticToken{
|
||||||
|
Name: t.Name,
|
||||||
|
Key: t.Key,
|
||||||
|
RateLimitRPM: t.RateLimitRPM,
|
||||||
|
DailyBudgetUSD: t.DailyBudgetUSD,
|
||||||
|
})
|
||||||
|
log.Printf("Loaded static token: %s", t.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authStore := auth.NewStore(db.DB, staticTokens)
|
||||||
authMiddleware := auth.NewMiddleware(authStore)
|
authMiddleware := auth.NewMiddleware(authStore)
|
||||||
authHandlers := auth.NewHandlers(authStore, cfg.Server.SessionSecret)
|
authHandlers := auth.NewHandlers(authStore, cfg.Server.SessionSecret)
|
||||||
|
|
||||||
// Seed default admin and static tokens
|
// Seed default admin
|
||||||
seedAdminAndTokens(cfg, authStore)
|
seedDefaultAdmin(cfg, authStore)
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
m := metrics.New()
|
m := metrics.New()
|
||||||
|
|
@ -244,9 +256,8 @@ func main() {
|
||||||
log.Println("Stopped")
|
log.Println("Stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedAdminAndTokens creates the default admin and seeds static tokens from config.
|
// seedDefaultAdmin creates the default admin user if no users exist.
|
||||||
func seedAdminAndTokens(cfg *config.Config, authStore *auth.Store) {
|
func seedDefaultAdmin(cfg *config.Config, authStore *auth.Store) {
|
||||||
// Seed default admin if no users exist
|
|
||||||
if !authStore.HasAnyUser() {
|
if !authStore.HasAnyUser() {
|
||||||
da := cfg.Server.DefaultAdmin
|
da := cfg.Server.DefaultAdmin
|
||||||
if da.Username != "" && da.Password != "" {
|
if da.Username != "" && da.Password != "" {
|
||||||
|
|
@ -258,24 +269,4 @@ func seedAdminAndTokens(cfg *config.Config, authStore *auth.Store) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed static tokens from config
|
|
||||||
if len(cfg.Tokens) > 0 {
|
|
||||||
admin, err := authStore.GetFirstAdmin()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("WARNING: no admin user found, cannot seed static tokens")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, t := range cfg.Tokens {
|
|
||||||
if t.Key == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := authStore.SeedStaticToken(admin.ID, t.Name, t.Key, t.RateLimitRPM, t.DailyBudgetUSD); err != nil {
|
|
||||||
log.Printf("WARNING: failed to seed token %q: %v", t.Name, err)
|
|
||||||
} else {
|
|
||||||
log.Printf("Seeded static token: %s", t.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,21 @@ type APIToken struct {
|
||||||
LastUsedAt int64 `json:"last_used_at"`
|
LastUsedAt int64 `json:"last_used_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Store struct {
|
// StaticToken represents a token defined in config (checked in-memory, never stored in DB).
|
||||||
db *sql.DB
|
type StaticToken struct {
|
||||||
|
Name string
|
||||||
|
Key string
|
||||||
|
RateLimitRPM int
|
||||||
|
DailyBudgetUSD float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStore(db *sql.DB) *Store {
|
type Store struct {
|
||||||
return &Store{db: db}
|
db *sql.DB
|
||||||
|
staticTokens []StaticToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(db *sql.DB, staticTokens []StaticToken) *Store {
|
||||||
|
return &Store{db: db, staticTokens: staticTokens}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) HasAnyUser() bool {
|
func (s *Store) HasAnyUser() bool {
|
||||||
|
|
@ -100,12 +109,6 @@ func (s *Store) GetUserByID(id int64) (*User, error) {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetFirstAdmin() (*User, error) {
|
|
||||||
return s.scanUser(s.db.QueryRow(
|
|
||||||
"SELECT id, username, email, password_hash, is_admin, totp_secret, totp_enabled, created_at, updated_at FROM users WHERE is_admin = 1 ORDER BY id LIMIT 1",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) scanUser(row *sql.Row) (*User, error) {
|
func (s *Store) scanUser(row *sql.Row) (*User, error) {
|
||||||
var u User
|
var u User
|
||||||
var isAdmin, totpEnabled int
|
var isAdmin, totpEnabled int
|
||||||
|
|
@ -271,6 +274,24 @@ func (s *Store) CreateAPIToken(userID int64, name string, rateLimitRPM int, dail
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) LookupAPIToken(key string) (*APIToken, error) {
|
func (s *Store) LookupAPIToken(key string) (*APIToken, error) {
|
||||||
|
// Check static tokens first (from config, never stored in DB)
|
||||||
|
for _, st := range s.staticTokens {
|
||||||
|
if st.Key == key {
|
||||||
|
prefix := st.Key
|
||||||
|
if len(prefix) > 11 {
|
||||||
|
prefix = prefix[:11]
|
||||||
|
}
|
||||||
|
return &APIToken{
|
||||||
|
ID: -1, // sentinel: static token
|
||||||
|
Name: st.Name,
|
||||||
|
KeyPrefix: prefix,
|
||||||
|
RateLimitRPM: st.RateLimitRPM,
|
||||||
|
DailyBudgetUSD: st.DailyBudgetUSD,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to DB tokens
|
||||||
hash := sha256.Sum256([]byte(key))
|
hash := sha256.Sum256([]byte(key))
|
||||||
keyHash := hex.EncodeToString(hash[:])
|
keyHash := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
|
@ -286,6 +307,23 @@ func (s *Store) LookupAPIToken(key string) (*APIToken, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
|
func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
|
||||||
|
// Include static tokens (shown for all users, not deletable)
|
||||||
|
var tokens []APIToken
|
||||||
|
for _, st := range s.staticTokens {
|
||||||
|
prefix := st.Key
|
||||||
|
if len(prefix) > 11 {
|
||||||
|
prefix = prefix[:11]
|
||||||
|
}
|
||||||
|
tokens = append(tokens, APIToken{
|
||||||
|
ID: -1, // sentinel: static token
|
||||||
|
Name: st.Name,
|
||||||
|
KeyPrefix: prefix,
|
||||||
|
RateLimitRPM: st.RateLimitRPM,
|
||||||
|
DailyBudgetUSD: st.DailyBudgetUSD,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB tokens
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
if userID == 0 {
|
if userID == 0 {
|
||||||
|
|
@ -295,15 +333,14 @@ func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
|
||||||
rows, err = s.db.Query("SELECT id, name, key_hash, key_prefix, user_id, rate_limit_rpm, daily_budget_usd, created_at, last_used_at FROM api_tokens WHERE user_id = ? ORDER BY id", userID)
|
rows, err = s.db.Query("SELECT id, name, key_hash, key_prefix, user_id, rate_limit_rpm, daily_budget_usd, created_at, last_used_at FROM api_tokens WHERE user_id = ? ORDER BY id", userID)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return tokens, nil // return static tokens even if DB query fails
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var tokens []APIToken
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t APIToken
|
var t APIToken
|
||||||
if err := rows.Scan(&t.ID, &t.Name, &t.KeyHash, &t.KeyPrefix, &t.UserID, &t.RateLimitRPM, &t.DailyBudgetUSD, &t.CreatedAt, &t.LastUsedAt); err != nil {
|
if err := rows.Scan(&t.ID, &t.Name, &t.KeyHash, &t.KeyPrefix, &t.UserID, &t.RateLimitRPM, &t.DailyBudgetUSD, &t.CreatedAt, &t.LastUsedAt); err != nil {
|
||||||
return nil, err
|
return tokens, nil
|
||||||
}
|
}
|
||||||
tokens = append(tokens, t)
|
tokens = append(tokens, t)
|
||||||
}
|
}
|
||||||
|
|
@ -331,31 +368,6 @@ func (s *Store) UpdateAPITokenLastUsed(id int64) {
|
||||||
s.db.Exec("UPDATE api_tokens SET last_used_at = ? WHERE id = ?", time.Now().Unix(), id)
|
s.db.Exec("UPDATE api_tokens SET last_used_at = ? WHERE id = ?", time.Now().Unix(), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedStaticToken creates a token by name if it doesn't already exist (idempotent).
|
|
||||||
func (s *Store) SeedStaticToken(userID int64, name, plainKey string, rateLimitRPM int, dailyBudgetUSD float64) error {
|
|
||||||
// Check if token with this name already exists
|
|
||||||
var count int
|
|
||||||
s.db.QueryRow("SELECT COUNT(*) FROM api_tokens WHERE name = ?", name).Scan(&count)
|
|
||||||
if count > 0 {
|
|
||||||
return nil // already seeded
|
|
||||||
}
|
|
||||||
|
|
||||||
keyPrefix := plainKey
|
|
||||||
if len(keyPrefix) > 11 {
|
|
||||||
keyPrefix = keyPrefix[:11]
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := sha256.Sum256([]byte(plainKey))
|
|
||||||
keyHash := hex.EncodeToString(hash[:])
|
|
||||||
|
|
||||||
now := time.Now().Unix()
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
"INSERT INTO api_tokens (name, key_hash, key_prefix, user_id, rate_limit_rpm, daily_budget_usd, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
name, keyHash, keyPrefix, userID, rateLimitRPM, dailyBudgetUSD, now,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) UpdateUsername(userID int64, newUsername string) error {
|
func (s *Store) UpdateUsername(userID int64, newUsername string) error {
|
||||||
_, err := s.db.Exec("UPDATE users SET username = ?, updated_at = ? WHERE id = ?", newUsername, time.Now().Unix(), userID)
|
_, err := s.db.Exec("UPDATE users SET username = ?, updated_at = ? WHERE id = ?", newUsername, time.Now().Unix(), userID)
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,30 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
<h2>Static Tokens <span style="font-size:0.75rem;color:#64748b;font-weight:400;">(from config, managed via environment variables)</span></h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>Prefix</th><th>Rate Limit</th><th>Budget</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Tokens}}{{if lt .ID 0}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Name}}</td>
|
||||||
|
<td><code>{{.KeyPrefix}}...</code></td>
|
||||||
|
<td>{{if eq .RateLimitRPM 0}}unlimited{{else}}{{.RateLimitRPM}} rpm{{end}}</td>
|
||||||
|
<td>{{if gt .DailyBudgetUSD 0.0}}${{printf "%.2f" .DailyBudgetUSD}}{{else}}unlimited{{end}}</td>
|
||||||
|
<td><span class="badge badge-totp">config</span></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Dynamic Tokens <span style="font-size:0.75rem;color:#64748b;font-weight:400;">(created via dashboard)</span></h2>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Name</th><th>Prefix</th><th>Rate Limit</th><th>Budget</th><th>Created</th><th>Last Used</th><th></th></tr></thead>
|
<thead><tr><th>Name</th><th>Prefix</th><th>Rate Limit</th><th>Budget</th><th>Created</th><th>Last Used</th><th></th></tr></thead>
|
||||||
<tbody id="tokens-tbody">
|
<tbody id="tokens-tbody">
|
||||||
{{range .Tokens}}
|
{{$hasDynamic := false}}
|
||||||
|
{{range .Tokens}}{{if gt .ID 0}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{.Name}}</td>
|
<td>{{.Name}}</td>
|
||||||
<td><code>{{.KeyPrefix}}...</code></td>
|
<td><code>{{.KeyPrefix}}...</code></td>
|
||||||
|
|
@ -23,9 +43,7 @@
|
||||||
<td>{{if gt .LastUsedAt 0}}{{formatTime .LastUsedAt}}{{else}}never{{end}}</td>
|
<td>{{if gt .LastUsedAt 0}}{{formatTime .LastUsedAt}}{{else}}never{{end}}</td>
|
||||||
<td><button class="btn btn-sm btn-danger" onclick="deleteToken({{.ID}})">Revoke</button></td>
|
<td><button class="btn btn-sm btn-danger" onclick="deleteToken({{.ID}})">Revoke</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{end}}{{end}}
|
||||||
<tr><td colspan="7" style="color:#64748b;text-align:center;padding:20px;">No API tokens yet. Create one to get started.</td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -88,7 +106,6 @@ async function doCreateToken(e) {
|
||||||
closeModal();
|
closeModal();
|
||||||
document.getElementById('new-token-key').textContent = data.key;
|
document.getElementById('new-token-key').textContent = data.key;
|
||||||
document.getElementById('new-token-display').style.display = 'block';
|
document.getElementById('new-token-display').style.display = 'block';
|
||||||
// Reload tokens partial
|
|
||||||
htmx.ajax('GET', '/tokens', {target: '#content', swap: 'innerHTML'});
|
htmx.ajax('GET', '/tokens', {target: '#content', swap: 'innerHTML'});
|
||||||
} catch (e) { document.getElementById('create-token-error').innerHTML = '<div class="error-msg">' + e.message + '</div>'; }
|
} catch (e) { document.getElementById('create-token-error').innerHTML = '<div class="error-msg">' + e.message + '</div>'; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,10 @@ func (a *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last used asynchronously
|
// Update last used asynchronously (skip for static tokens)
|
||||||
go a.authStore.UpdateAPITokenLastUsed(token.ID)
|
if token.ID > 0 {
|
||||||
|
go a.authStore.UpdateAPITokenLastUsed(token.ID)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := withTokenName(r.Context(), token.Name)
|
ctx := withTokenName(r.Context(), token.Name)
|
||||||
ctx = withAPIToken(ctx, token)
|
ctx = withAPIToken(ctx, token)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue