Replace token-based admin auth with JWT session authentication
CI / test (push) Failing after 12m45s
CI / test (push) Failing after 12m45s
- Add AdminUser model (bcrypt hashed passwords) and admin_users table - Add AdminJWTService for HS256 JWT sessions (24h TTL) - Add AdminSessionAuth middleware for /api/v1/admin/* routes - Add admin handlers: login, logout, me, change-password, users CRUD - Keys and model management routes now require admin JWT session - Remove ADMIN_TOKEN, add ADMIN_USERNAME, ADMIN_PASSWORD env vars - Update frontend: username/password login, admin_session storage, AdminUsers CRUD view
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+22
-6
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user