975 lines
32 KiB
Go
975 lines
32 KiB
Go
package controllers
|
|
|
|
import (
|
|
configs "ares/config"
|
|
database "ares/database/config"
|
|
"ares/database/models"
|
|
"ares/middlewares"
|
|
utils "ares/pkg/utis"
|
|
"crypto/sha256"
|
|
"ares/services"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/gofiber/fiber/v3"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var validate = validator.New()
|
|
|
|
type RegisterRequest struct {
|
|
UserName string `json:"username" validate:"required,min=3"`
|
|
Email string `json:"email" validate:"required,email"`
|
|
Password string `json:"password" validate:"required,min=6"`
|
|
FirstName string `json:"first_name" validate:"required"`
|
|
LastName string `json:"last_name" validate:"required"`
|
|
}
|
|
|
|
type LoginRequest struct {
|
|
Email string `json:"email" validate:"required,email"`
|
|
Password string `json:"password" validate:"required"`
|
|
}
|
|
|
|
type RefreshRequest struct {
|
|
RefreshToken string `json:"refresh_token" validate:"required"`
|
|
}
|
|
|
|
type ResendVerificationRequest struct {
|
|
Email string `json:"email" validate:"required,email"`
|
|
}
|
|
|
|
// UpdateUserRequest represents allowed fields for updating a user
|
|
type UpdateUserRequest struct {
|
|
UserName string `json:"username,omitempty" example:"jdoe"`
|
|
Email string `json:"email,omitempty" example:"jdoe@example.com"`
|
|
IsAdmin *bool `json:"is_admin,omitempty" example:"false"`
|
|
Password string `json:"password,omitempty" example:"#secret"`
|
|
FirstName string `json:"first_name,omitempty" example:"John"`
|
|
LastName string `json:"last_name,omitempty" example:"Doe"`
|
|
AvatarURL string `json:"avatar_url,omitempty" example:"/uploads/avatar.jpg"`
|
|
EmailVerified *bool `json:"email_verified,omitempty" example:"true"`
|
|
// Accept avatar file via multipart/form-data with field name "avatar" when using form upload
|
|
}
|
|
|
|
func GetUser(c fiber.Ctx) error {
|
|
return c.Status(fiber.StatusOK).SendString("Get User")
|
|
}
|
|
|
|
func AdminListUsers(c fiber.Ctx) error {
|
|
if database.DB == nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
|
|
}
|
|
|
|
var users []models.User
|
|
if err := database.DB.Preload("Profile").Order("id DESC").Find(&users).Error; err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"count": len(users),
|
|
"users": users,
|
|
})
|
|
}
|
|
|
|
func AdminListDeletedUsers(c fiber.Ctx) error {
|
|
if database.DB == nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
|
|
}
|
|
|
|
var users []models.User
|
|
if err := database.DB.Unscoped().
|
|
Preload("Profile").
|
|
Where("deleted_at IS NOT NULL").
|
|
Order("deleted_at DESC").
|
|
Find(&users).Error; err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"count": len(users),
|
|
"users": users,
|
|
})
|
|
}
|
|
|
|
func GetUserOne(c fiber.Ctx) error {
|
|
return c.Status(fiber.StatusOK).SendString("Get User One")
|
|
}
|
|
|
|
func UpdateUser(c fiber.Ctx) error {
|
|
if database.DB == nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
|
|
}
|
|
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil || id == 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid user id"})
|
|
}
|
|
|
|
var user models.User
|
|
if err := database.DB.Preload("Profile").First(&user, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
|
}
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
|
}
|
|
|
|
// Parse incoming JSON or multipart/form-data into map to allow partial updates including false values
|
|
var payload map[string]interface{}
|
|
|
|
// Prefer detecting multipart by trying to read the multipart form first
|
|
if mf, err := c.MultipartForm(); err == nil && mf != nil {
|
|
payload = map[string]interface{}{}
|
|
// form values
|
|
for k, vals := range mf.Value {
|
|
if len(vals) > 0 {
|
|
payload[k] = vals[0]
|
|
}
|
|
}
|
|
|
|
// handle avatar file if present
|
|
if files, ok := mf.File["avatar"]; ok && len(files) > 0 {
|
|
file := files[0]
|
|
if _, err := os.Stat("./uploads/avatars"); os.IsNotExist(err) {
|
|
os.MkdirAll("./uploads/avatars", 0755)
|
|
}
|
|
filename := fmt.Sprintf("%d_%s", time.Now().Unix(), file.Filename)
|
|
filePath := filepath.Join("./uploads/avatars", filename)
|
|
if err := c.SaveFile(file, filePath); err != nil {
|
|
if configs.Logger != nil {
|
|
configs.Logger.Sugar().Errorf("failed to save avatar: %v", err)
|
|
}
|
|
} else {
|
|
payload["avatar_url"] = "/uploads/avatars/" + filename
|
|
}
|
|
}
|
|
} else {
|
|
// fallback to JSON body
|
|
if err := json.Unmarshal(c.Body(), &payload); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
}
|
|
|
|
// Prepare updates for user table
|
|
userUpdates := map[string]interface{}{}
|
|
if v, ok := payload["username"].(string); ok {
|
|
userUpdates["user_name"] = v
|
|
userUpdates["user_name"] = v
|
|
user.UserName = v
|
|
}
|
|
if v, ok := payload["email"].(string); ok {
|
|
userUpdates["email"] = v
|
|
user.Email = v
|
|
}
|
|
if v, ok := payload["is_admin"]; ok {
|
|
// handle bool or string representations
|
|
switch val := v.(type) {
|
|
case bool:
|
|
userUpdates["is_admin"] = val
|
|
user.IsAdmin = &val
|
|
case string:
|
|
if parsed, err := strconv.ParseBool(val); err == nil {
|
|
userUpdates["is_admin"] = parsed
|
|
user.IsAdmin = &parsed
|
|
}
|
|
}
|
|
}
|
|
// Handle email_verified explicitly (bool or string)
|
|
if v, ok := payload["email_verified"]; ok {
|
|
switch val := v.(type) {
|
|
case bool:
|
|
userUpdates["email_verified"] = val
|
|
now := time.Now()
|
|
if val {
|
|
userUpdates["email_verified_at"] = now
|
|
user.EmailVerified = &val
|
|
user.EmailVerifiedAt = &now
|
|
} else {
|
|
userUpdates["email_verified_at"] = nil
|
|
user.EmailVerified = &val
|
|
user.EmailVerifiedAt = nil
|
|
}
|
|
case string:
|
|
if parsed, err := strconv.ParseBool(val); err == nil {
|
|
userUpdates["email_verified"] = parsed
|
|
now := time.Now()
|
|
if parsed {
|
|
userUpdates["email_verified_at"] = now
|
|
user.EmailVerified = &parsed
|
|
user.EmailVerifiedAt = &now
|
|
} else {
|
|
userUpdates["email_verified_at"] = nil
|
|
user.EmailVerified = &parsed
|
|
user.EmailVerifiedAt = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if v, ok := payload["password"].(string); ok && v != "" {
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(v), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "could not hash password"})
|
|
}
|
|
userUpdates["password"] = string(hashed)
|
|
user.Password = string(hashed)
|
|
}
|
|
|
|
// Apply user updates if any
|
|
if len(userUpdates) > 0 {
|
|
if err := database.DB.Model(&user).Updates(userUpdates).Error; err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "user could not be updated"})
|
|
}
|
|
}
|
|
|
|
// Handle profile updates (first_name, last_name, avatar_url)
|
|
profileUpdates := map[string]interface{}{}
|
|
if v, ok := payload["first_name"].(string); ok {
|
|
profileUpdates["first_name"] = v
|
|
}
|
|
if v, ok := payload["last_name"].(string); ok {
|
|
profileUpdates["last_name"] = v
|
|
}
|
|
if v, ok := payload["avatar_url"].(string); ok {
|
|
profileUpdates["avatar_url"] = v
|
|
}
|
|
|
|
if len(profileUpdates) > 0 {
|
|
// Profile may be stored as slice; update first profile if exists else create
|
|
var profile models.Profile
|
|
if len(user.Profile) > 0 {
|
|
profile = user.Profile[0]
|
|
if err := database.DB.Model(&profile).Updates(profileUpdates).Error; err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "profile could not be updated"})
|
|
}
|
|
} else {
|
|
profile = models.Profile{
|
|
UserID: uint64(user.ID),
|
|
}
|
|
if v, ok := profileUpdates["first_name"].(string); ok {
|
|
profile.FirstName = v
|
|
}
|
|
if v, ok := profileUpdates["last_name"].(string); ok {
|
|
profile.LastName = v
|
|
}
|
|
if v, ok := profileUpdates["avatar_url"].(string); ok {
|
|
profile.AvatarURL = v
|
|
}
|
|
if err := database.DB.Create(&profile).Error; err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "profile could not be created"})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reload user with profile
|
|
if err := database.DB.Preload("Profile").First(&user, id).Error; err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"message": "user updated", "user": user})
|
|
}
|
|
|
|
func DeleteUser(c fiber.Ctx) error {
|
|
if database.DB == nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
|
|
}
|
|
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil || id == 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid user id"})
|
|
}
|
|
|
|
var user models.User
|
|
if err := database.DB.First(&user, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
|
}
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
|
}
|
|
|
|
if err := database.DB.Delete(&user).Error; err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "user could not be deleted"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"message": "user soft-deleted successfully",
|
|
"user_id": id,
|
|
})
|
|
}
|
|
|
|
func HardDeleteUser(c fiber.Ctx) error {
|
|
if database.DB == nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
|
|
}
|
|
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil || id == 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid user id"})
|
|
}
|
|
|
|
var user models.User
|
|
if err := database.DB.Unscoped().First(&user, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
|
}
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
|
}
|
|
|
|
err = database.DB.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Unscoped().Where("user_id = ?", id).Delete(&models.Profile{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Unscoped().Where("user_id = ?", id).Delete(&models.SocialAccount{}).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Unscoped().Delete(&models.User{}, id).Error
|
|
})
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "user hard-delete failed"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"message": "user permanently deleted",
|
|
"user_id": id,
|
|
})
|
|
}
|
|
|
|
func RestoreUser(c fiber.Ctx) error {
|
|
if database.DB == nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
|
|
}
|
|
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
|
if err != nil || id == 0 {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid user id"})
|
|
}
|
|
|
|
var user models.User
|
|
if err := database.DB.Unscoped().First(&user, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
|
}
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
|
}
|
|
|
|
if !user.DeletedAt.Valid {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "user is not soft-deleted"})
|
|
}
|
|
|
|
if err := database.DB.Unscoped().Model(&models.User{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "user could not be restored"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"message": "user restored successfully",
|
|
"user_id": id,
|
|
})
|
|
}
|
|
|
|
// Register godoc
|
|
// @Summary Register user
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body RegisterRequest true "Register payload"
|
|
// @Success 201 {object} map[string]interface{}
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 409 {object} map[string]string
|
|
// @Router /api/v1/auth/register [post]
|
|
func Register(c fiber.Ctx) error {
|
|
if database.DB == nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
|
|
}
|
|
|
|
var req RegisterRequest
|
|
if err := c.Bind().JSON(&req); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
if err := validate.Struct(req); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "password could not be hashed"})
|
|
}
|
|
|
|
verifyToken, err := utils.GenerateSecureToken(32)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "verify token could not be generated"})
|
|
}
|
|
|
|
user := models.User{
|
|
UserName: req.UserName,
|
|
Email: req.Email,
|
|
Password: string(hashedPassword),
|
|
EmailVerifyToken: verifyToken,
|
|
}
|
|
|
|
if err := database.DB.Create(&user).Error; err != nil {
|
|
return c.Status(http.StatusConflict).JSON(fiber.Map{"error": "email already exists"})
|
|
}
|
|
|
|
profile := models.Profile{
|
|
UserID: uint64(user.ID),
|
|
FirstName: req.FirstName,
|
|
LastName: req.LastName,
|
|
}
|
|
if err := database.DB.Create(&profile).Error; err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "profile could not be created"})
|
|
}
|
|
|
|
appURL := strings.TrimRight(configs.AppConfig.AppURL, "/")
|
|
verifyURL := fmt.Sprintf("%s/api/v1/auth/verify-email?token=%s", appURL, url.QueryEscape(verifyToken))
|
|
|
|
emailService := services.NewEmailService()
|
|
err = emailService.SendVerificationEmail(user.Email, profile.FirstName, verifyURL)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "verification email could not be sent"})
|
|
}
|
|
|
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
|
"message": "registration successful, please verify your email before login",
|
|
"user": fiber.Map{
|
|
"id": user.ID,
|
|
"username": user.UserName,
|
|
"email": user.Email,
|
|
"is_admin": boolPtrValue(user.IsAdmin),
|
|
"email_verified": false,
|
|
"first_name": profile.FirstName,
|
|
"last_name": profile.LastName,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Login godoc
|
|
// @Summary Login user
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body LoginRequest true "Login payload"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Router /api/v1/auth/login [post]
|
|
func Login(c fiber.Ctx) error {
|
|
if database.DB == nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
|
|
}
|
|
|
|
var req LoginRequest
|
|
if err := c.Bind().JSON(&req); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
if err := validate.Struct(req); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
var user models.User
|
|
if err := database.DB.Preload("Profile").Where("email = ?", req.Email).First(&user).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "invalid email or password"})
|
|
}
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "invalid email or password"})
|
|
}
|
|
|
|
if !user.IsEmailVerified() {
|
|
return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "please verify your email before login"})
|
|
}
|
|
|
|
firstName, lastName := extractProfileName(user.Profile)
|
|
jwtService := services.NewJWTService()
|
|
accessToken, refreshToken, err := jwtService.GenerateTokenPair(
|
|
user.ID,
|
|
user.Email,
|
|
boolPtrValue(user.IsAdmin),
|
|
firstName,
|
|
lastName,
|
|
)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "tokens could not be generated"})
|
|
}
|
|
|
|
// Persist refresh token server-side for rotation & revoke
|
|
refreshClaims, err := jwtService.ValidateToken(refreshToken)
|
|
if err != nil || refreshClaims.TokenType != services.TokenTypeRefresh {
|
|
if configs.Logger != nil {
|
|
configs.Logger.Sugar().Errorf("failed to validate issued refresh token for user=%d: %v", user.ID, err)
|
|
}
|
|
} else {
|
|
// Revoke any existing refresh tokens for this user (single-device semantics)
|
|
if err := database.DB.Model(&models.RefreshToken{}).
|
|
Where("user_id = ?", user.ID).
|
|
Update("revoked", true).Error; err != nil {
|
|
if configs.Logger != nil {
|
|
configs.Logger.Sugar().Errorf("failed to revoke existing refresh tokens for user=%d: %v", user.ID, err)
|
|
}
|
|
}
|
|
|
|
expiresAt := time.Now().Add(time.Duration(configs.AppConfig.RefreshTokenExpireDays) * 24 * time.Hour)
|
|
if refreshClaims.ExpiresAt != nil {
|
|
expiresAt = refreshClaims.ExpiresAt.Time
|
|
}
|
|
|
|
rt := models.RefreshToken{
|
|
UserID: user.ID,
|
|
TokenID: refreshClaims.ID,
|
|
TokenHash: sha256Hex(refreshToken),
|
|
TokenFingerprint: tokenFingerprint(refreshToken),
|
|
ExpiresAt: expiresAt.UTC(),
|
|
Revoked: false,
|
|
UserAgent: c.Get("User-Agent"),
|
|
IP: c.IP(),
|
|
}
|
|
if err := database.DB.Create(&rt).Error; err != nil {
|
|
if configs.Logger != nil {
|
|
configs.Logger.Sugar().Errorf("failed to persist refresh token for user=%d: %v", user.ID, err)
|
|
}
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "refresh token could not be persisted"})
|
|
}
|
|
if configs.Logger != nil {
|
|
configs.Logger.Sugar().Infof("refresh token persisted user=%d token_id=%s fingerprint=%s", user.ID, rt.TokenID, rt.TokenFingerprint)
|
|
}
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"user": fiber.Map{
|
|
"id": user.ID,
|
|
"username": user.UserName,
|
|
"email": user.Email,
|
|
"is_admin": boolPtrValue(user.IsAdmin),
|
|
"first_name": firstName,
|
|
"last_name": lastName,
|
|
},
|
|
"access_token": accessToken,
|
|
"refresh_token": refreshToken,
|
|
})
|
|
}
|
|
|
|
// RefreshToken godoc
|
|
// @Summary Refresh access token
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body RefreshRequest true "Refresh payload"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Router /api/v1/auth/refresh [post]
|
|
func RefreshToken(c fiber.Ctx) error {
|
|
if database.DB == nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
|
|
}
|
|
|
|
var req RefreshRequest
|
|
if err := c.Bind().JSON(&req); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
if err := validate.Struct(req); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
jwtService := services.NewJWTService()
|
|
claims, err := jwtService.ValidateToken(req.RefreshToken)
|
|
if err != nil || claims.TokenType != services.TokenTypeRefresh {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "invalid refresh token"})
|
|
}
|
|
|
|
// Look up refresh token server-side to enforce rotation and revoke state
|
|
var stored models.RefreshToken
|
|
if err := database.DB.Where("token_id = ? AND user_id = ?", claims.ID, claims.UserID).First(&stored).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "invalid or expired refresh token"})
|
|
}
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "refresh token lookup failed"})
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
if stored.Revoked || stored.ExpiresAt.Before(now) {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "invalid or expired refresh token"})
|
|
}
|
|
// Extra safety: if we have a stored hash, require it to match.
|
|
if stored.TokenHash != "" && stored.TokenHash != sha256Hex(req.RefreshToken) {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "invalid refresh token"})
|
|
}
|
|
|
|
// Reuse detection: if this token was already rotated to a new one, treat as suspicious
|
|
if stored.ReplacedByTokenID != "" {
|
|
if configs.Logger != nil {
|
|
configs.Logger.Sugar().Warnf("refresh token reuse detected for user=%d token_id=%s", claims.UserID, claims.ID)
|
|
}
|
|
// Revoke all refresh tokens for this user to force re-login
|
|
if err := database.DB.Model(&models.RefreshToken{}).
|
|
Where("user_id = ?", claims.UserID).
|
|
Update("revoked", true).Error; err != nil {
|
|
if configs.Logger != nil {
|
|
configs.Logger.Sugar().Errorf("failed to revoke refresh tokens after reuse for user=%d: %v", claims.UserID, err)
|
|
}
|
|
}
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "refresh token has been reused; please login again"})
|
|
}
|
|
|
|
var user models.User
|
|
if err := database.DB.Preload("Profile").First(&user, claims.UserID).Error; err != nil {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "user not found"})
|
|
}
|
|
|
|
firstName, lastName := extractProfileName(user.Profile)
|
|
accessToken, refreshToken, err := jwtService.GenerateTokenPair(
|
|
user.ID,
|
|
user.Email,
|
|
boolPtrValue(user.IsAdmin),
|
|
firstName,
|
|
lastName,
|
|
)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "tokens could not be generated"})
|
|
}
|
|
|
|
// Persist new refresh token and rotate old one
|
|
newClaims, err := jwtService.ValidateToken(refreshToken)
|
|
if err != nil || newClaims.TokenType != services.TokenTypeRefresh {
|
|
if configs.Logger != nil {
|
|
configs.Logger.Sugar().Errorf("failed to validate rotated refresh token for user=%d: %v", user.ID, err)
|
|
}
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "refresh token rotation failed"})
|
|
}
|
|
|
|
expiresAt := now.Add(time.Duration(configs.AppConfig.RefreshTokenExpireDays) * 24 * time.Hour)
|
|
if newClaims.ExpiresAt != nil {
|
|
expiresAt = newClaims.ExpiresAt.Time
|
|
}
|
|
|
|
if err := database.DB.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Model(&stored).Updates(map[string]interface{}{
|
|
"revoked": true,
|
|
"replaced_by_token_id": newClaims.ID,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
rt := models.RefreshToken{
|
|
UserID: user.ID,
|
|
TokenID: newClaims.ID,
|
|
TokenHash: sha256Hex(refreshToken),
|
|
TokenFingerprint: tokenFingerprint(refreshToken),
|
|
ExpiresAt: expiresAt.UTC(),
|
|
Revoked: false,
|
|
UserAgent: c.Get("User-Agent"),
|
|
IP: c.IP(),
|
|
}
|
|
if err := tx.Create(&rt).Error; err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
if configs.Logger != nil {
|
|
configs.Logger.Sugar().Errorf("failed to rotate refresh token for user=%d: %v", user.ID, err)
|
|
}
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "refresh token rotation failed"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"access_token": accessToken,
|
|
"refresh_token": refreshToken,
|
|
})
|
|
}
|
|
|
|
func sha256Hex(s string) string {
|
|
sum := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func tokenFingerprint(token string) string {
|
|
token = strings.TrimSpace(token)
|
|
if len(token) <= 10 {
|
|
return "****"
|
|
}
|
|
return token[:6] + "..." + token[len(token)-4:]
|
|
}
|
|
|
|
// Logout godoc
|
|
// @Summary Logout user and revoke refresh tokens
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body RefreshRequest true "Logout payload (refresh token)"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Router /api/v1/auth/logout [post]
|
|
func Logout(c fiber.Ctx) error {
|
|
if database.DB == nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
|
|
}
|
|
|
|
var req RefreshRequest
|
|
if err := c.Bind().JSON(&req); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
if err := validate.Struct(req); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
jwtService := services.NewJWTService()
|
|
claims, err := jwtService.ValidateToken(req.RefreshToken)
|
|
if err != nil || claims.TokenType != services.TokenTypeRefresh {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "invalid refresh token"})
|
|
}
|
|
|
|
// Revoke all refresh tokens for this user to enforce full logout
|
|
if err := database.DB.Model(&models.RefreshToken{}).
|
|
Where("user_id = ?", claims.UserID).
|
|
Update("revoked", true).Error; err != nil {
|
|
if configs.Logger != nil {
|
|
configs.Logger.Sugar().Errorf("failed to revoke refresh tokens on logout for user=%d: %v", claims.UserID, err)
|
|
}
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "logout failed"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"message": "logged out successfully"})
|
|
}
|
|
|
|
// VerifyEmail godoc
|
|
// @Summary Verify email address with token
|
|
// @Tags Auth
|
|
// @Produce json
|
|
// @Param token query string true "Email verify token"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/auth/verify-email [get]
|
|
func VerifyEmail(c fiber.Ctx) error {
|
|
if database.DB == nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
|
|
}
|
|
|
|
token := strings.TrimSpace(c.Query("token"))
|
|
if token == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "token is required"})
|
|
}
|
|
|
|
var user models.User
|
|
if err := database.DB.Where("email_verify_token = ?", token).First(&user).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "invalid or expired token"})
|
|
}
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
|
}
|
|
|
|
now := time.Now()
|
|
isVerified := true
|
|
user.EmailVerified = &isVerified
|
|
user.EmailVerifiedAt = &now
|
|
user.EmailVerifyToken = ""
|
|
|
|
if err := database.DB.Save(&user).Error; err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "email verification could not be saved"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"message": "email verified successfully"})
|
|
}
|
|
|
|
// ResendVerificationEmail godoc
|
|
// @Summary Resend verification email
|
|
// @Tags Auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body ResendVerificationRequest true "Resend verification payload"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/auth/resend-verification [post]
|
|
func ResendVerificationEmail(c fiber.Ctx) error {
|
|
if database.DB == nil {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "database is not configured"})
|
|
}
|
|
|
|
var req ResendVerificationRequest
|
|
if err := c.Bind().JSON(&req); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
if err := validate.Struct(req); err != nil {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
var user models.User
|
|
if err := database.DB.Preload("Profile").Where("email = ?", req.Email).First(&user).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
|
}
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
|
}
|
|
|
|
if user.IsEmailVerified() {
|
|
return c.JSON(fiber.Map{"message": "email is already verified"})
|
|
}
|
|
|
|
verifyToken, err := utils.GenerateSecureToken(32)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "verify token could not be generated"})
|
|
}
|
|
user.EmailVerifyToken = verifyToken
|
|
if err := database.DB.Save(&user).Error; err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "verification token could not be saved"})
|
|
}
|
|
|
|
firstName, _ := extractProfileName(user.Profile)
|
|
appURL := strings.TrimRight(configs.AppConfig.AppURL, "/")
|
|
verifyURL := fmt.Sprintf("%s/api/v1/auth/verify-email?token=%s", appURL, url.QueryEscape(verifyToken))
|
|
|
|
emailService := services.NewEmailService()
|
|
if err := emailService.SendVerificationEmail(user.Email, firstName, verifyURL); err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "verification email could not be sent"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"message": "verification email has been sent"})
|
|
}
|
|
|
|
// Me godoc
|
|
// @Summary Get current user from token
|
|
// @Tags Auth
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 401 {object} map[string]string
|
|
// @Router /api/v1/auth/me [get]
|
|
func Me(c fiber.Ctx) error {
|
|
claims, ok := middlewares.GetAuthClaims(c)
|
|
if !ok {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"user": fiber.Map{
|
|
"id": claims.UserID,
|
|
"email": claims.Email,
|
|
"is_admin": claims.IsAdmin,
|
|
"first_name": claims.FirstName,
|
|
"last_name": claims.LastName,
|
|
},
|
|
})
|
|
}
|
|
|
|
func AdminOnlyExample(c fiber.Ctx) error {
|
|
claims, ok := middlewares.GetAuthClaims(c)
|
|
if !ok {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"message": "only admins can access this endpoint",
|
|
"user": claims.Email,
|
|
})
|
|
}
|
|
|
|
func UserOnlyExample(c fiber.Ctx) error {
|
|
claims, ok := middlewares.GetAuthClaims(c)
|
|
if !ok {
|
|
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"message": "only normal users can access this endpoint",
|
|
"user": claims.Email,
|
|
})
|
|
}
|
|
|
|
func GoogleAuth(c fiber.Ctx) error {
|
|
if configs.AppConfig.GoogleClientID == "" {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "google oauth is not configured"})
|
|
}
|
|
|
|
stateToken, err := utils.GenerateSecureToken(16)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "state token could not be generated"})
|
|
}
|
|
|
|
authURL := "https://accounts.google.com/o/oauth2/v2/auth?" + url.Values{
|
|
"client_id": []string{configs.AppConfig.GoogleClientID},
|
|
"redirect_uri": []string{configs.AppConfig.GoogleRedirectURL},
|
|
"response_type": []string{"code"},
|
|
"scope": []string{"openid email profile"},
|
|
"state": []string{stateToken},
|
|
}.Encode()
|
|
|
|
return c.JSON(fiber.Map{"provider": "google", "auth_url": authURL, "state": stateToken})
|
|
}
|
|
|
|
func GoogleAuthCallback(c fiber.Ctx) error {
|
|
code := c.Query("code")
|
|
if code == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "google callback code is missing"})
|
|
}
|
|
|
|
// OAuth token exchange is intentionally left simple for now.
|
|
return c.JSON(fiber.Map{
|
|
"provider": "google",
|
|
"message": "google callback infrastructure is ready, token exchange can be added next",
|
|
"code": code,
|
|
"state": c.Query("state"),
|
|
})
|
|
}
|
|
|
|
func GithubAuth(c fiber.Ctx) error {
|
|
if configs.AppConfig.GithubClientID == "" {
|
|
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "github oauth is not configured"})
|
|
}
|
|
|
|
stateToken, err := utils.GenerateSecureToken(16)
|
|
if err != nil {
|
|
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "state token could not be generated"})
|
|
}
|
|
|
|
authURL := "https://github.com/login/oauth/authorize?" + url.Values{
|
|
"client_id": []string{configs.AppConfig.GithubClientID},
|
|
"redirect_uri": []string{configs.AppConfig.GithubRedirectURL},
|
|
"scope": []string{"read:user user:email"},
|
|
"state": []string{stateToken},
|
|
}.Encode()
|
|
|
|
return c.JSON(fiber.Map{"provider": "github", "auth_url": authURL, "state": stateToken})
|
|
}
|
|
|
|
func GithubAuthCallback(c fiber.Ctx) error {
|
|
code := c.Query("code")
|
|
if code == "" {
|
|
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "github callback code is missing"})
|
|
}
|
|
|
|
// OAuth token exchange is intentionally left simple for now.
|
|
return c.JSON(fiber.Map{
|
|
"provider": "github",
|
|
"message": "github callback infrastructure is ready, token exchange can be added next",
|
|
"code": code,
|
|
"state": c.Query("state"),
|
|
})
|
|
}
|
|
|
|
func extractProfileName(profiles []models.Profile) (string, string) {
|
|
if len(profiles) == 0 {
|
|
return "", ""
|
|
}
|
|
return profiles[0].FirstName, profiles[0].LastName
|
|
}
|
|
|
|
func boolPtrValue(v *bool) bool {
|
|
if v == nil {
|
|
return false
|
|
}
|
|
return *v
|
|
}
|