go-quasar-partial-ssr/backend/internal/user/controller.go

518 lines
15 KiB
Go

package users
import (
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/google/uuid"
"gorm.io/gorm"
"server/internal/auth"
"server/internal/db"
"server/internal/mail"
"server/internal/responses"
"server/internal/systemUtils"
"server/internal/tokens"
"server/internal/validation"
)
type UserController struct {
TockenService *tokens.TockenService
}
func NewUserController(tockenService *tokens.TockenService) *UserController {
return &UserController{
TockenService: tockenService,
}
}
// GetUser returns a single user by ID.
func (uc *UserController) GetUser(c fiber.Ctx) error {
user, err := GetUserByID(c.Params("id"))
if err != nil {
return err
}
return c.JSON(responses.Success(user))
}
// CreateUser creates a user together with optional details and preferences.
func (uc *UserController) CreateUser(c fiber.Ctx) error {
var req UserCreateRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
user, err := CreateUser(req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).JSON(responses.Success(user))
}
// UpdateUser replaces user fields and synchronizes details/preferences.
func (uc *UserController) UpdateUser(c fiber.Ctx) error {
var req UpdateUserRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
user, err := UpdateUser(req)
if err != nil {
return err
}
return c.JSON(responses.Success(user))
}
// UpdateUserDetails replaces user fields and synchronizes details/preferences.
func (uc *UserController) UpdateUserDetails(c fiber.Ctx) error {
var req UpdateUserDetailsRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
err := UpdateUserDetails(req)
if err != nil {
return err
}
user, err := GetUserByID(req.UserID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid user id")
}
return c.JSON(responses.Success(user))
}
// DeleteUser removes a user and linked details/preferences through cascading delete rules.
func (uc *UserController) DeleteUser(c fiber.Ctx) error {
if err := DeleteUser(c.Params("uuid")); err != nil {
return err
}
return c.JSON(responses.Success(responses.SimpleResponse{Message: "user deleted"}))
}
// Login authenticates a user and issues an access/refresh token pair.
func (uc *UserController) Login(c fiber.Ctx) error {
var req LoginRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := db.DBFromCtx(c)
if err != nil {
return err
}
var user User
if err := db.Where("email = ?", req.Username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch user")
}
match, err := systemUtils.VerifyPassword(user.Password, req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to verify credentials")
}
if !match {
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
}
permission := auth.RoleToPermission(user.Permission)
if permission == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "invalid user role")
}
token, err := uc.TockenService.GenerateTokenPair(user.ID, permission)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
}
userID := user.ID
now := time.Now().UTC()
if err := db.Where("expires_at < ?", now).Delete(&Session{}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to purge expired sessions")
}
session := Session{
UserID: &userID,
Username: user.Email,
AccessTokenHash: tokens.HashToken(token.AccessToken),
RefreshTokenHash: tokens.HashToken(token.RefreshToken),
ExpiresAt: now.Add(uc.TockenService.RefreshExpiry()),
IPAddress: c.IP(),
UserAgent: c.Get("User-Agent"),
}
if err := db.Create(&session).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to record session")
}
c.Response().Header.Set("Auth-Token", token.AccessToken)
return c.JSON(responses.Success(token))
}
// Register creates a new user with optional roles/types/preferences.
func (uc *UserController) Register(c fiber.Ctx) error {
var req UserCreateRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := db.DBFromCtx(c)
if err != nil {
return err
}
var existing User
if err := db.Where("email = ?", req.Email).First(&existing).Error; err == nil {
return fiber.NewError(fiber.StatusConflict, "user already exists")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
}
now := time.Now().UTC()
hashedPassword, err := systemUtils.HashPassword(req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
}
user := User{
Email: req.Email,
Name: req.Name,
Password: hashedPassword,
Permission: req.Permission,
Status: func() UserStatus {
if req.Status == "" {
return UserStatusPending
}
return req.Status
}(),
Type: func() UserType {
if len(req.Type) == 0 {
return UserType("internal")
}
return req.Type
}(),
Avatar: req.Avatar,
ID: uuid.NewString(),
Details: req.Details,
Preferences: func() *UserPreferences {
if req.Preferences == nil {
return nil
}
return &UserPreferences{
UseIdle: req.Preferences.UseIdle,
IdleTimeout: req.Preferences.IdleTimeout,
UseIdlePassword: req.Preferences.UseIdlePassword,
IdlePin: req.Preferences.IdlePin,
UseDirectLogin: req.Preferences.UseDirectLogin,
UseQuadcodeLogin: req.Preferences.UseQuadcodeLogin,
SendNoticesMail: req.Preferences.SendNoticesMail,
Language: req.Preferences.Language,
}
}(),
CreatedAt: &now,
UpdatedAt: &now,
}
if err := db.Create(&user).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to create user")
}
mailService, err := mail.New()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to initialize mail service")
}
if err := mailService.Send(c, mail.Message{
To: user.Email,
Subject: fmt.Sprintf("[%s] Registrazione completata", mailService.AppName()),
Template: "registration",
TemplateData: mail.TemplateData{
AppName: mailService.AppName(),
UserName: user.Name,
UserEmail: user.Email,
},
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to send registration email")
}
return c.Status(fiber.StatusCreated).JSON(responses.Success(&user))
}
func (uc *UserController) ForgotPassword(c fiber.Ctx) error {
var req ForgotPasswordRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := db.DBFromCtx(c)
if err != nil {
return err
}
var user User
if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(responses.Success(fiber.Map{"sent": true}))
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
if user.Status == UserStatusDisabled {
return c.JSON(responses.Success(fiber.Map{"sent": true}))
}
resetToken, err := uc.TockenService.GenerateSecureToken()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate reset token")
}
now := time.Now().UTC()
record := PasswordResetToken{
UserID: user.ID,
TokenHash: tokens.HashToken(resetToken),
ExpiresAt: now.Add(30 * time.Minute),
CreatedAt: &now,
UpdatedAt: &now,
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ? OR expires_at < ? OR used_at IS NOT NULL", user.ID, now).
Delete(&PasswordResetToken{}).Error; err != nil {
return err
}
return tx.Create(&record).Error
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to store reset token")
}
mailService, err := mail.New()
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to initialize mail service")
}
if err := mailService.Send(c, mail.Message{
To: user.Email,
Subject: fmt.Sprintf("[%s] Recupero password", mailService.AppName()),
Template: "password_reset",
TemplateData: mail.TemplateData{
AppName: mailService.AppName(),
UserName: user.Name,
UserEmail: user.Email,
ResetToken: resetToken,
ResetURL: mailService.ResetLink(resetToken),
},
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to send reset email")
}
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset email sent"}))
}
func (uc *UserController) ResetPassword(c fiber.Ctx) error {
var req ResetPasswordRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
db, err := db.DBFromCtx(c)
if err != nil {
return err
}
hashedPassword, err := systemUtils.HashPassword(req.Password)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
}
now := time.Now().UTC()
tokenHash := tokens.HashToken(req.Token)
if err := db.Transaction(func(tx *gorm.DB) error {
var resetToken PasswordResetToken
if err := tx.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
}
return err
}
if resetToken.UsedAt != nil || now.After(resetToken.ExpiresAt) {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
}
if err := tx.Model(&User{}).Where("id = ?", resetToken.UserID).Updates(map[string]any{
"password": hashedPassword,
"updated_at": now,
}).Error; err != nil {
return err
}
if err := tx.Model(&resetToken).Updates(map[string]any{
"used_at": now,
"updated_at": now,
}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", resetToken.UserID).Delete(&Session{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ? AND id <> ?", resetToken.UserID, resetToken.ID).Delete(&PasswordResetToken{}).Error; err != nil {
return err
}
return nil
}); err != nil {
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
return fiberErr
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to reset password")
}
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset successful"}))
}
func (uc *UserController) ValidToken(c fiber.Ctx) error {
raw := strings.TrimSpace(string(c.Body()))
if raw == "" {
return fiber.NewError(fiber.StatusBadRequest, "token is required")
}
token := raw
if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") {
if err := json.Unmarshal([]byte(raw), &token); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
}
token = strings.TrimSpace(token)
if token == "" {
return fiber.NewError(fiber.StatusBadRequest, "token is required")
}
db, err := db.DBFromCtx(c)
if err != nil {
return err
}
now := time.Now().UTC()
tokenHash := tokens.HashToken(token)
var resetToken PasswordResetToken
if err := db.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to validate reset token")
}
if resetToken.UsedAt != nil || now.After(resetToken.ExpiresAt) {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
}
return c.JSON(responses.Success(responses.SimpleResponse{Message: "valid reset token"}))
}
// Me returns the authenticated user's profile (short format).
func (uc *UserController) Me(c fiber.Ctx) error {
tokenService, err := tokens.GetTockenService()
if err != nil {
log.Fatalf("init tokens: %v", err)
}
tokenString := c.Get("Auth-Token")
if tokenString == "" {
return fiber.NewError(fiber.StatusForbidden, "missing token header")
}
claims, err := tokenService.ValidateAccessToken(tokenString)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, "bad token")
}
db, err := db.DBFromCtx(c)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to load db")
}
var user User
if err := db.Preload("Details").Preload("Preferences").Where("id = ?", claims.ID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "user not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
return c.JSON(responses.Success(&user))
}
func (us *UserController) Refresh(c fiber.Ctx) error {
var req RefreshRequest
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if req.RefreshToken == "" {
return fiber.NewError(fiber.StatusBadRequest, "refresh_token is required")
}
claims, err := us.TockenService.ParseToken(req.RefreshToken)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
}
if claims.TokenType != tokens.TokenTypeRefresh {
return fiber.NewError(fiber.StatusUnauthorized, "refresh token required")
}
tokens, err := us.TockenService.GenerateTokenPair(claims.ID, claims.Permission)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(responses.Success(tokens))
}
// update user password by Claims
func (us *UserController) UpdatePassword(c fiber.Ctx) error {
var req UpdatePasswordRequest
claims := c.Locals("authClaims")
if claims == nil {
return fiber.NewError(fiber.StatusForbidden, "forbidden")
}
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
return err
}
if err := UpdateUserPassword(req, claims.(tokens.Claims).ID); err != nil {
return err
}
return c.JSON(responses.Success(responses.SimpleResponse{Message: "password updated"}))
}