1071 lines
30 KiB
Go
1071 lines
30 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/api/middleware"
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
"github.com/Wikid82/charon/backend/internal/services"
|
|
"github.com/Wikid82/charon/backend/internal/utils"
|
|
)
|
|
|
|
type UserHandler struct {
|
|
DB *gorm.DB
|
|
MailService *services.MailService
|
|
securitySvc *services.SecurityService
|
|
}
|
|
|
|
func NewUserHandler(db *gorm.DB) *UserHandler {
|
|
return &UserHandler{
|
|
DB: db,
|
|
MailService: services.NewMailService(db),
|
|
securitySvc: services.NewSecurityService(db),
|
|
}
|
|
}
|
|
|
|
func (h *UserHandler) actorFromContext(c *gin.Context) string {
|
|
if userID, ok := c.Get("userID"); ok {
|
|
return fmt.Sprintf("%v", userID)
|
|
}
|
|
return c.ClientIP()
|
|
}
|
|
|
|
func (h *UserHandler) logUserAudit(c *gin.Context, action string, user *models.User, details map[string]any) {
|
|
if h.securitySvc == nil || user == nil {
|
|
return
|
|
}
|
|
|
|
detailsJSON, err := json.Marshal(details)
|
|
if err != nil {
|
|
detailsJSON = []byte("{}")
|
|
}
|
|
|
|
_ = h.securitySvc.LogAudit(&models.SecurityAudit{
|
|
Actor: h.actorFromContext(c),
|
|
Action: action,
|
|
EventCategory: "user",
|
|
ResourceID: &user.ID,
|
|
ResourceUUID: user.UUID,
|
|
Details: string(detailsJSON),
|
|
IPAddress: c.ClientIP(),
|
|
UserAgent: c.Request.UserAgent(),
|
|
})
|
|
}
|
|
|
|
func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) {
|
|
r.GET("/setup", h.GetSetupStatus)
|
|
r.POST("/setup", h.Setup)
|
|
r.GET("/profile", h.GetProfile)
|
|
r.POST("/regenerate-api-key", h.RegenerateAPIKey)
|
|
r.PUT("/profile", h.UpdateProfile)
|
|
|
|
// User management (admin only)
|
|
r.GET("/users", h.ListUsers)
|
|
r.POST("/users", h.CreateUser)
|
|
r.POST("/users/invite", h.InviteUser)
|
|
r.GET("/users/:id", h.GetUser)
|
|
r.PUT("/users/:id", h.UpdateUser)
|
|
r.DELETE("/users/:id", h.DeleteUser)
|
|
r.PUT("/users/:id/permissions", h.UpdateUserPermissions)
|
|
|
|
// Invite acceptance (public)
|
|
r.GET("/invite/validate", h.ValidateInvite)
|
|
r.POST("/invite/accept", h.AcceptInvite)
|
|
}
|
|
|
|
// GetSetupStatus checks if the application needs initial setup (i.e., no users exist).
|
|
func (h *UserHandler) GetSetupStatus(c *gin.Context) {
|
|
var count int64
|
|
if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"setupRequired": count == 0,
|
|
})
|
|
}
|
|
|
|
type SetupRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required,min=8"`
|
|
}
|
|
|
|
func isSetupConflictError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
errText := strings.ToLower(err.Error())
|
|
return strings.Contains(errText, "unique constraint failed") ||
|
|
strings.Contains(errText, "duplicate key") ||
|
|
strings.Contains(errText, "database is locked") ||
|
|
strings.Contains(errText, "database table is locked")
|
|
}
|
|
|
|
// Setup creates the initial admin user and configures the ACME email.
|
|
func (h *UserHandler) Setup(c *gin.Context) {
|
|
// 1. Check if setup is allowed
|
|
var count int64
|
|
if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"})
|
|
return
|
|
}
|
|
|
|
if count > 0 {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"})
|
|
return
|
|
}
|
|
|
|
// 2. Parse request
|
|
var req SetupRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// 3. Create User
|
|
user := models.User{
|
|
UUID: uuid.New().String(),
|
|
Name: req.Name,
|
|
Email: strings.ToLower(req.Email),
|
|
Role: "admin",
|
|
Enabled: true,
|
|
APIKey: uuid.New().String(),
|
|
}
|
|
|
|
if err := user.SetPassword(req.Password); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
|
return
|
|
}
|
|
|
|
// 4. Create Setting for ACME Email
|
|
acmeEmailSetting := models.Setting{
|
|
Key: "caddy.acme_email",
|
|
Value: req.Email,
|
|
Type: "string",
|
|
Category: "caddy",
|
|
}
|
|
|
|
// Transaction to ensure both succeed
|
|
err := h.DB.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(&user).Error; err != nil {
|
|
return err
|
|
}
|
|
// Use Save to update if exists (though it shouldn't in fresh setup) or create
|
|
if err := tx.Where(models.Setting{Key: "caddy.acme_email"}).Assign(models.Setting{Value: req.Email}).FirstOrCreate(&acmeEmailSetting).Error; err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
var postTxCount int64
|
|
if countErr := h.DB.Model(&models.User{}).Count(&postTxCount).Error; countErr == nil && postTxCount > 0 {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"})
|
|
return
|
|
}
|
|
|
|
if isSetupConflictError(err) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Setup conflict: setup already in progress or completed"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete setup: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"message": "Setup completed successfully",
|
|
"user": gin.H{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
},
|
|
})
|
|
}
|
|
|
|
// RegenerateAPIKey generates a new API key for the authenticated user.
|
|
func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
|
|
userID, exists := c.Get("userID")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
|
|
apiKey := uuid.New().String()
|
|
|
|
if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Update("api_key", apiKey).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update API key"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "API key regenerated successfully",
|
|
"has_api_key": true,
|
|
"api_key_masked": maskSecretForResponse(apiKey),
|
|
"api_key_updated": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
// GetProfile returns the current user's profile including API key.
|
|
func (h *UserHandler) GetProfile(c *gin.Context) {
|
|
userID, exists := c.Get("userID")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if err := h.DB.First(&user, userID).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
"role": user.Role,
|
|
"has_api_key": strings.TrimSpace(user.APIKey) != "",
|
|
"api_key_masked": maskSecretForResponse(user.APIKey),
|
|
})
|
|
}
|
|
|
|
type UpdateProfileRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Email string `json:"email" binding:"required,email"`
|
|
CurrentPassword string `json:"current_password"`
|
|
}
|
|
|
|
// UpdateProfile updates the authenticated user's profile.
|
|
func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
|
userID, exists := c.Get("userID")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
|
|
var req UpdateProfileRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get current user
|
|
var user models.User
|
|
if err := h.DB.First(&user, userID).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
// Check if email is already taken by another user
|
|
req.Email = strings.ToLower(req.Email)
|
|
var count int64
|
|
if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, userID).Count(&count).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check email availability"})
|
|
return
|
|
}
|
|
|
|
if count > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
|
|
return
|
|
}
|
|
|
|
// If email is changing, verify password
|
|
if req.Email != user.Email {
|
|
if req.CurrentPassword == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is required to change email"})
|
|
return
|
|
}
|
|
if !user.CheckPassword(req.CurrentPassword) {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Updates(map[string]any{
|
|
"name": req.Name,
|
|
"email": req.Email,
|
|
}).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Profile updated successfully"})
|
|
}
|
|
|
|
// ListUsers returns all users (admin only).
|
|
func (h *UserHandler) ListUsers(c *gin.Context) {
|
|
role, _ := c.Get("role")
|
|
if role != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
|
return
|
|
}
|
|
|
|
var users []models.User
|
|
if err := h.DB.Preload("PermittedHosts").Find(&users).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch users"})
|
|
return
|
|
}
|
|
|
|
// Return users with safe fields only
|
|
result := make([]gin.H, len(users))
|
|
for i, u := range users {
|
|
result[i] = gin.H{
|
|
"id": u.ID,
|
|
"uuid": u.UUID,
|
|
"email": u.Email,
|
|
"name": u.Name,
|
|
"role": u.Role,
|
|
"enabled": u.Enabled,
|
|
"last_login": u.LastLogin,
|
|
"invite_status": u.InviteStatus,
|
|
"invited_at": u.InvitedAt,
|
|
"permission_mode": u.PermissionMode,
|
|
"created_at": u.CreatedAt,
|
|
"updated_at": u.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// CreateUserRequest represents the request body for creating a user.
|
|
type CreateUserRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Name string `json:"name" binding:"required"`
|
|
Password string `json:"password" binding:"required,min=8"`
|
|
Role string `json:"role"`
|
|
PermissionMode string `json:"permission_mode"`
|
|
PermittedHosts []uint `json:"permitted_hosts"`
|
|
}
|
|
|
|
// CreateUser creates a new user with a password (admin only).
|
|
func (h *UserHandler) CreateUser(c *gin.Context) {
|
|
role, _ := c.Get("role")
|
|
if role != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
|
return
|
|
}
|
|
|
|
var req CreateUserRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Default role to "user"
|
|
if req.Role == "" {
|
|
req.Role = "user"
|
|
}
|
|
|
|
// Default permission mode to "allow_all"
|
|
if req.PermissionMode == "" {
|
|
req.PermissionMode = "allow_all"
|
|
}
|
|
|
|
// Check if email already exists
|
|
var count int64
|
|
if err := h.DB.Model(&models.User{}).Where("email = ?", strings.ToLower(req.Email)).Count(&count).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check email"})
|
|
return
|
|
}
|
|
if count > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
|
|
return
|
|
}
|
|
|
|
user := models.User{
|
|
UUID: uuid.New().String(),
|
|
Email: strings.ToLower(req.Email),
|
|
Name: req.Name,
|
|
Role: req.Role,
|
|
Enabled: true,
|
|
APIKey: uuid.New().String(),
|
|
PermissionMode: models.PermissionMode(req.PermissionMode),
|
|
}
|
|
|
|
if err := user.SetPassword(req.Password); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
|
return
|
|
}
|
|
|
|
err := h.DB.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(&user).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Add permitted hosts if specified
|
|
if len(req.PermittedHosts) > 0 {
|
|
var hosts []models.ProxyHost
|
|
if err := tx.Where("id IN ?", req.PermittedHosts).Find(&hosts).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&user).Association("PermittedHosts").Replace(hosts); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
h.logUserAudit(c, "user_create", &user, map[string]any{
|
|
"target_email": user.Email,
|
|
"target_role": user.Role,
|
|
})
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"id": user.ID,
|
|
"uuid": user.UUID,
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
"role": user.Role,
|
|
})
|
|
}
|
|
|
|
// InviteUserRequest represents the request body for inviting a user.
|
|
type InviteUserRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Role string `json:"role"`
|
|
PermissionMode string `json:"permission_mode"`
|
|
PermittedHosts []uint `json:"permitted_hosts"`
|
|
}
|
|
|
|
// generateSecureToken creates a cryptographically secure random token.
|
|
func generateSecureToken(length int) (string, error) {
|
|
bytes := make([]byte, length)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(bytes), nil
|
|
}
|
|
|
|
// InviteUser creates a new user with an invite token and sends an email (admin only).
|
|
func (h *UserHandler) InviteUser(c *gin.Context) {
|
|
role, _ := c.Get("role")
|
|
if role != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
|
return
|
|
}
|
|
|
|
inviterID, _ := c.Get("userID")
|
|
|
|
var req InviteUserRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Default role to "user"
|
|
if req.Role == "" {
|
|
req.Role = "user"
|
|
}
|
|
|
|
// Default permission mode to "allow_all"
|
|
if req.PermissionMode == "" {
|
|
req.PermissionMode = "allow_all"
|
|
}
|
|
|
|
// Check if email already exists
|
|
var existingUser models.User
|
|
if err := h.DB.Where("email = ?", strings.ToLower(req.Email)).First(&existingUser).Error; err == nil {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
|
|
return
|
|
}
|
|
|
|
// Generate invite token
|
|
inviteToken, err := generateSecureToken(32)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate invite token"})
|
|
return
|
|
}
|
|
|
|
// Set invite expiration (48 hours)
|
|
inviteExpires := time.Now().Add(48 * time.Hour)
|
|
invitedAt := time.Now()
|
|
inviterIDUint := inviterID.(uint)
|
|
|
|
user := models.User{
|
|
UUID: uuid.New().String(),
|
|
Email: strings.ToLower(req.Email),
|
|
Role: req.Role,
|
|
Enabled: false, // Disabled until invite is accepted
|
|
APIKey: uuid.New().String(),
|
|
PermissionMode: models.PermissionMode(req.PermissionMode),
|
|
InviteToken: inviteToken,
|
|
InviteExpires: &inviteExpires,
|
|
InvitedAt: &invitedAt,
|
|
InvitedBy: &inviterIDUint,
|
|
InviteStatus: "pending",
|
|
}
|
|
|
|
err = h.DB.Transaction(func(tx *gorm.DB) error {
|
|
if txErr := tx.Create(&user).Error; txErr != nil {
|
|
return txErr
|
|
}
|
|
|
|
// Explicitly disable user (bypass GORM's default:true)
|
|
if txErr := tx.Model(&user).Update("enabled", false).Error; txErr != nil {
|
|
return txErr
|
|
}
|
|
|
|
// Add permitted hosts if specified
|
|
if len(req.PermittedHosts) > 0 {
|
|
var hosts []models.ProxyHost
|
|
if findErr := tx.Where("id IN ?", req.PermittedHosts).Find(&hosts).Error; findErr != nil {
|
|
return findErr
|
|
}
|
|
if assocErr := tx.Model(&user).Association("PermittedHosts").Replace(hosts); assocErr != nil {
|
|
return assocErr
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
h.logUserAudit(c, "user_invite", &user, map[string]any{
|
|
"target_email": user.Email,
|
|
"target_role": user.Role,
|
|
"invite_status": user.InviteStatus,
|
|
})
|
|
|
|
// Send invite email asynchronously (non-blocking)
|
|
// Capture the generated invite URL from configured public URL only.
|
|
inviteURL := ""
|
|
baseURL, hasConfiguredPublicURL := utils.GetConfiguredPublicURL(h.DB)
|
|
if hasConfiguredPublicURL {
|
|
inviteURL = fmt.Sprintf("%s/accept-invite?token=%s", strings.TrimSuffix(baseURL, "/"), inviteToken)
|
|
}
|
|
|
|
// Only mark as sent when SMTP is configured AND invite URL is usable.
|
|
emailSent := false
|
|
if h.MailService.IsConfigured() && hasConfiguredPublicURL {
|
|
emailSent = true
|
|
userEmail := user.Email
|
|
userToken := inviteToken
|
|
appName := getAppName(h.DB)
|
|
|
|
go func() {
|
|
if err := h.MailService.SendInvite(userEmail, userToken, appName, baseURL); err != nil {
|
|
// Log failure but don't block response
|
|
middleware.GetRequestLogger(c).WithField("user_email", sanitizeForLog(userEmail)).WithField("error", sanitizeForLog(err.Error())).Error("Failed to send invite email")
|
|
}
|
|
}()
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"id": user.ID,
|
|
"uuid": user.UUID,
|
|
"email": user.Email,
|
|
"role": user.Role,
|
|
"invite_token_masked": maskSecretForResponse(inviteToken),
|
|
"invite_url": redactInviteURL(inviteURL),
|
|
"email_sent": emailSent,
|
|
"expires_at": inviteExpires,
|
|
})
|
|
}
|
|
|
|
// PreviewInviteURLRequest represents the request for previewing an invite URL.
|
|
type PreviewInviteURLRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
}
|
|
|
|
// PreviewInviteURL returns what the invite URL would look like with current settings.
|
|
func (h *UserHandler) PreviewInviteURL(c *gin.Context) {
|
|
role, _ := c.Get("role")
|
|
if role != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
|
return
|
|
}
|
|
|
|
var req PreviewInviteURLRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
baseURL, isConfigured := utils.GetConfiguredPublicURL(h.DB)
|
|
// Generate a sample token for preview (not stored)
|
|
sampleToken := "SAMPLE_TOKEN_PREVIEW"
|
|
inviteURL := ""
|
|
if isConfigured {
|
|
inviteURL = fmt.Sprintf("%s/accept-invite?token=%s", strings.TrimSuffix(baseURL, "/"), sampleToken)
|
|
}
|
|
|
|
warningMessage := ""
|
|
if !isConfigured {
|
|
warningMessage = "Application URL not configured. The invite link may not be accessible from external networks."
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"preview_url": inviteURL,
|
|
"base_url": baseURL,
|
|
"is_configured": isConfigured,
|
|
"email": req.Email,
|
|
"warning": !isConfigured,
|
|
"warning_message": warningMessage,
|
|
})
|
|
}
|
|
|
|
// getAppName retrieves the application name from settings or returns a default.
|
|
func getAppName(db *gorm.DB) string {
|
|
var setting models.Setting
|
|
if err := db.Where("key = ?", "app_name").First(&setting).Error; err == nil && setting.Value != "" {
|
|
return setting.Value
|
|
}
|
|
return "Charon"
|
|
}
|
|
|
|
// GetUser returns a single user by ID (admin only).
|
|
func (h *UserHandler) GetUser(c *gin.Context) {
|
|
role, _ := c.Get("role")
|
|
if role != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
|
return
|
|
}
|
|
|
|
idParam := c.Param("id")
|
|
id, err := strconv.ParseUint(idParam, 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if err := h.DB.Preload("PermittedHosts").First(&user, id).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
// Build permitted host IDs list
|
|
permittedHostIDs := make([]uint, len(user.PermittedHosts))
|
|
for i, host := range user.PermittedHosts {
|
|
permittedHostIDs[i] = host.ID
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": user.ID,
|
|
"uuid": user.UUID,
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
"role": user.Role,
|
|
"enabled": user.Enabled,
|
|
"last_login": user.LastLogin,
|
|
"invite_status": user.InviteStatus,
|
|
"invited_at": user.InvitedAt,
|
|
"permission_mode": user.PermissionMode,
|
|
"permitted_hosts": permittedHostIDs,
|
|
"created_at": user.CreatedAt,
|
|
"updated_at": user.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
// UpdateUserRequest represents the request body for updating a user.
|
|
type UpdateUserRequest struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Password *string `json:"password" binding:"omitempty,min=8"`
|
|
Role string `json:"role"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
// UpdateUser updates an existing user (admin only).
|
|
func (h *UserHandler) UpdateUser(c *gin.Context) {
|
|
role, _ := c.Get("role")
|
|
if role != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
|
return
|
|
}
|
|
|
|
idParam := c.Param("id")
|
|
id, err := strconv.ParseUint(idParam, 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if findErr := h.DB.First(&user, id).Error; findErr != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
var req UpdateUserRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
updates := make(map[string]any)
|
|
|
|
if req.Name != "" {
|
|
updates["name"] = req.Name
|
|
}
|
|
|
|
if req.Email != "" {
|
|
email := strings.ToLower(req.Email)
|
|
// Check if email is taken by another user
|
|
var count int64
|
|
if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; err == nil && count > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
|
|
return
|
|
}
|
|
updates["email"] = email
|
|
}
|
|
|
|
if req.Role != "" {
|
|
updates["role"] = req.Role
|
|
}
|
|
|
|
if req.Password != nil {
|
|
if err := user.SetPassword(*req.Password); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
|
return
|
|
}
|
|
updates["password_hash"] = user.PasswordHash
|
|
updates["failed_login_attempts"] = 0
|
|
updates["locked_until"] = nil
|
|
}
|
|
|
|
if req.Enabled != nil {
|
|
updates["enabled"] = *req.Enabled
|
|
}
|
|
|
|
if len(updates) > 0 {
|
|
if err := h.DB.Model(&user).Updates(updates).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
|
return
|
|
}
|
|
|
|
h.logUserAudit(c, "user_update", &user, map[string]any{
|
|
"target_email": user.Email,
|
|
"target_role": user.Role,
|
|
"fields": mapsKeys(updates),
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "User updated successfully"})
|
|
}
|
|
|
|
func mapsKeys(values map[string]any) []string {
|
|
keys := make([]string, 0, len(values))
|
|
for key := range values {
|
|
keys = append(keys, key)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// DeleteUser deletes a user (admin only).
|
|
func (h *UserHandler) DeleteUser(c *gin.Context) {
|
|
role, _ := c.Get("role")
|
|
if role != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
|
return
|
|
}
|
|
|
|
currentUserID, _ := c.Get("userID")
|
|
|
|
idParam := c.Param("id")
|
|
id, err := strconv.ParseUint(idParam, 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
|
return
|
|
}
|
|
|
|
// Prevent self-deletion
|
|
if uint(id) == currentUserID.(uint) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot delete your own account"})
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if findErr := h.DB.First(&user, id).Error; findErr != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
// Clear associations first
|
|
if err := h.DB.Model(&user).Association("PermittedHosts").Clear(); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear user associations"})
|
|
return
|
|
}
|
|
|
|
if err := h.DB.Delete(&user).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"})
|
|
return
|
|
}
|
|
|
|
h.logUserAudit(c, "user_delete", &user, map[string]any{
|
|
"target_email": user.Email,
|
|
"target_role": user.Role,
|
|
})
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"})
|
|
}
|
|
|
|
// UpdateUserPermissionsRequest represents the request body for updating user permissions.
|
|
type UpdateUserPermissionsRequest struct {
|
|
PermissionMode string `json:"permission_mode" binding:"required,oneof=allow_all deny_all"`
|
|
PermittedHosts []uint `json:"permitted_hosts"`
|
|
}
|
|
|
|
// ResendInvite regenerates and resends an invitation to a pending user (admin only).
|
|
func (h *UserHandler) ResendInvite(c *gin.Context) {
|
|
role, _ := c.Get("role")
|
|
if role != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
|
return
|
|
}
|
|
|
|
idParam := c.Param("id")
|
|
id, err := strconv.ParseUint(idParam, 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if findErr := h.DB.First(&user, id).Error; findErr != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
// Verify user has a pending invite
|
|
if user.InviteStatus != "pending" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "User does not have a pending invite"})
|
|
return
|
|
}
|
|
|
|
// Generate new invite token
|
|
inviteToken, err := generateSecureToken(32)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate invite token"})
|
|
return
|
|
}
|
|
|
|
// Set new invite expiration (48 hours)
|
|
inviteExpires := time.Now().Add(48 * time.Hour)
|
|
|
|
// Update user with new token
|
|
if err := h.DB.Model(&user).Updates(map[string]any{
|
|
"invite_token": inviteToken,
|
|
"invite_expires": inviteExpires,
|
|
}).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update invite token"})
|
|
return
|
|
}
|
|
|
|
// Try to send invite email
|
|
emailSent := false
|
|
if h.MailService.IsConfigured() {
|
|
baseURL, ok := utils.GetConfiguredPublicURL(h.DB)
|
|
if ok {
|
|
appName := getAppName(h.DB)
|
|
if err := h.MailService.SendInvite(user.Email, inviteToken, appName, baseURL); err == nil {
|
|
emailSent = true
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": user.ID,
|
|
"uuid": user.UUID,
|
|
"email": user.Email,
|
|
"role": user.Role,
|
|
"invite_token_masked": maskSecretForResponse(inviteToken),
|
|
"email_sent": emailSent,
|
|
"expires_at": inviteExpires,
|
|
})
|
|
}
|
|
|
|
func maskSecretForResponse(value string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return ""
|
|
}
|
|
|
|
return "********"
|
|
}
|
|
|
|
func redactInviteURL(inviteURL string) string {
|
|
if strings.TrimSpace(inviteURL) == "" {
|
|
return ""
|
|
}
|
|
|
|
return "[REDACTED]"
|
|
}
|
|
|
|
// UpdateUserPermissions updates a user's permission mode and host exceptions (admin only).
|
|
func (h *UserHandler) UpdateUserPermissions(c *gin.Context) {
|
|
role, _ := c.Get("role")
|
|
if role != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
|
return
|
|
}
|
|
|
|
idParam := c.Param("id")
|
|
id, err := strconv.ParseUint(idParam, 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if findErr := h.DB.First(&user, id).Error; findErr != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
var req UpdateUserPermissionsRequest
|
|
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
|
|
return
|
|
}
|
|
|
|
err = h.DB.Transaction(func(tx *gorm.DB) error {
|
|
// Update permission mode
|
|
if txErr := tx.Model(&user).Update("permission_mode", req.PermissionMode).Error; txErr != nil {
|
|
return txErr
|
|
}
|
|
|
|
// Update permitted hosts
|
|
var hosts []models.ProxyHost
|
|
if len(req.PermittedHosts) > 0 {
|
|
if findErr := tx.Where("id IN ?", req.PermittedHosts).Find(&hosts).Error; findErr != nil {
|
|
return findErr
|
|
}
|
|
}
|
|
|
|
if assocErr := tx.Model(&user).Association("PermittedHosts").Replace(hosts); assocErr != nil {
|
|
return assocErr
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update permissions: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Permissions updated successfully"})
|
|
}
|
|
|
|
// ValidateInvite validates an invite token (public endpoint).
|
|
func (h *UserHandler) ValidateInvite(c *gin.Context) {
|
|
token := c.Query("token")
|
|
if token == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Token required"})
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if err := h.DB.Where("invite_token = ?", token).First(&user).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid or expired invite token"})
|
|
return
|
|
}
|
|
|
|
// Check if token is expired
|
|
if user.InviteExpires != nil && user.InviteExpires.Before(time.Now()) {
|
|
c.JSON(http.StatusGone, gin.H{"error": "Invite token has expired"})
|
|
return
|
|
}
|
|
|
|
// Check if already accepted
|
|
if user.InviteStatus != "pending" {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Invite has already been accepted"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"valid": true,
|
|
"email": user.Email,
|
|
})
|
|
}
|
|
|
|
// AcceptInviteRequest represents the request body for accepting an invite.
|
|
type AcceptInviteRequest struct {
|
|
Token string `json:"token" binding:"required"`
|
|
Name string `json:"name" binding:"required"`
|
|
Password string `json:"password" binding:"required,min=8"`
|
|
}
|
|
|
|
// AcceptInvite accepts an invitation and sets the user's password (public endpoint).
|
|
func (h *UserHandler) AcceptInvite(c *gin.Context) {
|
|
var req AcceptInviteRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if err := h.DB.Where("invite_token = ?", req.Token).First(&user).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid or expired invite token"})
|
|
return
|
|
}
|
|
|
|
// Check if token is expired
|
|
if user.InviteExpires != nil && user.InviteExpires.Before(time.Now()) {
|
|
// Mark as expired
|
|
h.DB.Model(&user).Update("invite_status", "expired")
|
|
c.JSON(http.StatusGone, gin.H{"error": "Invite token has expired"})
|
|
return
|
|
}
|
|
|
|
// Check if already accepted
|
|
if user.InviteStatus != "pending" {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Invite has already been accepted"})
|
|
return
|
|
}
|
|
|
|
// Set password and activate user
|
|
if err := user.SetPassword(req.Password); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set password"})
|
|
return
|
|
}
|
|
|
|
if err := h.DB.Model(&user).Updates(map[string]any{
|
|
"name": req.Name,
|
|
"password_hash": user.PasswordHash,
|
|
"enabled": true,
|
|
"invite_token": "", // Clear token
|
|
"invite_expires": nil, // Clear expiration
|
|
"invite_status": "accepted",
|
|
}).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to accept invite"})
|
|
return
|
|
}
|
|
|
|
h.logUserAudit(c, "user_invite_accept", &user, map[string]any{
|
|
"target_email": user.Email,
|
|
"invite_status": "accepted",
|
|
})
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Invite accepted successfully",
|
|
"email": user.Email,
|
|
})
|
|
}
|