Files
darroyo 4d34c6d31a
CI / test (push) Failing after 12m45s
Replace token-based admin auth with JWT session authentication
- 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
2026-07-31 17:15:29 -04:00

191 lines
5.4 KiB
Go

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)
}