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:
+3
-1
@@ -20,7 +20,9 @@ RATE_LIMIT_PER_MINUTE=60
|
|||||||
RATE_LIMIT_STORAGE=memory
|
RATE_LIMIT_STORAGE=memory
|
||||||
# RATE_LIMIT_REDIS_URL=redis://localhost:6379/0
|
# 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_LEVEL=info
|
||||||
LOG_FORMAT=json
|
LOG_FORMAT=json
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
logger.Info("database migrations complete")
|
logger.Info("database migrations complete")
|
||||||
|
|
||||||
// Seed admin key if needed
|
// Seed admin user if needed
|
||||||
if err := db.SeedAdminKey(database, cfg.AdminToken); err != nil {
|
if err := db.SeedAdminUser(database, cfg.AdminUsername, cfg.AdminPassword); err != nil {
|
||||||
logger.Error("failed to seed admin key", "error", err)
|
logger.Error("failed to seed admin user", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // 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/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
|||||||
@@ -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-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 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
|||||||
@@ -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/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/llamalink/llamalink/internal/api/middleware"
|
|
||||||
"github.com/llamalink/llamalink/internal/auth"
|
"github.com/llamalink/llamalink/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,18 +96,6 @@ func (h *KeysHandler) RevokeKey(c *gin.Context) {
|
|||||||
return
|
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 := h.authService.Revoke(id); err != nil {
|
||||||
if err == auth.ErrKeyNotFound {
|
if err == auth.ErrKeyNotFound {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
ApiKeyCtx = "api_key"
|
ApiKeyCtx = "api_key"
|
||||||
ApiKeyIDCtx = "api_key_id"
|
ApiKeyIDCtx = "api_key_id"
|
||||||
|
AdminCtx = "admin_user"
|
||||||
)
|
)
|
||||||
|
|
||||||
func APIKeyAuth(authService *auth.Service) gin.HandlerFunc {
|
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 {
|
func GetAPIKeyID(c *gin.Context) uuid.UUID {
|
||||||
id, _ := c.Get(ApiKeyIDCtx)
|
id, _ := c.Get(ApiKeyIDCtx)
|
||||||
return id.(uuid.UUID)
|
return id.(uuid.UUID)
|
||||||
@@ -154,3 +195,11 @@ func GetAPIKey(c *gin.Context) *db.ApiKey {
|
|||||||
}
|
}
|
||||||
return key.(*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/gin-gonic/gin"
|
||||||
"github.com/llamalink/llamalink/internal/api/handlers"
|
"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/api/middleware"
|
||||||
"github.com/llamalink/llamalink/internal/auth"
|
"github.com/llamalink/llamalink/internal/auth"
|
||||||
"github.com/llamalink/llamalink/internal/config"
|
"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)
|
quotaSvc := quota.NewService(db)
|
||||||
webhookSvc := quota.NewWebhookService(db)
|
webhookSvc := quota.NewWebhookService(db)
|
||||||
proxy := llama.NewProxy(llamaManager)
|
proxy := llama.NewProxy(llamaManager)
|
||||||
|
adminJWTService := auth.NewAdminJWTService(cfg.JWTSecret, cfg.AdminSessionTTL)
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
healthHandler := handlers.NewHealthHandler(db, llamaManager)
|
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)
|
keysHandler := handlers.NewKeysHandler(authService)
|
||||||
modelsHandler := handlers.NewModelsHandler(llamaManager)
|
modelsHandler := handlers.NewModelsHandler(llamaManager)
|
||||||
usageHandler := handlers.NewUsageHandler(db, quotaSvc)
|
usageHandler := handlers.NewUsageHandler(db, quotaSvc)
|
||||||
|
adminLoginHandler := admin.NewLoginHandler(db, adminJWTService)
|
||||||
|
adminMeHandler := admin.NewMeHandler(db)
|
||||||
|
adminUsersHandler := admin.NewUsersHandler(db)
|
||||||
|
|
||||||
// Health endpoints (public)
|
// Health endpoints (public)
|
||||||
r.GET("/health", healthHandler.Health)
|
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.Use(middleware.APIKeyAuth(authService))
|
||||||
chat.POST("/completions", chatHandler.ChatCompletions)
|
chat.POST("/completions", chatHandler.ChatCompletions)
|
||||||
|
|
||||||
// Keys management (admin only)
|
// Keys management (admin session required)
|
||||||
keys := v1.Group("/keys")
|
keys := v1.Group("/keys")
|
||||||
keys.Use(middleware.APIKeyAuth(authService))
|
keys.Use(middleware.AdminSessionAuth(adminJWTService))
|
||||||
keys.Use(middleware.AdminOnly())
|
|
||||||
keys.GET("", keysHandler.ListKeys)
|
keys.GET("", keysHandler.ListKeys)
|
||||||
keys.POST("", keysHandler.CreateKey)
|
keys.POST("", keysHandler.CreateKey)
|
||||||
keys.DELETE("/:id", keysHandler.RevokeKey)
|
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("/active", modelsHandler.GetActiveModel)
|
||||||
models.GET("/:name", modelsHandler.GetActiveModel) // alias for compatibility
|
models.GET("/:name", modelsHandler.GetActiveModel) // alias for compatibility
|
||||||
|
|
||||||
// Model load (admin only)
|
// Model load (admin session required)
|
||||||
modelLoad := v1.Group("/models")
|
modelLoad := v1.Group("/models")
|
||||||
modelLoad.Use(middleware.APIKeyAuth(authService))
|
modelLoad.Use(middleware.AdminSessionAuth(adminJWTService))
|
||||||
modelLoad.Use(middleware.AdminOnly())
|
|
||||||
modelLoad.POST("/:name/load", modelsHandler.LoadModel)
|
modelLoad.POST("/:name/load", modelsHandler.LoadModel)
|
||||||
|
|
||||||
// Usage (requires API key auth)
|
// 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)
|
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
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -33,7 +33,10 @@ type Config struct {
|
|||||||
RateLimitStorage string // "memory" or "redis"
|
RateLimitStorage string // "memory" or "redis"
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
AdminToken string
|
AdminUsername string
|
||||||
|
AdminPassword string
|
||||||
|
JWTSecret []byte
|
||||||
|
AdminSessionTTL int // seconds
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
LogLevel string
|
LogLevel string
|
||||||
@@ -58,10 +61,19 @@ func Load() *Config {
|
|||||||
ModelSwapCooldown: intEnv("MODEL_SWAP_COOLDOWN", 2),
|
ModelSwapCooldown: intEnv("MODEL_SWAP_COOLDOWN", 2),
|
||||||
RateLimitPerMinute: intEnv("RATE_LIMIT_PER_MINUTE", 60),
|
RateLimitPerMinute: intEnv("RATE_LIMIT_PER_MINUTE", 60),
|
||||||
RateLimitStorage: getEnv("RATE_LIMIT_STORAGE", "memory"),
|
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"),
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||||
LogFormat: getEnv("LOG_FORMAT", "json"),
|
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
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ func TestLoad(t *testing.T) {
|
|||||||
func TestLoadEnvOverride(t *testing.T) {
|
func TestLoadEnvOverride(t *testing.T) {
|
||||||
t.Setenv("LLAMALINK_PORT", "9000")
|
t.Setenv("LLAMALINK_PORT", "9000")
|
||||||
t.Setenv("LLAMALINK_ENV", "production")
|
t.Setenv("LLAMALINK_ENV", "production")
|
||||||
t.Setenv("ADMIN_TOKEN", "secret-token")
|
t.Setenv("ADMIN_USERNAME", "admin")
|
||||||
|
t.Setenv("ADMIN_PASSWORD", "secret-password")
|
||||||
|
|
||||||
cfg := Load()
|
cfg := Load()
|
||||||
|
|
||||||
@@ -44,8 +45,12 @@ func TestLoadEnvOverride(t *testing.T) {
|
|||||||
t.Errorf("expected env production, got %s", cfg.LlamalinkEnv)
|
t.Errorf("expected env production, got %s", cfg.LlamalinkEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.AdminToken != "secret-token" {
|
if cfg.AdminUsername != "admin" {
|
||||||
t.Errorf("expected AdminToken secret-token, got %s", cfg.AdminToken)
|
t.Errorf("expected AdminUsername admin, got %s", cfg.AdminUsername)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AdminPassword != "secret-password" {
|
||||||
|
t.Errorf("expected AdminPassword secret-password, got %s", cfg.AdminPassword)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,25 @@ func (a *StringArray) Scan(value interface{}) error {
|
|||||||
return json.Unmarshal(b, a)
|
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 {
|
type ApiKey struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
|
||||||
Name string `gorm:"size:255;not null" json:"name"`
|
Name string `gorm:"size:255;not null" json:"name"`
|
||||||
|
|||||||
+30
-13
@@ -1,42 +1,59 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SeedAdminKey(db *gorm.DB, adminToken string) error {
|
func generateRandomPassword(length int) (string, error) {
|
||||||
var count int64
|
bytes := make([]byte, length)
|
||||||
db.Model(&ApiKey{}).Count(&count)
|
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 {
|
if count > 0 {
|
||||||
slog.Info("admin key already exists, skipping seed")
|
slog.Info("admin user already exists, skipping seed")
|
||||||
return nil
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
admin := ApiKey{
|
admin := AdminUser{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
Name: "admin",
|
Username: strings.ToLower(username),
|
||||||
KeyHash: string(hash),
|
PasswordHash: string(hash),
|
||||||
KeyPrefix: "admin-",
|
|
||||||
Scopes: StringArray{"chat", "models", "usage", "admin"},
|
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsAdmin: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&admin).Error; err != nil {
|
if err := db.Create(&admin).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("admin key seeded", "prefix", admin.KeyPrefix)
|
slog.Info("admin user seeded", "username", admin.Username)
|
||||||
slog.Warn("ADMIN TOKEN: " + adminToken + " (save this!)")
|
slog.Warn("ADMIN USER: username=" + username + " password=" + pwd + " (save this, it won't be shown again)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ api.interceptors.response.use(
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
localStorage.removeItem('admin_token')
|
localStorage.removeItem('admin_session')
|
||||||
window.location.href = '/admin/login'
|
window.location.href = '/admin/login'
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ const routes = [
|
|||||||
name: 'Usage',
|
name: 'Usage',
|
||||||
component: () => import('@/views/Usage.vue'),
|
component: () => import('@/views/Usage.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'users',
|
||||||
|
name: 'AdminUsers',
|
||||||
|
component: () => import('@/views/AdminUsers.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ import { ref, computed } from 'vue'
|
|||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref<string | null>(localStorage.getItem('admin_token'))
|
const token = ref<string | null>(localStorage.getItem('admin_session'))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!token.value)
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
|
||||||
async function login(adminToken: string) {
|
async function login(username: string, password: string) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
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
|
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}`
|
api.defaults.headers.common['Authorization'] = `Bearer ${response.data.token}`
|
||||||
return true
|
return true
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -29,7 +29,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
token.value = null
|
token.value = null
|
||||||
localStorage.removeItem('admin_token')
|
localStorage.removeItem('admin_session')
|
||||||
delete api.defaults.headers.common['Authorization']
|
delete api.defaults.headers.common['Authorization']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { Plus, Trash2, Edit2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
interface AdminUser {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
last_login_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = ref<AdminUser[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const showEditDialog = ref(false)
|
||||||
|
const newUsername = ref('')
|
||||||
|
const newPassword = ref('')
|
||||||
|
const editingUser = ref<AdminUser | null>(null)
|
||||||
|
const editPassword = ref('')
|
||||||
|
const editIsActive = ref(true)
|
||||||
|
|
||||||
|
async function fetchUsers() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get('/api/v1/admin/users')
|
||||||
|
users.value = res.data
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch users:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser() {
|
||||||
|
try {
|
||||||
|
await api.post('/api/v1/admin/users', {
|
||||||
|
username: newUsername.value,
|
||||||
|
password: newPassword.value,
|
||||||
|
})
|
||||||
|
showCreateDialog.value = false
|
||||||
|
newUsername.value = ''
|
||||||
|
newPassword.value = ''
|
||||||
|
await fetchUsers()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create user:', err)
|
||||||
|
alert('Failed to create user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUser() {
|
||||||
|
if (!editingUser.value) return
|
||||||
|
try {
|
||||||
|
const payload: any = {}
|
||||||
|
if (editPassword.value) {
|
||||||
|
payload.password = editPassword.value
|
||||||
|
}
|
||||||
|
payload.is_active = editIsActive.value
|
||||||
|
await api.put(`/api/v1/admin/users/${editingUser.value.id}`, payload)
|
||||||
|
showEditDialog.value = false
|
||||||
|
editingUser.value = null
|
||||||
|
editPassword.value = ''
|
||||||
|
await fetchUsers()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update user:', err)
|
||||||
|
alert('Failed to update user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(id: string) {
|
||||||
|
if (!confirm('Are you sure you want to delete this user?')) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/v1/admin/users/${id}`)
|
||||||
|
await fetchUsers()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete user:', err)
|
||||||
|
alert('Failed to delete user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(user: AdminUser) {
|
||||||
|
editingUser.value = user
|
||||||
|
editIsActive.value = user.is_active
|
||||||
|
editPassword.value = ''
|
||||||
|
showEditDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: string | null) {
|
||||||
|
if (!date) return 'Never'
|
||||||
|
return new Date(date).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchUsers)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">Admin Users</h1>
|
||||||
|
<button @click="showCreateDialog = true" class="btn btn-primary">
|
||||||
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
|
New User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-text-muted">Loading...</div>
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div class="card">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Last Login</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="user in users" :key="user.id">
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>
|
||||||
|
<span :class="user.is_active ? 'badge-success' : 'badge-error'" class="badge">
|
||||||
|
{{ user.is_active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDate(user.created_at) }}</td>
|
||||||
|
<td>{{ formatDate(user.last_login_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="openEditDialog(user)" class="btn btn-secondary btn-sm">
|
||||||
|
<Edit2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button @click="deleteUser(user.id)" class="btn btn-danger btn-sm">
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="users.length === 0">
|
||||||
|
<td colspan="5" class="text-center text-text-muted py-8">
|
||||||
|
No admin users yet. Create one to get started.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Dialog -->
|
||||||
|
<div v-if="showCreateDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="card w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Create Admin User</h2>
|
||||||
|
<form @submit.prevent="createUser">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-2">Username</label>
|
||||||
|
<input v-model="newUsername" type="text" class="input" placeholder="admin" required minlength="3" maxlength="64" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-2">Password</label>
|
||||||
|
<input v-model="newPassword" type="password" class="input" placeholder="Min 8 characters" required minlength="8" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<button type="button" @click="showCreateDialog = false" class="btn btn-secondary">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Dialog -->
|
||||||
|
<div v-if="showEditDialog && editingUser" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="card w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Edit User: {{ editingUser.username }}</h2>
|
||||||
|
<form @submit.prevent="updateUser">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-2">New Password (leave blank to keep current)</label>
|
||||||
|
<input v-model="editPassword" type="password" class="input" placeholder="Min 8 characters" minlength="8" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input v-model="editIsActive" type="checkbox" class="rounded" />
|
||||||
|
<span class="text-sm font-medium">Active</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<button type="button" @click="showEditDialog = false" class="btn btn-secondary">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router'
|
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { LayoutDashboard, Key, Cpu, BarChart3, LogOut } from 'lucide-vue-next'
|
import { LayoutDashboard, Key, Cpu, BarChart3, LogOut, Users } from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -12,6 +12,7 @@ const navItems = [
|
|||||||
{ name: 'API Keys', path: '/admin/keys', icon: Key },
|
{ name: 'API Keys', path: '/admin/keys', icon: Key },
|
||||||
{ name: 'Models', path: '/admin/models', icon: Cpu },
|
{ name: 'Models', path: '/admin/models', icon: Cpu },
|
||||||
{ name: 'Usage', path: '/admin/usage', icon: BarChart3 },
|
{ name: 'Usage', path: '/admin/usage', icon: BarChart3 },
|
||||||
|
{ name: 'Admin Users', path: '/admin/users', icon: Users },
|
||||||
]
|
]
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
|
|||||||
@@ -7,16 +7,21 @@ import { Key } from 'lucide-vue-next'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const adminToken = ref('')
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
if (!adminToken.value.trim()) {
|
if (!username.value.trim()) {
|
||||||
error.value = 'Admin token is required'
|
error.value = 'Username is required'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password.value) {
|
||||||
|
error.value = 'Password is required'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await authStore.login(adminToken.value)
|
const success = await authStore.login(username.value, password.value)
|
||||||
if (success) {
|
if (success) {
|
||||||
router.push('/admin/')
|
router.push('/admin/')
|
||||||
} else {
|
} else {
|
||||||
@@ -41,12 +46,23 @@ async function handleLogin() {
|
|||||||
|
|
||||||
<form @submit.prevent="handleLogin" class="space-y-4">
|
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2">Admin Token</label>
|
<label class="block text-sm font-medium mb-2">Username</label>
|
||||||
<input
|
<input
|
||||||
v-model="adminToken"
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
type="password"
|
type="password"
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="Enter your admin token"
|
placeholder="Enter your password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user