- Implement tests for ImportSuccessModal to verify rendering and functionality. - Update AuthContext to store authentication token in localStorage and manage token state. - Modify useImport hook to capture and expose commit results, preventing unnecessary refetches. - Enhance useCertificates hook to support optional refetch intervals. - Update Dashboard to conditionally poll certificates based on pending status. - Integrate ImportSuccessModal into ImportCaddy for user feedback on import completion. - Adjust Login component to utilize returned token for authentication. - Refactor CrowdSecConfig tests for improved readability and reliability. - Add debug_db.py script for inspecting the SQLite database. - Update integration and test scripts for better configuration and error handling. - Introduce Trivy scan script for vulnerability assessment of Docker images.
398 lines
11 KiB
Go
398 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
"github.com/Wikid82/charon/backend/internal/services"
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type AuthHandler struct {
|
|
authService *services.AuthService
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
|
|
return &AuthHandler{authService: authService}
|
|
}
|
|
|
|
// NewAuthHandlerWithDB creates an AuthHandler with database access for forward auth.
|
|
func NewAuthHandlerWithDB(authService *services.AuthService, db *gorm.DB) *AuthHandler {
|
|
return &AuthHandler{authService: authService, db: db}
|
|
}
|
|
|
|
// isProduction checks if we're running in production mode
|
|
func isProduction() bool {
|
|
env := os.Getenv("CHARON_ENV")
|
|
return env == "production" || env == "prod"
|
|
}
|
|
|
|
func requestScheme(c *gin.Context) string {
|
|
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
|
|
// Honor first entry in a comma-separated header
|
|
parts := strings.Split(proto, ",")
|
|
return strings.ToLower(strings.TrimSpace(parts[0]))
|
|
}
|
|
if c.Request != nil && c.Request.TLS != nil {
|
|
return "https"
|
|
}
|
|
if c.Request != nil && c.Request.URL != nil && c.Request.URL.Scheme != "" {
|
|
return strings.ToLower(c.Request.URL.Scheme)
|
|
}
|
|
return "http"
|
|
}
|
|
|
|
// setSecureCookie sets an auth cookie with security best practices
|
|
// - HttpOnly: prevents JavaScript access (XSS protection)
|
|
// - Secure: derived from request scheme to allow HTTP/IP logins when needed
|
|
// - SameSite: Strict for HTTPS, Lax for HTTP/IP to allow forward-auth redirects
|
|
func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
|
|
scheme := requestScheme(c)
|
|
secure := isProduction() && scheme == "https"
|
|
sameSite := http.SameSiteStrictMode
|
|
if scheme != "https" {
|
|
sameSite = http.SameSiteLaxMode
|
|
}
|
|
|
|
// Use the host without port for domain
|
|
domain := ""
|
|
|
|
c.SetSameSite(sameSite)
|
|
c.SetCookie(
|
|
name, // name
|
|
value, // value
|
|
maxAge, // maxAge in seconds
|
|
"/", // path
|
|
domain, // domain (empty = current host)
|
|
secure, // secure (HTTPS only in production)
|
|
true, // httpOnly (no JS access)
|
|
)
|
|
}
|
|
|
|
// clearSecureCookie removes a cookie with the same security settings
|
|
func clearSecureCookie(c *gin.Context, name string) {
|
|
setSecureCookie(c, name, "", -1)
|
|
}
|
|
|
|
type LoginRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required"`
|
|
}
|
|
|
|
func (h *AuthHandler) Login(c *gin.Context) {
|
|
var req LoginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
token, err := h.authService.Login(req.Email, req.Password)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Set secure cookie (scheme-aware) and return token for header fallback
|
|
setSecureCookie(c, "auth_token", token, 3600*24)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"token": token})
|
|
}
|
|
|
|
type RegisterRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required,min=8"`
|
|
Name string `json:"name" binding:"required"`
|
|
}
|
|
|
|
func (h *AuthHandler) Register(c *gin.Context) {
|
|
var req RegisterRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
user, err := h.authService.Register(req.Email, req.Password, req.Name)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, user)
|
|
}
|
|
|
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
|
clearSecureCookie(c, "auth_token")
|
|
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
|
|
}
|
|
|
|
func (h *AuthHandler) Me(c *gin.Context) {
|
|
userID, _ := c.Get("userID")
|
|
role, _ := c.Get("role")
|
|
|
|
u, err := h.authService.GetUserByID(userID.(uint))
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"user_id": userID,
|
|
"role": role,
|
|
"name": u.Name,
|
|
"email": u.Email,
|
|
})
|
|
}
|
|
|
|
type ChangePasswordRequest struct {
|
|
OldPassword string `json:"old_password" binding:"required"`
|
|
NewPassword string `json:"new_password" binding:"required,min=8"`
|
|
}
|
|
|
|
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
|
var req ChangePasswordRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
userID, exists := c.Get("userID")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
|
|
if err := h.authService.ChangePassword(userID.(uint), req.OldPassword, req.NewPassword); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
|
|
}
|
|
|
|
// Verify is the forward auth endpoint for Caddy.
|
|
// It validates the user's session and checks access permissions for the requested host.
|
|
// Used by Caddy's forward_auth directive.
|
|
//
|
|
// Expected headers from Caddy:
|
|
// - X-Forwarded-Host: The original host being accessed
|
|
// - X-Forwarded-Uri: The original URI being accessed
|
|
//
|
|
// Response headers on success (200):
|
|
// - X-Forwarded-User: The user's email
|
|
// - X-Forwarded-Groups: The user's role (for future RBAC)
|
|
//
|
|
// Response on failure:
|
|
// - 401: Not authenticated (redirect to login)
|
|
// - 403: Authenticated but not authorized for this host
|
|
func (h *AuthHandler) Verify(c *gin.Context) {
|
|
// Extract token from cookie or Authorization header
|
|
var tokenString string
|
|
|
|
// Try cookie first (most common for browser requests)
|
|
if cookie, err := c.Cookie("auth_token"); err == nil && cookie != "" {
|
|
tokenString = cookie
|
|
}
|
|
|
|
// Fall back to Authorization header
|
|
if tokenString == "" {
|
|
authHeader := c.GetHeader("Authorization")
|
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
|
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
|
}
|
|
}
|
|
|
|
// No token found - not authenticated
|
|
if tokenString == "" {
|
|
c.Header("X-Auth-Redirect", "/login")
|
|
c.AbortWithStatus(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Validate token
|
|
claims, err := h.authService.ValidateToken(tokenString)
|
|
if err != nil {
|
|
c.Header("X-Auth-Redirect", "/login")
|
|
c.AbortWithStatus(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Get user details
|
|
user, err := h.authService.GetUserByID(claims.UserID)
|
|
if err != nil || !user.Enabled {
|
|
c.Header("X-Auth-Redirect", "/login")
|
|
c.AbortWithStatus(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Get the forwarded host from Caddy
|
|
forwardedHost := c.GetHeader("X-Forwarded-Host")
|
|
if forwardedHost == "" {
|
|
forwardedHost = c.GetHeader("X-Original-Host")
|
|
}
|
|
|
|
// If we have a database reference and a forwarded host, check permissions
|
|
if h.db != nil && forwardedHost != "" {
|
|
// Find the proxy host for this domain
|
|
var proxyHost models.ProxyHost
|
|
err := h.db.Where("domain_names LIKE ?", "%"+forwardedHost+"%").First(&proxyHost).Error
|
|
|
|
if err == nil && proxyHost.ForwardAuthEnabled {
|
|
// Load user's permitted hosts for permission check
|
|
var userWithHosts models.User
|
|
if err := h.db.Preload("PermittedHosts").First(&userWithHosts, user.ID).Error; err == nil {
|
|
// Check if user can access this host
|
|
if !userWithHosts.CanAccessHost(proxyHost.ID) {
|
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
|
"error": "Access denied to this application",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set headers for downstream services
|
|
c.Header("X-Forwarded-User", user.Email)
|
|
c.Header("X-Forwarded-Groups", user.Role)
|
|
c.Header("X-Forwarded-Name", user.Name)
|
|
|
|
// Return 200 OK - access granted
|
|
c.Status(http.StatusOK)
|
|
}
|
|
|
|
// VerifyStatus returns the current auth status without triggering a redirect.
|
|
// Useful for frontend to check if user is logged in.
|
|
func (h *AuthHandler) VerifyStatus(c *gin.Context) {
|
|
// Extract token
|
|
var tokenString string
|
|
|
|
if cookie, err := c.Cookie("auth_token"); err == nil && cookie != "" {
|
|
tokenString = cookie
|
|
}
|
|
|
|
if tokenString == "" {
|
|
authHeader := c.GetHeader("Authorization")
|
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
|
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
|
}
|
|
}
|
|
|
|
if tokenString == "" {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"authenticated": false,
|
|
})
|
|
return
|
|
}
|
|
|
|
claims, err := h.authService.ValidateToken(tokenString)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"authenticated": false,
|
|
})
|
|
return
|
|
}
|
|
|
|
user, err := h.authService.GetUserByID(claims.UserID)
|
|
if err != nil || !user.Enabled {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"authenticated": false,
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"authenticated": true,
|
|
"user": gin.H{
|
|
"id": user.ID,
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
"role": user.Role,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetAccessibleHosts returns the list of proxy hosts the authenticated user can access.
|
|
func (h *AuthHandler) GetAccessibleHosts(c *gin.Context) {
|
|
userID, exists := c.Get("userID")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
|
|
if h.db == nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database not available"})
|
|
return
|
|
}
|
|
|
|
// Load user with permitted hosts
|
|
var user models.User
|
|
if err := h.db.Preload("PermittedHosts").First(&user, userID).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
// Get all enabled proxy hosts
|
|
var allHosts []models.ProxyHost
|
|
if err := h.db.Where("enabled = ?", true).Find(&allHosts).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch hosts"})
|
|
return
|
|
}
|
|
|
|
// Filter to accessible hosts
|
|
accessibleHosts := make([]gin.H, 0)
|
|
for _, host := range allHosts {
|
|
if user.CanAccessHost(host.ID) {
|
|
accessibleHosts = append(accessibleHosts, gin.H{
|
|
"id": host.ID,
|
|
"name": host.Name,
|
|
"domain_names": host.DomainNames,
|
|
})
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"hosts": accessibleHosts,
|
|
"permission_mode": user.PermissionMode,
|
|
})
|
|
}
|
|
|
|
// CheckHostAccess checks if the current user can access a specific host.
|
|
func (h *AuthHandler) CheckHostAccess(c *gin.Context) {
|
|
userID, exists := c.Get("userID")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
|
|
hostIDStr := c.Param("hostId")
|
|
hostID, err := strconv.ParseUint(hostIDStr, 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid host ID"})
|
|
return
|
|
}
|
|
|
|
if h.db == nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database not available"})
|
|
return
|
|
}
|
|
|
|
// Load user with permitted hosts
|
|
var user models.User
|
|
if err := h.db.Preload("PermittedHosts").First(&user, userID).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
|
|
canAccess := user.CanAccessHost(uint(hostID))
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"host_id": hostID,
|
|
"can_access": canAccess,
|
|
})
|
|
}
|