first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:37:58 +03:00
commit 8b1fbdee99
104 changed files with 23398 additions and 0 deletions

View File

@@ -0,0 +1,267 @@
package handlers
import (
"fmt"
"net/http"
"gauth-central/internal/models"
"gauth-central/internal/services"
"gauth-central/pkg/utils"
"github.com/gin-gonic/gin"
"github.com/markbates/goth/gothic"
)
type AuthHandler struct {
authService *services.AuthService
}
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
type RegisterRequest struct {
UserName string `json:"username" binding:"required,min=3"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
// Register godoc
// @Summary Register a new user
// @Description Register with username, email and password
// @Tags auth
// @Accept json
// @Produce json
// @Param request body RegisterRequest true "Register Request"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Router /auth/register [post]
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
}
// Register creates user with email_verified=false; tokens only after email verification
user, _, _, verifyToken, err := h.authService.Register(req.UserName, req.Email, req.Password)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Send verification email asynchronously
go func() {
if err := utils.SendVerificationEmail(user.Email, verifyToken); err != nil {
fmt.Printf("Failed to send verification email to %s: %v\n", user.Email, err)
} else {
fmt.Printf("Verification email sent to %s\n", user.Email)
}
}()
roles := user.Roles
if roles == nil {
roles = []models.Role{}
}
c.JSON(http.StatusCreated, gin.H{
"message": "User created. Please verify your email.",
"user_id": user.ID,
"username": user.UserName,
"email": user.Email,
"avatar": user.Avatar,
"roles": roles,
"email_verified": false,
"verification_token": verifyToken, // Returned for dev convenience, usually hidden in prod
})
}
// Login godoc
// @Summary Login user
// @Description Login with email and password to get JWT token
// @Tags auth
// @Accept json
// @Produce json
// @Param request body LoginRequest true "Login Request"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /auth/login [post]
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
}
user, accessToken, refreshToken, err := h.authService.Login(req.Email, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
// Ensure roles is always returned, even if empty
roles := user.Roles
if roles == nil {
roles = []models.Role{}
}
c.JSON(http.StatusOK, gin.H{
"user_id": user.ID,
"username": user.UserName,
"email": user.Email,
"avatar": user.Avatar,
"roles": roles,
"access_token": accessToken,
"refresh_token": refreshToken,
})
}
// BeginAuth godoc
// @Summary Start OAuth2 flow
// @Description Redirect to OAuth2 provider
// @Tags oauth
// @Param provider path string true "Provider (google, github)"
// @Router /auth/{provider} [get]
func (h *AuthHandler) BeginAuth(c *gin.Context) {
// Try to complete user auth if we've already got a session
// but context is not set correctly for gin with gothic usually
provider := c.Param("provider")
q := c.Request.URL.Query()
q.Add("provider", provider)
c.Request.URL.RawQuery = q.Encode()
gothic.BeginAuthHandler(c.Writer, c.Request)
}
// Callback godoc
// @Summary OAuth2 Callback
// @Description Handle callback from OAuth2 provider
// @Tags oauth
// @Param provider path string true "Provider (google, github)"
// @Success 200 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /auth/{provider}/callback [get]
func (h *AuthHandler) Callback(c *gin.Context) {
provider := c.Param("provider")
q := c.Request.URL.Query()
q.Add("provider", provider)
c.Request.URL.RawQuery = q.Encode()
gothUser, err := gothic.CompleteUserAuth(c.Writer, c.Request)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
user, accessToken, refreshToken, err := h.authService.FindOrCreateSocialUser(gothUser, provider)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Ensure roles is always returned
roles := user.Roles
if roles == nil {
roles = []models.Role{}
}
c.JSON(http.StatusOK, gin.H{
"access_token": accessToken,
"refresh_token": refreshToken,
"user": gin.H{
"id": user.ID,
"username": user.UserName,
"email": user.Email,
"avatar": user.Avatar,
"email_verified": user.EmailVerified,
"roles": roles,
"social_accounts": user.SocialAccounts,
},
})
}
// Refresh godoc
// @Summary Refresh Access Token
// @Description usage: send refresh_token to get new access_token
// @Tags auth
// @Accept json
// @Produce json
// @Param request body RefreshRequest true "Refresh Request"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /auth/refresh [post]
func (h *AuthHandler) Refresh(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
accessToken, refreshToken, err := h.authService.RefreshToken(req.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"access_token": accessToken,
"refresh_token": refreshToken,
})
}
// VerifyEmail godoc
// @Summary Verify email address
// @Description Verify email with token sent after email/password registration
// @Tags auth
// @Param token query string true "Verification token"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /auth/verify-email [get]
func (h *AuthHandler) VerifyEmail(c *gin.Context) {
token := c.Query("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
return
}
if err := h.authService.VerifyEmail(token); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Email verified successfully"})
}
// Me godoc
// @Summary Get Current User Profile
// @Description Get details of the currently authenticated user
// @Tags auth
// @Security ApiKeyAuth
// @Produce json
// @Success 200 {object} models.User
// @Failure 401 {object} map[string]string
// @Router /auth/me [get]
func (h *AuthHandler) Me(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
user, err := h.authService.GetUserByID(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, user)
}

View File

@@ -0,0 +1,193 @@
package handlers
import (
"net/http"
"os"
"strings"
"gauth-central/internal/database"
"gauth-central/internal/models"
"gauth-central/pkg/utils"
"github.com/gin-gonic/gin"
)
type AvatarHandler struct{}
func NewAvatarHandler() *AvatarHandler {
return &AvatarHandler{}
}
// UploadAvatar godoc
// @Summary Upload user avatar
// @Tags User
// @Security ApiKeyAuth
// @Accept multipart/form-data
// @Produce json
// @Param avatar formData file true "Avatar image file"
// @Success 200 {object} map[string]interface{}
// @Router /user/avatar [post]
func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Parse multipart form
file, err := c.FormFile("avatar")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"})
return
}
// Validate file size (max 5MB)
if file.Size > 5*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "File size exceeds 5MB limit"})
return
}
// Get user to check for old avatar
var user models.User
if err := database.DB.Where("id = ?", userID).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Delete old avatar file if exists and is not from OAuth
if user.Avatar != "" && strings.HasPrefix(user.Avatar, "/uploads/") {
oldPath := "." + user.Avatar
os.Remove(oldPath) // Ignore error if file doesn't exist
}
// Use utils.SaveOptimizedImage
avatarURL, err := utils.SaveOptimizedImage(file, "./uploads/avatars", userID, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar: " + err.Error()})
return
}
// Update avatar URL
if err := database.DB.Model(&user).Update("avatar", avatarURL).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update avatar"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Avatar uploaded successfully",
"avatar_url": avatarURL,
"user": gin.H{
"id": user.ID,
"username": user.UserName,
"email": user.Email,
"avatar": avatarURL,
},
})
}
// DeleteAvatar godoc
// @Summary Delete user avatar
// @Tags User
// @Security ApiKeyAuth
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /user/avatar [delete]
func (h *AvatarHandler) DeleteAvatar(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
var user models.User
if err := database.DB.Where("id = ?", userID).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Delete avatar file if it's a local upload
if user.Avatar != "" && strings.HasPrefix(user.Avatar, "/uploads/") {
filepath := "." + user.Avatar
os.Remove(filepath) // Ignore error if file doesn't exist
}
// Set avatar to empty string
if err := database.DB.Model(&user).Update("avatar", "").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete avatar"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Avatar deleted successfully",
"user": gin.H{
"id": user.ID,
"username": user.UserName,
"email": user.Email,
"avatar": "",
},
})
}
// AdminUploadAvatar godoc
// @Summary Upload avatar for any user (Admin only)
// @Tags Admin - User Management
// @Security ApiKeyAuth
// @Accept multipart/form-data
// @Produce json
// @Param id path string true "User ID"
// @Param avatar formData file true "Avatar image file"
// @Success 200 {object} map[string]interface{}
// @Router /admin/users/{id}/avatar [post]
func (h *AvatarHandler) AdminUploadAvatar(c *gin.Context) {
userID := c.Param("id")
// Parse multipart form
file, err := c.FormFile("avatar")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"})
return
}
// Validate file size (max 5MB)
if file.Size > 5*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "File size exceeds 5MB limit"})
return
}
// Get user to check for old avatar
var user models.User
if err := database.DB.Where("id = ?", userID).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Delete old avatar file if exists and is not from OAuth
if user.Avatar != "" && strings.HasPrefix(user.Avatar, "/uploads/") {
oldPath := "." + user.Avatar
os.Remove(oldPath) // Ignore error if file doesn't exist
}
// Use utils.SaveOptimizedImage
avatarURL, err := utils.SaveOptimizedImage(file, "./uploads/avatars", userID, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar: " + err.Error()})
return
}
// Update avatar URL
if err := database.DB.Model(&user).Update("avatar", avatarURL).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update avatar"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Avatar uploaded successfully",
"avatar_url": avatarURL,
"user": gin.H{
"id": user.ID,
"username": user.UserName,
"email": user.Email,
"avatar": avatarURL,
},
})
}

View File

@@ -0,0 +1,326 @@
package handlers
import (
"fmt"
"net/http"
"os"
"strings"
"gauth-central/internal/database"
"gauth-central/internal/models"
"gauth-central/pkg/utils"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
type ProfileHandler struct{}
func NewProfileHandler() *ProfileHandler {
return &ProfileHandler{}
}
// isOAuthUser checks if user is an OAuth user (has social accounts)
func isOAuthUser(user *models.User) bool {
// OAuth user if they have social accounts OR if they don't have a password
return len(user.SocialAccounts) > 0 || user.Password == ""
}
// GetProfile godoc
// @Summary Get current user profile
// @Tags Profile
// @Security ApiKeyAuth
// @Produce json
// @Success 200 {object} models.User
// @Router /profile [get]
func (h *ProfileHandler) GetProfile(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
var user models.User
if err := database.DB.
Preload("Roles").
Preload("Roles.Permissions").
Preload("SocialAccounts").
Where("id = ?", userID).
First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Add is_oauth_user flag to response
type ProfileResponse struct {
models.User
IsOAuthUser bool `json:"is_oauth_user"`
}
response := ProfileResponse{
User: user,
IsOAuthUser: isOAuthUser(&user),
}
c.JSON(http.StatusOK, response)
}
// UpdateProfile godoc
// @Summary Update current user profile
// @Tags Profile
// @Security ApiKeyAuth
// @Accept multipart/form-data
// @Produce json
// @Param user_name formData string false "Username"
// @Param avatar formData file false "Avatar image"
// @Success 200 {object} map[string]interface{}
// @Router /profile [put]
func (h *ProfileHandler) UpdateProfile(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Try to parse as multipart form first
contentType := c.GetHeader("Content-Type")
isMultipart := strings.Contains(contentType, "multipart/form-data")
updates := make(map[string]interface{})
if isMultipart {
// Parse multipart form
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse form data"})
return
}
// Get form values
if userName := c.PostForm("user_name"); userName != "" {
updates["user_name"] = userName
}
} else {
// Parse as JSON
var input struct {
UserName *string `json:"user_name"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if input.UserName != nil {
updates["user_name"] = *input.UserName
}
}
// Update basic user fields
if len(updates) > 0 {
if err := database.DB.Model(&models.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
return
}
}
// Handle avatar upload if multipart and file provided
if isMultipart {
avatarFile, err := c.FormFile("avatar")
if err == nil && avatarFile != nil {
// Validate file size (max 5MB)
if avatarFile.Size > 5*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Avatar file size exceeds 5MB limit"})
return
}
// Get user to check for old avatar
var user models.User
if err := database.DB.Where("id = ?", userID).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Delete old avatar if exists and is local
if user.Avatar != "" && strings.HasPrefix(user.Avatar, "/uploads/") {
oldPath := "." + user.Avatar
os.Remove(oldPath)
}
// Use utils.SaveOptimizedImage
avatarURL, err := utils.SaveOptimizedImage(avatarFile, "./uploads/avatars", userID, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar: " + err.Error()})
return
}
// Update user avatar
if err := database.DB.Model(&user).Update("avatar", avatarURL).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update avatar in database"})
return
}
}
}
// Get updated user to return
var user models.User
if err := database.DB.
Preload("Roles").
Preload("SocialAccounts").
Where("id = ?", userID).
First(&user).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"message": "Profile updated successfully"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Profile updated successfully",
"user": user,
})
}
// ChangePassword godoc
// @Summary Change password
// @Tags Profile
// @Security ApiKeyAuth
// @Accept json
// @Produce json
// @Param request body object true "Password change request"
// @Success 200 {object} map[string]interface{}
// @Router /profile/password [put]
func (h *ProfileHandler) ChangePassword(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
var input struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user
var user models.User
if err := database.DB.Preload("SocialAccounts").Where("id = ?", userID).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Check if user is OAuth user
if isOAuthUser(&user) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot change password for OAuth users (Google/GitHub login)"})
return
}
// Verify current password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.CurrentPassword)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is incorrect"})
return
}
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
// Update password
if err := database.DB.Model(&user).Update("password", string(hashedPassword)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update password"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password changed successfully"})
}
// ChangeEmail godoc
// @Summary Change email address
// @Tags Profile
// @Security ApiKeyAuth
// @Accept json
// @Produce json
// @Param request body object true "Email change request"
// @Success 200 {object} map[string]interface{}
// @Router /profile/email [put]
func (h *ProfileHandler) ChangeEmail(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
var input struct {
NewEmail string `json:"new_email" binding:"required,email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user
var user models.User
if err := database.DB.Preload("SocialAccounts").Where("id = ?", userID).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Check if user is OAuth user
if isOAuthUser(&user) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot change email for OAuth users (Google/GitHub login)"})
return
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Password)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Password is incorrect"})
return
}
// Check if new email already exists
var existingUser models.User
if err := database.DB.Where("email = ? AND id != ?", input.NewEmail, userID).First(&existingUser).Error; err == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already in use"})
return
}
// Generate verification token
verifyToken, err := utils.GenerateSecureToken(32)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate verification token"})
return
}
// Update email and set as unverified
falseBool := false
updates := map[string]interface{}{
"email": input.NewEmail,
"email_verified": &falseBool,
"email_verify_token": verifyToken,
}
if err := database.DB.Model(&user).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update email"})
return
}
// Send verification email
go func() {
if err := utils.SendVerificationEmail(input.NewEmail, verifyToken); err != nil {
fmt.Printf("Failed to send verification email to %s: %v\n", input.NewEmail, err)
}
}()
c.JSON(http.StatusOK, gin.H{
"message": "Email updated. Please verify your new email address.",
"new_email": input.NewEmail,
"verification_token": verifyToken,
})
}

View File

@@ -0,0 +1,328 @@
package handlers
import (
"net/http"
"gauth-central/internal/models"
"gauth-central/internal/services"
"github.com/gin-gonic/gin"
)
type SettingsHandler struct {
settingsService *services.SettingsService
}
func NewSettingsHandler(settingsService *services.SettingsService) *SettingsHandler {
return &SettingsHandler{
settingsService: settingsService,
}
}
// ==================== CORS WHITELIST ====================
// GetAllWhitelist godoc
// @Summary Get all CORS whitelist entries
// @Tags Settings
// @Security ApiKeyAuth
// @Produce json
// @Success 200 {array} models.CorsWhitelist
// @Router /settings/cors/whitelist [get]
func (h *SettingsHandler) GetAllWhitelist(c *gin.Context) {
whitelists, err := h.settingsService.GetAllCorsWhitelist()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch whitelist"})
return
}
c.JSON(http.StatusOK, whitelists)
}
// CreateWhitelist godoc
// @Summary Create CORS whitelist entry
// @Tags Settings
// @Security ApiKeyAuth
// @Accept json
// @Produce json
// @Param whitelist body object true "Whitelist data"
// @Success 201 {object} models.CorsWhitelist
// @Router /settings/cors/whitelist [post]
func (h *SettingsHandler) CreateWhitelist(c *gin.Context) {
var input struct {
Origin string `json:"origin" binding:"required"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
email := c.GetString("email")
whitelist := &models.CorsWhitelist{
Origin: input.Origin,
Description: input.Description,
IsActive: true,
CreatedBy: email,
}
err := h.settingsService.CreateCorsWhitelist(whitelist)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create whitelist entry"})
return
}
c.JSON(http.StatusCreated, whitelist)
}
// UpdateWhitelist godoc
// @Summary Update CORS whitelist entry
// @Tags Settings
// @Security ApiKeyAuth
// @Accept json
// @Produce json
// @Param id path string true "Whitelist ID"
// @Param whitelist body object true "Update data"
// @Success 200 {object} map[string]interface{}
// @Router /settings/cors/whitelist/{id} [put]
func (h *SettingsHandler) UpdateWhitelist(c *gin.Context) {
id := c.Param("id")
var input struct {
Origin *string `json:"origin"`
Description *string `json:"description"`
IsActive *bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := make(map[string]interface{})
if input.Origin != nil {
updates["origin"] = *input.Origin
}
if input.Description != nil {
updates["description"] = *input.Description
}
if input.IsActive != nil {
updates["is_active"] = *input.IsActive
}
err := h.settingsService.UpdateCorsWhitelist(id, updates)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update whitelist entry"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Whitelist updated successfully"})
}
// DeleteWhitelist godoc
// @Summary Delete CORS whitelist entry
// @Tags Settings
// @Security ApiKeyAuth
// @Param id path string true "Whitelist ID"
// @Success 200 {object} map[string]interface{}
// @Router /settings/cors/whitelist/{id} [delete]
func (h *SettingsHandler) DeleteWhitelist(c *gin.Context) {
id := c.Param("id")
err := h.settingsService.DeleteCorsWhitelist(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete whitelist entry"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Whitelist entry deleted successfully"})
}
// ==================== CORS BLACKLIST ====================
// GetAllBlacklist godoc
// @Summary Get all CORS blacklist entries
// @Tags Settings
// @Security ApiKeyAuth
// @Produce json
// @Success 200 {array} models.CorsBlacklist
// @Router /settings/cors/blacklist [get]
func (h *SettingsHandler) GetAllBlacklist(c *gin.Context) {
blacklists, err := h.settingsService.GetAllCorsBlacklist()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch blacklist"})
return
}
c.JSON(http.StatusOK, blacklists)
}
// CreateBlacklist godoc
// @Summary Create CORS blacklist entry
// @Tags Settings
// @Security ApiKeyAuth
// @Accept json
// @Produce json
// @Param blacklist body object true "Blacklist data"
// @Success 201 {object} models.CorsBlacklist
// @Router /settings/cors/blacklist [post]
func (h *SettingsHandler) CreateBlacklist(c *gin.Context) {
var input struct {
Origin string `json:"origin" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
email := c.GetString("email")
blacklist := &models.CorsBlacklist{
Origin: input.Origin,
Reason: input.Reason,
IsActive: true,
CreatedBy: email,
}
err := h.settingsService.CreateCorsBlacklist(blacklist)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create blacklist entry"})
return
}
c.JSON(http.StatusCreated, blacklist)
}
// UpdateBlacklist godoc
// @Summary Update CORS blacklist entry
// @Tags Settings
// @Security ApiKeyAuth
// @Accept json
// @Produce json
// @Param id path string true "Blacklist ID"
// @Param blacklist body object true "Update data"
// @Success 200 {object} map[string]interface{}
// @Router /settings/cors/blacklist/{id} [put]
func (h *SettingsHandler) UpdateBlacklist(c *gin.Context) {
id := c.Param("id")
var input struct {
Origin *string `json:"origin"`
Reason *string `json:"reason"`
IsActive *bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := make(map[string]interface{})
if input.Origin != nil {
updates["origin"] = *input.Origin
}
if input.Reason != nil {
updates["reason"] = *input.Reason
}
if input.IsActive != nil {
updates["is_active"] = *input.IsActive
}
err := h.settingsService.UpdateCorsBlacklist(id, updates)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update blacklist entry"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Blacklist updated successfully"})
}
// DeleteBlacklist godoc
// @Summary Delete CORS blacklist entry
// @Tags Settings
// @Security ApiKeyAuth
// @Param id path string true "Blacklist ID"
// @Success 200 {object} map[string]interface{}
// @Router /settings/cors/blacklist/{id} [delete]
func (h *SettingsHandler) DeleteBlacklist(c *gin.Context) {
id := c.Param("id")
err := h.settingsService.DeleteCorsBlacklist(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete blacklist entry"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Blacklist entry deleted successfully"})
}
// ==================== RATE LIMIT SETTINGS ====================
// GetAllRateLimits godoc
// @Summary Get all rate limit settings
// @Tags Settings
// @Security ApiKeyAuth
// @Produce json
// @Success 200 {array} models.RateLimitSetting
// @Router /settings/ratelimit [get]
func (h *SettingsHandler) GetAllRateLimits(c *gin.Context) {
settings, err := h.settingsService.GetAllRateLimitSettings()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch rate limit settings"})
return
}
c.JSON(http.StatusOK, settings)
}
// UpdateRateLimit godoc
// @Summary Update rate limit setting
// @Tags Settings
// @Security ApiKeyAuth
// @Accept json
// @Produce json
// @Param id path string true "Setting ID"
// @Param setting body object true "Update data"
// @Success 200 {object} map[string]interface{}
// @Router /settings/ratelimit/{id} [put]
func (h *SettingsHandler) UpdateRateLimit(c *gin.Context) {
id := c.Param("id")
var input struct {
MaxRequests *int64 `json:"max_requests"`
WindowSeconds *int `json:"window_seconds"`
Description *string `json:"description"`
IsActive *bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
email := c.GetString("email")
updates := make(map[string]interface{})
if input.MaxRequests != nil {
updates["max_requests"] = *input.MaxRequests
}
if input.WindowSeconds != nil {
updates["window_seconds"] = *input.WindowSeconds
}
if input.Description != nil {
updates["description"] = *input.Description
}
if input.IsActive != nil {
updates["is_active"] = *input.IsActive
}
updates["updated_by"] = email
err := h.settingsService.UpdateRateLimitSetting(id, updates)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update rate limit setting"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Rate limit setting updated successfully"})
}

View File

@@ -0,0 +1,538 @@
package handlers
import (
"net/http"
"os"
"strconv"
"strings"
"time"
"gauth-central/internal/database"
"gauth-central/internal/models"
"gauth-central/internal/services"
"gauth-central/pkg/utils"
"github.com/gin-gonic/gin"
)
type UserManagementHandler struct {
userService *services.UserManagementService
}
func NewUserManagementHandler(userService *services.UserManagementService) *UserManagementHandler {
return &UserManagementHandler{
userService: userService,
}
}
// GetAllUsers godoc
// @Summary Get all users (Admin only)
// @Tags Admin - User Management
// @Security ApiKeyAuth
// @Produce json
// @Param page query int false "Page number" default(1)
// @Param limit query int false "Items per page" default(10)
// @Success 200 {object} map[string]interface{}
// @Router /admin/users [get]
func (h *UserManagementHandler) GetAllUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 10
}
users, total, err := h.userService.GetAllUsers(page, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch users"})
return
}
c.JSON(http.StatusOK, gin.H{
"users": users,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"totalPages": (total + int64(limit) - 1) / int64(limit),
},
})
}
// GetUserByID godoc
// @Summary Get user by ID (Admin only)
// @Tags Admin - User Management
// @Security ApiKeyAuth
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {object} models.User
// @Router /admin/users/{id} [get]
func (h *UserManagementHandler) GetUserByID(c *gin.Context) {
userID := c.Param("id")
user, err := h.userService.GetUserByID(userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, user)
}
// CreateUser godoc
// @Summary Create new user (Admin only)
// @Tags Admin - User Management
// @Security ApiKeyAuth
// @Accept multipart/form-data
// @Produce json
// @Param email formData string true "Email"
// @Param password formData string true "Password"
// @Param user_name formData string true "Username"
// @Param email_verified formData boolean false "Email verified"
// @Param roles formData string false "Roles (comma separated: admin,user)"
// @Param avatar formData file false "Avatar image"
// @Success 201 {object} models.User
// @Router /admin/users [post]
func (h *UserManagementHandler) CreateUser(c *gin.Context) {
// Parse multipart form
if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse form data"})
return
}
email := c.PostForm("email")
password := c.PostForm("password")
userName := c.PostForm("user_name")
emailVerified := c.PostForm("email_verified") == "true"
rolesStr := c.PostForm("roles")
// Validate required fields
if email == "" || password == "" || userName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email, password, and user_name are required"})
return
}
// Parse roles
var roles []string
if rolesStr != "" {
roles = strings.Split(rolesStr, ",")
// Trim spaces
for i, role := range roles {
roles[i] = strings.TrimSpace(role)
}
}
user, err := h.userService.CreateUser(
email,
password,
userName,
emailVerified,
roles,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
return
}
// Handle avatar upload if provided
avatarFile, err := c.FormFile("avatar")
if err == nil && avatarFile != nil {
// Validate file size (max 5MB)
if avatarFile.Size > 5*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Avatar file size exceeds 5MB limit"})
return
}
// Use utils.SaveOptimizedImage
// Default options (WebP, 800px width)
avatarURL, err := utils.SaveOptimizedImage(avatarFile, "./uploads/avatars", user.ID.String(), nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar: " + err.Error()})
return
}
// Update user avatar
database.DB.Model(&user).Update("avatar", avatarURL)
user.Avatar = avatarURL
}
c.JSON(http.StatusCreated, user)
}
// UpdateUser godoc
// @Summary Update user (Admin only)
// @Tags Admin - User Management
// @Security ApiKeyAuth
// @Accept multipart/form-data
// @Produce json
// @Param id path string true "User ID"
// @Param email formData string false "Email"
// @Param password formData string false "Password"
// @Param user_name formData string false "Username"
// @Param email_verified formData boolean false "Email verified"
// @Param roles formData string false "Roles (comma separated: admin,user)"
// @Param avatar formData file false "Avatar image"
// @Success 200 {object} map[string]interface{}
// @Router /admin/users/{id} [put]
func (h *UserManagementHandler) UpdateUser(c *gin.Context) {
userID := c.Param("id")
// Try to parse as multipart form first
contentType := c.GetHeader("Content-Type")
isMultipart := strings.Contains(contentType, "multipart/form-data")
updates := make(map[string]interface{})
var roles []string
if isMultipart {
// Parse multipart form
if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse form data"})
return
}
// Get form values
if email := c.PostForm("email"); email != "" {
updates["email"] = email
}
if password := c.PostForm("password"); password != "" {
updates["password"] = password
}
if userName := c.PostForm("user_name"); userName != "" {
updates["user_name"] = userName
}
if emailVerified := c.PostForm("email_verified"); emailVerified != "" {
updates["email_verified"] = emailVerified == "true"
}
if rolesStr := c.PostForm("roles"); rolesStr != "" {
roles = strings.Split(rolesStr, ",")
for i, role := range roles {
roles[i] = strings.TrimSpace(role)
}
}
} else {
// Parse as JSON
var input struct {
Email *string `json:"email"`
Password *string `json:"password"`
UserName *string `json:"user_name"`
Avatar *string `json:"avatar"`
EmailVerified *bool `json:"email_verified"`
Roles []string `json:"roles"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if input.Email != nil {
updates["email"] = *input.Email
}
if input.Password != nil {
updates["password"] = *input.Password
}
if input.UserName != nil {
updates["user_name"] = *input.UserName
}
if input.Avatar != nil {
updates["avatar"] = *input.Avatar
}
if input.EmailVerified != nil {
updates["email_verified"] = *input.EmailVerified
}
if input.Roles != nil {
roles = input.Roles
}
}
// Update basic user fields
if len(updates) > 0 {
if err := h.userService.UpdateUser(userID, updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return
}
}
// Update roles if provided
if len(roles) > 0 {
if err := h.userService.AssignRoles(userID, roles); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update roles: " + err.Error()})
return
}
}
// Handle avatar upload if multipart and file provided
if isMultipart {
avatarFile, err := c.FormFile("avatar")
if err == nil && avatarFile != nil {
// Validate file size (max 5MB)
if avatarFile.Size > 5*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Avatar file size exceeds 5MB limit"})
return
}
// Get user to check for old avatar
var user models.User
if err := database.DB.Where("id = ?", userID).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Delete old avatar if exists and is local
if user.Avatar != "" && strings.HasPrefix(user.Avatar, "/uploads/") {
oldPath := "." + user.Avatar
os.Remove(oldPath)
}
// Use utils.SaveOptimizedImage
avatarURL, err := utils.SaveOptimizedImage(avatarFile, "./uploads/avatars", userID, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar: " + err.Error()})
return
}
// Update user avatar
if err := database.DB.Model(&user).Update("avatar", avatarURL).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update avatar in database"})
return
}
}
}
// Get updated user to return
user, err := h.userService.GetUserByID(userID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "User updated successfully"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "User updated successfully",
"user": user,
})
}
// DeleteUser godoc
// @Summary Delete user (Admin only)
// @Tags Admin - User Management
// @Security ApiKeyAuth
// @Param id path string true "User ID"
// @Param hard query boolean false "Hard delete (permanent)" default(false)
// @Success 200 {object} map[string]interface{}
// @Router /admin/users/{id} [delete]
func (h *UserManagementHandler) DeleteUser(c *gin.Context) {
userID := c.Param("id")
hardDelete := c.Query("hard") == "true"
// Prevent deleting self
currentUserID := c.GetString("user_id")
if userID == currentUserID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete your own account"})
return
}
if err := h.userService.DeleteUser(userID, hardDelete); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"})
return
}
deleteType := "soft"
if hardDelete {
deleteType = "permanently"
}
c.JSON(http.StatusOK, gin.H{"message": "User deleted " + deleteType + " successfully"})
}
// AssignRoles godoc
// @Summary Assign roles to user (Admin only)
// @Tags Admin - User Management
// @Security ApiKeyAuth
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Param roles body object true "Roles"
// @Success 200 {object} map[string]interface{}
// @Router /admin/users/{id}/roles [post]
func (h *UserManagementHandler) AssignRoles(c *gin.Context) {
userID := c.Param("id")
var input struct {
Roles []string `json:"roles" binding:"required"` // ["admin", "user"]
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.userService.AssignRoles(userID, input.Roles); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to assign roles: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Roles assigned successfully"})
}
// RemoveRole godoc
// @Summary Remove role from user (Admin only)
// @Tags Admin - User Management
// @Security ApiKeyAuth
// @Param id path string true "User ID"
// @Param role path string true "Role name"
// @Success 200 {object} map[string]interface{}
// @Router /admin/users/{id}/roles/{role} [delete]
func (h *UserManagementHandler) RemoveRole(c *gin.Context) {
userID := c.Param("id")
roleName := c.Param("role")
if err := h.userService.RemoveRole(userID, roleName); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove role"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Role removed successfully"})
}
// SearchUsers godoc
// @Summary Search users (Admin only)
// @Tags Admin - User Management
// @Security ApiKeyAuth
// @Produce json
// @Param q query string true "Search query"
// @Param page query int false "Page number" default(1)
// @Param limit query int false "Items per page" default(10)
// @Success 200 {object} map[string]interface{}
// @Router /admin/users/search [get]
func (h *UserManagementHandler) SearchUsers(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Search query required"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 10
}
users, total, err := h.userService.SearchUsers(query, page, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search users"})
return
}
c.JSON(http.StatusOK, gin.H{
"users": users,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"totalPages": (total + int64(limit) - 1) / int64(limit),
},
})
}
// GetDeletedUsers godoc
// @Summary Get all soft deleted users (Admin only)
// @Tags Admin - User Management
// @Security ApiKeyAuth
// @Produce json
// @Param page query int false "Page number" default(1)
// @Param limit query int false "Items per page" default(10)
// @Success 200 {object} map[string]interface{}
// @Router /admin/users/deleted [get]
func (h *UserManagementHandler) GetDeletedUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 10
}
users, total, err := h.userService.GetDeletedUsers(page, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch deleted users"})
return
}
// Transform users to include deleted_at field
type DeletedUserResponse struct {
ID string `json:"id"`
UserName string `json:"username"`
Email string `json:"email"`
Avatar string `json:"avatar,omitempty"`
EmailVerified bool `json:"email_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at"`
Roles []models.Role `json:"roles,omitempty"`
SocialAccounts []models.SocialAccount `json:"social_accounts,omitempty"`
}
deletedUsers := make([]DeletedUserResponse, len(users))
for i, user := range users {
var deletedAt *time.Time
if user.DeletedAt.Valid {
deletedAt = &user.DeletedAt.Time
}
emailVerified := false
if user.EmailVerified != nil {
emailVerified = *user.EmailVerified
}
deletedUsers[i] = DeletedUserResponse{
ID: user.ID.String(),
UserName: user.UserName,
Email: user.Email,
Avatar: user.Avatar,
EmailVerified: emailVerified,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
DeletedAt: deletedAt,
Roles: user.Roles,
SocialAccounts: user.SocialAccounts,
}
}
c.JSON(http.StatusOK, gin.H{
"users": deletedUsers,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
"totalPages": (total + int64(limit) - 1) / int64(limit),
},
})
}
// RestoreUser godoc
// @Summary Restore a soft deleted user (Admin only)
// @Tags Admin - User Management
// @Security ApiKeyAuth
// @Param id path string true "User ID"
// @Success 200 {object} map[string]interface{}
// @Router /admin/users/{id}/restore [post]
func (h *UserManagementHandler) RestoreUser(c *gin.Context) {
userID := c.Param("id")
if err := h.userService.RestoreUser(userID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User restored successfully"})
}