Files
LocalAI/core/http/auth/apikeys.go
Ettore Di Giacinto aea21951a2 feat: add users and authentication support (#9061)
* feat(ui): add users and authentication support

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat: allow the admin user to impersonificate users

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore: ui improvements, disable 'Users' button in navbar when no auth is configured

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat: add OIDC support

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix: gate models

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore: cache requests to optimize speed

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* small UI enhancements

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore(ui): style improvements

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix: cover other paths by auth

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore: separate local auth, refactor

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* security hardening, approval mode

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix: fix tests and expectations

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore: update localagi/localrecall

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-19 21:40:51 +01:00

122 lines
3.6 KiB
Go

package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
apiKeyPrefix = "lai-"
apiKeyRandBytes = 32 // 32 bytes = 64 hex chars
keyPrefixLen = 8 // display prefix length (from the random part)
)
// GenerateAPIKey generates a new API key. Returns the plaintext key,
// its HMAC-SHA256 hash, and a display prefix.
func GenerateAPIKey(hmacSecret string) (plaintext, hash, prefix string, err error) {
b := make([]byte, apiKeyRandBytes)
if _, err := rand.Read(b); err != nil {
return "", "", "", fmt.Errorf("failed to generate API key: %w", err)
}
randHex := hex.EncodeToString(b)
plaintext = apiKeyPrefix + randHex
hash = HashAPIKey(plaintext, hmacSecret)
prefix = plaintext[:len(apiKeyPrefix)+keyPrefixLen]
return plaintext, hash, prefix, nil
}
// HashAPIKey returns the HMAC-SHA256 hex digest of the given plaintext key.
// If hmacSecret is empty, falls back to plain SHA-256 for backward compatibility.
func HashAPIKey(plaintext, hmacSecret string) string {
if hmacSecret == "" {
h := sha256.Sum256([]byte(plaintext))
return hex.EncodeToString(h[:])
}
mac := hmac.New(sha256.New, []byte(hmacSecret))
mac.Write([]byte(plaintext))
return hex.EncodeToString(mac.Sum(nil))
}
// CreateAPIKey generates and stores a new API key for the given user.
// Returns the plaintext key (shown once) and the database record.
func CreateAPIKey(db *gorm.DB, userID, name, role, hmacSecret string, expiresAt *time.Time) (string, *UserAPIKey, error) {
plaintext, hash, prefix, err := GenerateAPIKey(hmacSecret)
if err != nil {
return "", nil, err
}
record := &UserAPIKey{
ID: uuid.New().String(),
UserID: userID,
Name: name,
KeyHash: hash,
KeyPrefix: prefix,
Role: role,
ExpiresAt: expiresAt,
}
if err := db.Create(record).Error; err != nil {
return "", nil, fmt.Errorf("failed to store API key: %w", err)
}
return plaintext, record, nil
}
// ValidateAPIKey looks up an API key by hashing the plaintext and searching
// the database. Returns the key record if found, or an error.
// Updates LastUsed on successful validation.
func ValidateAPIKey(db *gorm.DB, plaintext, hmacSecret string) (*UserAPIKey, error) {
hash := HashAPIKey(plaintext, hmacSecret)
var key UserAPIKey
if err := db.Preload("User").Where("key_hash = ?", hash).First(&key).Error; err != nil {
return nil, fmt.Errorf("invalid API key")
}
if key.ExpiresAt != nil && time.Now().After(*key.ExpiresAt) {
return nil, fmt.Errorf("API key expired")
}
if key.User.Status != StatusActive {
return nil, fmt.Errorf("user account is not active")
}
// Update LastUsed
now := time.Now()
db.Model(&key).Update("last_used", now)
return &key, nil
}
// ListAPIKeys returns all API keys for the given user (without plaintext).
func ListAPIKeys(db *gorm.DB, userID string) ([]UserAPIKey, error) {
var keys []UserAPIKey
if err := db.Where("user_id = ?", userID).Order("created_at DESC").Find(&keys).Error; err != nil {
return nil, err
}
return keys, nil
}
// RevokeAPIKey deletes an API key. Only the owner can revoke their own key.
func RevokeAPIKey(db *gorm.DB, keyID, userID string) error {
result := db.Where("id = ? AND user_id = ?", keyID, userID).Delete(&UserAPIKey{})
if result.RowsAffected == 0 {
return fmt.Errorf("API key not found or not owned by user")
}
return result.Error
}
// CleanExpiredAPIKeys removes all API keys that have passed their expiry time.
func CleanExpiredAPIKeys(db *gorm.DB) error {
return db.Where("expires_at IS NOT NULL AND expires_at < ?", time.Now()).Delete(&UserAPIKey{}).Error
}