diff --git a/.env.example b/.env.example index 6dacce2..46d098b 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,9 @@ RATE_LIMIT_PER_MINUTE=60 RATE_LIMIT_STORAGE=memory # RATE_LIMIT_REDIS_URL=redis://localhost:6379/0 -ADMIN_TOKEN=change-me-in-production +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-in-production +ADMIN_SESSION_TTL=24h LOG_LEVEL=info LOG_FORMAT=json diff --git a/cmd/llamalink/main.go b/cmd/llamalink/main.go index bbd49fa..3633c42 100644 --- a/cmd/llamalink/main.go +++ b/cmd/llamalink/main.go @@ -52,9 +52,9 @@ func main() { } logger.Info("database migrations complete") - // Seed admin key if needed - if err := db.SeedAdminKey(database, cfg.AdminToken); err != nil { - logger.Error("failed to seed admin key", "error", err) + // Seed admin user if needed + if err := db.SeedAdminUser(database, cfg.AdminUsername, cfg.AdminPassword); err != nil { + logger.Error("failed to seed admin user", "error", err) os.Exit(1) } diff --git a/go.mod b/go.mod index 65122db..ea7c5fe 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/go.sum b/go.sum index 668f7d7..b98a3d0 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/internal/api/handlers/admin/login.go b/internal/api/handlers/admin/login.go new file mode 100644 index 0000000..5ae4d40 --- /dev/null +++ b/internal/api/handlers/admin/login.go @@ -0,0 +1,71 @@ +package admin + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/llamalink/llamalink/internal/auth" + "github.com/llamalink/llamalink/internal/db" +) + +type LoginHandler struct { + db *gorm.DB + jwtService *auth.AdminJWTService +} + +func NewLoginHandler(db *gorm.DB, jwtService *auth.AdminJWTService) *LoginHandler { + return &LoginHandler{db: db, jwtService: jwtService} +} + +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required,min=8"` +} + +func (h *LoginHandler) Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "username and password are required (password min 8 chars)"}}) + return + } + + var user db.AdminUser + if err := h.db.Where("username = ?", req.Username).First(&user).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "invalid_credentials", "message": "Invalid username or password"}}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Internal server error"}}) + return + } + + if err := auth.ValidateAdminPassword(&user, req.Password); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "invalid_credentials", "message": "Invalid username or password"}}) + return + } + + token, expiresAt, err := h.jwtService.IssueToken(&user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Failed to issue session token"}}) + return + } + + h.db.Model(&user).Update("last_login_at", time.Now()) + + c.JSON(http.StatusOK, gin.H{ + "token": token, + "expires_at": expiresAt.Unix(), + "user": gin.H{ + "id": user.ID, + "username": user.Username, + "is_active": user.IsActive, + }, + }) +} + +func (h *LoginHandler) Logout(c *gin.Context) { + c.Status(http.StatusOK) +} diff --git a/internal/api/handlers/admin/me.go b/internal/api/handlers/admin/me.go new file mode 100644 index 0000000..3b9c83c --- /dev/null +++ b/internal/api/handlers/admin/me.go @@ -0,0 +1,93 @@ +package admin + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "github.com/llamalink/llamalink/internal/api/middleware" + "github.com/llamalink/llamalink/internal/db" +) + +type MeHandler struct { + db *gorm.DB +} + +func NewMeHandler(db *gorm.DB) *MeHandler { + return &MeHandler{db: db} +} + +func (h *MeHandler) Me(c *gin.Context) { + claims := middleware.GetAdminClaims(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "unauthorized", "message": "Not authenticated"}}) + return + } + + var user db.AdminUser + if err := h.db.Where("username = ?", claims.Username).First(&user).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "unauthorized", "message": "User not found"}}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": user.ID, + "username": user.Username, + "is_active": user.IsActive, + "created_at": user.CreatedAt, + "last_login_at": user.LastLoginAt, + }) +} + +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=8"` +} + +func (h *MeHandler) ChangePassword(c *gin.Context) { + claims := middleware.GetAdminClaims(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "unauthorized", "message": "Not authenticated"}}) + return + } + + var req ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "current_password and new_password required (min 8 chars)"}}) + return + } + + userID, err := uuid.Parse(claims.Subject) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "invalid_request", "message": "Invalid token claims"}}) + return + } + + var user db.AdminUser + if err := h.db.Where("id = ?", userID).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"code": "not_found", "message": "User not found"}}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "invalid_credentials", "message": "Current password is incorrect"}}) + return + } + + newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Failed to hash password"}}) + return + } + + h.db.Model(&user).Updates(map[string]interface{}{ + "password_hash": string(newHash), + "updated_at": time.Now(), + }) + + c.Status(http.StatusOK) +} diff --git a/internal/api/handlers/admin/users.go b/internal/api/handlers/admin/users.go new file mode 100644 index 0000000..12a0a3e --- /dev/null +++ b/internal/api/handlers/admin/users.go @@ -0,0 +1,190 @@ +package admin + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "github.com/llamalink/llamalink/internal/api/middleware" + "github.com/llamalink/llamalink/internal/db" +) + +type UsersHandler struct { + db *gorm.DB +} + +func NewUsersHandler(db *gorm.DB) *UsersHandler { + return &UsersHandler{db: db} +} + +type CreateUserRequest struct { + Username string `json:"username" binding:"required,min=3,max=64"` + Password string `json:"password" binding:"required,min=8"` +} + +type UpdateUserRequest struct { + Password *string `json:"password"` + IsActive *bool `json:"is_active"` +} + +func (h *UsersHandler) List(c *gin.Context) { + var users []db.AdminUser + if err := h.db.Order("created_at DESC").Find(&users).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Failed to list users"}}) + return + } + + result := make([]gin.H, len(users)) + for i, u := range users { + result[i] = gin.H{ + "id": u.ID, + "username": u.Username, + "is_active": u.IsActive, + "created_at": u.CreatedAt, + "last_login_at": u.LastLoginAt, + } + } + c.JSON(http.StatusOK, result) +} + +func (h *UsersHandler) Create(c *gin.Context) { + claims := middleware.GetAdminClaims(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "unauthorized", "message": "Not authenticated"}}) + return + } + + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "username (3-64 chars) and password (min 8 chars) required"}}) + return + } + + username := strings.ToLower(strings.TrimSpace(req.Username)) + + var count int64 + h.db.Model(&db.AdminUser{}).Where("username = ?", username).Count(&count) + if count > 0 { + c.JSON(http.StatusConflict, gin.H{"error": gin.H{"code": "conflict", "message": "Username already exists"}}) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Failed to hash password"}}) + return + } + + user := &db.AdminUser{ + ID: uuid.New(), + Username: username, + PasswordHash: string(hash), + IsActive: true, + } + + if err := h.db.Create(user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Failed to create user"}}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "id": user.ID, + "username": user.Username, + "is_active": user.IsActive, + "created_at": user.CreatedAt, + }) +} + +func (h *UsersHandler) Update(c *gin.Context) { + claims := middleware.GetAdminClaims(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "unauthorized", "message": "Not authenticated"}}) + return + } + + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "Invalid user ID"}}) + return + } + + currentUser, _ := uuid.Parse(claims.Subject) + if currentUser == id { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "Cannot modify your own account via this endpoint"}}) + return + } + + var req UpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "Invalid request body"}}) + return + } + + var user db.AdminUser + if err := h.db.Where("id = ?", id).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"code": "not_found", "message": "User not found"}}) + return + } + + updates := map[string]interface{}{"updated_at": time.Now()} + if req.Password != nil && *req.Password != "" { + if len(*req.Password) < 8 { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "Password must be at least 8 characters"}}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Failed to hash password"}}) + return + } + updates["password_hash"] = string(hash) + } + if req.IsActive != nil { + updates["is_active"] = *req.IsActive + } + + h.db.Model(&user).Updates(updates) + + c.JSON(http.StatusOK, gin.H{ + "id": user.ID, + "username": user.Username, + "is_active": user.IsActive, + "updated_at": user.UpdatedAt, + }) +} + +func (h *UsersHandler) Delete(c *gin.Context) { + claims := middleware.GetAdminClaims(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "unauthorized", "message": "Not authenticated"}}) + return + } + + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "Invalid user ID"}}) + return + } + + currentUser, _ := uuid.Parse(claims.Subject) + if currentUser == id { + c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "Cannot delete your own account"}}) + return + } + + var user db.AdminUser + if err := h.db.Where("id = ?", id).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"code": "not_found", "message": "User not found"}}) + return + } + + h.db.Delete(&user) + c.Status(http.StatusNoContent) +} diff --git a/internal/api/handlers/keys.go b/internal/api/handlers/keys.go index d82c48c..106a854 100644 --- a/internal/api/handlers/keys.go +++ b/internal/api/handlers/keys.go @@ -6,7 +6,6 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/llamalink/llamalink/internal/api/middleware" "github.com/llamalink/llamalink/internal/auth" ) @@ -97,18 +96,6 @@ func (h *KeysHandler) RevokeKey(c *gin.Context) { return } - // Can't revoke own key - currentKey := middleware.GetAPIKey(c) - if currentKey.ID == id { - c.JSON(http.StatusBadRequest, gin.H{ - "error": gin.H{ - "code": "validation_error", - "message": "Cannot revoke your own admin key", - }, - }) - return - } - if err := h.authService.Revoke(id); err != nil { if err == auth.ErrKeyNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "key not found"}) diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index 4bdee1b..f969b65 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -14,6 +14,7 @@ import ( const ( ApiKeyCtx = "api_key" ApiKeyIDCtx = "api_key_id" + AdminCtx = "admin_user" ) func APIKeyAuth(authService *auth.Service) gin.HandlerFunc { @@ -142,6 +143,46 @@ func AdminTokenAuth(token string) gin.HandlerFunc { } } +func AdminSessionAuth(jwtService *auth.AdminJWTService) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{ + "code": "unauthorized", + "message": "Authorization header required", + }, + }) + return + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + if token == authHeader || strings.HasPrefix(token, auth.TokenPrefix) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{ + "code": "unauthorized", + "message": "Admin session required", + }, + }) + return + } + + claims, err := jwtService.VerifyToken(token) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{ + "code": "unauthorized", + "message": "Invalid or expired session", + }, + }) + return + } + + c.Set(AdminCtx, claims) + c.Next() + } +} + func GetAPIKeyID(c *gin.Context) uuid.UUID { id, _ := c.Get(ApiKeyIDCtx) return id.(uuid.UUID) @@ -154,3 +195,11 @@ func GetAPIKey(c *gin.Context) *db.ApiKey { } return key.(*db.ApiKey) } + +func GetAdminClaims(c *gin.Context) *auth.AdminClaims { + claims, _ := c.Get(AdminCtx) + if claims == nil { + return nil + } + return claims.(*auth.AdminClaims) +} diff --git a/internal/api/router.go b/internal/api/router.go index 249b0f0..8ddcd5f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "github.com/llamalink/llamalink/internal/api/handlers" + "github.com/llamalink/llamalink/internal/api/handlers/admin" "github.com/llamalink/llamalink/internal/api/middleware" "github.com/llamalink/llamalink/internal/auth" "github.com/llamalink/llamalink/internal/config" @@ -30,6 +31,7 @@ func New(cfg *config.Config, db *gorm.DB, llamaManager *llama.Manager) *gin.Engi quotaSvc := quota.NewService(db) webhookSvc := quota.NewWebhookService(db) proxy := llama.NewProxy(llamaManager) + adminJWTService := auth.NewAdminJWTService(cfg.JWTSecret, cfg.AdminSessionTTL) // Initialize handlers healthHandler := handlers.NewHealthHandler(db, llamaManager) @@ -37,6 +39,9 @@ func New(cfg *config.Config, db *gorm.DB, llamaManager *llama.Manager) *gin.Engi keysHandler := handlers.NewKeysHandler(authService) modelsHandler := handlers.NewModelsHandler(llamaManager) usageHandler := handlers.NewUsageHandler(db, quotaSvc) + adminLoginHandler := admin.NewLoginHandler(db, adminJWTService) + adminMeHandler := admin.NewMeHandler(db) + adminUsersHandler := admin.NewUsersHandler(db) // Health endpoints (public) r.GET("/health", healthHandler.Health) @@ -50,10 +55,9 @@ func New(cfg *config.Config, db *gorm.DB, llamaManager *llama.Manager) *gin.Engi chat.Use(middleware.APIKeyAuth(authService)) chat.POST("/completions", chatHandler.ChatCompletions) - // Keys management (admin only) + // Keys management (admin session required) keys := v1.Group("/keys") - keys.Use(middleware.APIKeyAuth(authService)) - keys.Use(middleware.AdminOnly()) + keys.Use(middleware.AdminSessionAuth(adminJWTService)) keys.GET("", keysHandler.ListKeys) keys.POST("", keysHandler.CreateKey) keys.DELETE("/:id", keysHandler.RevokeKey) @@ -66,10 +70,9 @@ func New(cfg *config.Config, db *gorm.DB, llamaManager *llama.Manager) *gin.Engi models.GET("/active", modelsHandler.GetActiveModel) models.GET("/:name", modelsHandler.GetActiveModel) // alias for compatibility - // Model load (admin only) + // Model load (admin session required) modelLoad := v1.Group("/models") - modelLoad.Use(middleware.APIKeyAuth(authService)) - modelLoad.Use(middleware.AdminOnly()) + modelLoad.Use(middleware.AdminSessionAuth(adminJWTService)) modelLoad.POST("/:name/load", modelsHandler.LoadModel) // Usage (requires API key auth) @@ -110,5 +113,18 @@ func New(cfg *config.Config, db *gorm.DB, llamaManager *llama.Manager) *gin.Engi c.Data(http.StatusOK, "text/html; charset=utf-8", index) }) + // Admin API v1 (JWT session auth) + adminV1 := v1.Group("/admin") + adminV1.POST("/login", adminLoginHandler.Login) + adminV1.POST("/logout", adminLoginHandler.Logout) + adminSession := adminV1.Group("") + adminSession.Use(middleware.AdminSessionAuth(adminJWTService)) + adminSession.GET("/me", adminMeHandler.Me) + adminSession.POST("/change-password", adminMeHandler.ChangePassword) + adminSession.GET("/users", adminUsersHandler.List) + adminSession.POST("/users", adminUsersHandler.Create) + adminSession.PUT("/users/:id", adminUsersHandler.Update) + adminSession.DELETE("/users/:id", adminUsersHandler.Delete) + return r } diff --git a/internal/auth/admin.go b/internal/auth/admin.go new file mode 100644 index 0000000..21f7400 --- /dev/null +++ b/internal/auth/admin.go @@ -0,0 +1,86 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + + "github.com/llamalink/llamalink/internal/db" +) + +var ( + ErrInvalidAdminCredentials = errors.New("invalid username or password") + ErrAdminNotFound = errors.New("admin user not found") + ErrAdminInactive = errors.New("admin user is inactive") +) + +type AdminClaims struct { + Username string `json:"username"` + jwt.RegisteredClaims +} + +type AdminJWTService struct { + secret []byte + ttl time.Duration +} + +func NewAdminJWTService(secret []byte, ttlSeconds int) *AdminJWTService { + return &AdminJWTService{ + secret: secret, + ttl: time.Duration(ttlSeconds) * time.Second, + } +} + +func (s *AdminJWTService) IssueToken(user *db.AdminUser) (string, time.Time, error) { + expiresAt := time.Now().Add(s.ttl) + claims := &AdminClaims{ + Username: user.Username, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: user.ID.String(), + ExpiresAt: jwt.NewNumericDate(expiresAt), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, err := token.SignedString(s.secret) + return tokenStr, expiresAt, err +} + +func (s *AdminJWTService) VerifyToken(tokenStr string) (*AdminClaims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &AdminClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return s.secret, nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*AdminClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} + +func ValidateAdminPassword(user *db.AdminUser, password string) error { + if !user.IsActive { + return ErrAdminInactive + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return ErrInvalidAdminCredentials + } + return nil +} + +func GenerateSecureToken(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} diff --git a/internal/config/config.go b/internal/config/config.go index c5ca448..bfe9b61 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,7 +33,10 @@ type Config struct { RateLimitStorage string // "memory" or "redis" // Auth - AdminToken string + AdminUsername string + AdminPassword string + JWTSecret []byte + AdminSessionTTL int // seconds // Logging LogLevel string @@ -58,10 +61,19 @@ func Load() *Config { ModelSwapCooldown: intEnv("MODEL_SWAP_COOLDOWN", 2), RateLimitPerMinute: intEnv("RATE_LIMIT_PER_MINUTE", 60), RateLimitStorage: getEnv("RATE_LIMIT_STORAGE", "memory"), - AdminToken: getEnv("ADMIN_TOKEN", "changeme"), + AdminUsername: getEnv("ADMIN_USERNAME", "admin"), + AdminPassword: getEnv("ADMIN_PASSWORD", ""), + AdminSessionTTL: intEnv("ADMIN_SESSION_TTL", 86400), LogLevel: getEnv("LOG_LEVEL", "info"), LogFormat: getEnv("LOG_FORMAT", "json"), } + + secretKey := getEnv("LLAMALINK_SECRET_KEY", "change-me-in-production") + if secretKey == "change-me-in-production" { + slog.Warn("LLAMALINK_SECRET_KEY is using the default value — set a secure random string in production") + } + c.JWTSecret = []byte(secretKey) + return c } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 58271cb..566cce8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -32,7 +32,8 @@ func TestLoad(t *testing.T) { func TestLoadEnvOverride(t *testing.T) { t.Setenv("LLAMALINK_PORT", "9000") t.Setenv("LLAMALINK_ENV", "production") - t.Setenv("ADMIN_TOKEN", "secret-token") + t.Setenv("ADMIN_USERNAME", "admin") + t.Setenv("ADMIN_PASSWORD", "secret-password") cfg := Load() @@ -44,8 +45,12 @@ func TestLoadEnvOverride(t *testing.T) { t.Errorf("expected env production, got %s", cfg.LlamalinkEnv) } - if cfg.AdminToken != "secret-token" { - t.Errorf("expected AdminToken secret-token, got %s", cfg.AdminToken) + if cfg.AdminUsername != "admin" { + t.Errorf("expected AdminUsername admin, got %s", cfg.AdminUsername) + } + + if cfg.AdminPassword != "secret-password" { + t.Errorf("expected AdminPassword secret-password, got %s", cfg.AdminPassword) } } diff --git a/internal/db/models.go b/internal/db/models.go index 543517d..cce807c 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -27,6 +27,25 @@ func (a *StringArray) Scan(value interface{}) error { return json.Unmarshal(b, a) } +type AdminUser struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + Username string `gorm:"size:64;uniqueIndex;not null" json:"username"` + PasswordHash string `gorm:"size:255;not null" json:"-"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` +} + +func (AdminUser) TableName() string { return "admin_users" } + +func (u *AdminUser) BeforeCreate(tx *gorm.DB) error { + if u.ID == uuid.Nil { + u.ID = uuid.New() + } + return nil +} + type ApiKey struct { ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` Name string `gorm:"size:255;not null" json:"name"` diff --git a/internal/db/seed.go b/internal/db/seed.go index 8346b76..b242858 100644 --- a/internal/db/seed.go +++ b/internal/db/seed.go @@ -1,42 +1,59 @@ package db import ( + "crypto/rand" + "encoding/hex" "log/slog" + "strings" "golang.org/x/crypto/bcrypt" "github.com/google/uuid" "gorm.io/gorm" ) -func SeedAdminKey(db *gorm.DB, adminToken string) error { - var count int64 - db.Model(&ApiKey{}).Count(&count) +func generateRandomPassword(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes)[:length], nil +} +func SeedAdminUser(db *gorm.DB, username, password string) error { + var count int64 + db.Model(&AdminUser{}).Count(&count) if count > 0 { - slog.Info("admin key already exists, skipping seed") + slog.Info("admin user already exists, skipping seed") return nil } - hash, err := bcrypt.GenerateFromPassword([]byte(adminToken), bcrypt.DefaultCost) + pwd := password + if pwd == "" { + var err error + pwd, err = generateRandomPassword(24) + if err != nil { + return err + } + slog.Warn("no ADMIN_PASSWORD set — generated random password (save this, it won't be shown again)") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) if err != nil { return err } - admin := ApiKey{ - ID: uuid.New(), - Name: "admin", - KeyHash: string(hash), - KeyPrefix: "admin-", - Scopes: StringArray{"chat", "models", "usage", "admin"}, - IsActive: true, - IsAdmin: true, + admin := AdminUser{ + ID: uuid.New(), + Username: strings.ToLower(username), + PasswordHash: string(hash), + IsActive: true, } if err := db.Create(&admin).Error; err != nil { return err } - slog.Info("admin key seeded", "prefix", admin.KeyPrefix) - slog.Warn("ADMIN TOKEN: " + adminToken + " (save this!)") + slog.Info("admin user seeded", "username", admin.Username) + slog.Warn("ADMIN USER: username=" + username + " password=" + pwd + " (save this, it won't be shown again)") return nil } diff --git a/web/frontend/src/lib/api.ts b/web/frontend/src/lib/api.ts index b7a759b..692a953 100644 --- a/web/frontend/src/lib/api.ts +++ b/web/frontend/src/lib/api.ts @@ -12,7 +12,7 @@ api.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { - localStorage.removeItem('admin_token') + localStorage.removeItem('admin_session') window.location.href = '/admin/login' } return Promise.reject(error) diff --git a/web/frontend/src/router/index.ts b/web/frontend/src/router/index.ts index 177155a..fbebd49 100644 --- a/web/frontend/src/router/index.ts +++ b/web/frontend/src/router/index.ts @@ -33,6 +33,11 @@ const routes = [ name: 'Usage', component: () => import('@/views/Usage.vue'), }, + { + path: 'users', + name: 'AdminUsers', + component: () => import('@/views/AdminUsers.vue'), + }, ], }, { diff --git a/web/frontend/src/stores/auth.ts b/web/frontend/src/stores/auth.ts index 534d8fb..9775538 100644 --- a/web/frontend/src/stores/auth.ts +++ b/web/frontend/src/stores/auth.ts @@ -3,20 +3,20 @@ import { ref, computed } from 'vue' import { api } from '@/lib/api' export const useAuthStore = defineStore('auth', () => { - const token = ref(localStorage.getItem('admin_token')) + const token = ref(localStorage.getItem('admin_session')) const loading = ref(false) const error = ref(null) const isAuthenticated = computed(() => !!token.value) - async function login(adminToken: string) { + async function login(username: string, password: string) { loading.value = true error.value = null try { - const response = await api.post('/api/v1/admin/login', { admin_token: adminToken }) + const response = await api.post('/api/v1/admin/login', { username, password }) token.value = response.data.token - localStorage.setItem('admin_token', response.data.token) + localStorage.setItem('admin_session', response.data.token) api.defaults.headers.common['Authorization'] = `Bearer ${response.data.token}` return true } catch (err: any) { @@ -29,7 +29,7 @@ export const useAuthStore = defineStore('auth', () => { function logout() { token.value = null - localStorage.removeItem('admin_token') + localStorage.removeItem('admin_session') delete api.defaults.headers.common['Authorization'] } diff --git a/web/frontend/src/views/AdminUsers.vue b/web/frontend/src/views/AdminUsers.vue new file mode 100644 index 0000000..7eb57df --- /dev/null +++ b/web/frontend/src/views/AdminUsers.vue @@ -0,0 +1,199 @@ + + + diff --git a/web/frontend/src/views/Layout.vue b/web/frontend/src/views/Layout.vue index 3c5dd3c..f7a08ce 100644 --- a/web/frontend/src/views/Layout.vue +++ b/web/frontend/src/views/Layout.vue @@ -1,7 +1,7 @@