package controllers import ( "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "strings" "time" "github.com/gofiber/fiber/v3" "gorm.io/gorm" "server/internal/auth" "server/internal/mail" "server/internal/models" "github.com/google/uuid" ) type AuthController struct { authService *auth.Service mailService *mail.Service } // Typescript: interface type SimpleResponse struct { Message string `json:"message"` } func NewAuthController(authService *auth.Service, mailService *mail.Service) *AuthController { return &AuthController{ authService: authService, mailService: mailService, } } // Typescript: interface type LoginRequest struct { Username string `json:"username" validate:"required,email"` Password string `json:"password" validate:"required,min=8,max=128"` } // Typescript: interface type RefreshRequest struct { RefreshToken string `json:"refresh_token"` } // Typescript: interface type ForgotPasswordRequest struct { Email string `json:"email" validate:"required,email"` } // Typescript: interface type ResetPasswordRequest struct { Token string `json:"token" validate:"required,min=20,max=255"` Password string `json:"password" validate:"required,min=8,max=128"` } // Login authenticates a user and issues an access/refresh token pair. func (ac *AuthController) 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 := validateStruct(&req); err != nil { return err } db, err := dbFromCtx(c) if err != nil { return err } var user models.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 := auth.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") } tokens, err := ac.authService.GenerateTokenPair(user.Email) 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(&models.Session{}).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to purge expired sessions") } session := models.Session{ UserID: &userID, Username: user.Email, AccessTokenHash: hashToken(tokens.AccessToken), RefreshTokenHash: hashToken(tokens.RefreshToken), ExpiresAt: now.Add(ac.authService.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.Set("Auth-Token", tokens.AccessToken) c.Response().Header.Set("Auth-Token", tokens.AccessToken) return c.JSON(success(tokens)) } // Refresh renews an access/refresh token pair using a valid refresh token. func (ac *AuthController) 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") } tokens, err := ac.authService.Refresh(req.RefreshToken) if err != nil { return fiber.NewError(fiber.StatusUnauthorized, err.Error()) } return c.JSON(success(tokens)) } // Me returns the authenticated user's profile (short format). func (ac *AuthController) Me(c fiber.Ctx) error { claims, ok := auth.ClaimsFromCtx(c) if !ok { return fiber.NewError(fiber.StatusUnauthorized, "missing claims") } db, err := dbFromCtx(c) if err != nil { return err } var user models.User if err := db.Preload("Details").Preload("Preferences").Where("email = ?", claims.Username).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(success(models.ToUserShort(&user))) } // Register creates a new user with optional roles/types/preferences. func (ac *AuthController) Register(c fiber.Ctx) error { var req models.UserCreateInput if err := c.Bind().Body(&req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "invalid payload") } if err := validateStruct(&req); err != nil { return err } db, err := dbFromCtx(c) if err != nil { return err } var existing models.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 := auth.HashPassword(req.Password) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password") } user := models.User{ Email: req.Email, Name: req.Name, Password: hashedPassword, Roles: func() models.UserRoles { if len(req.Roles) == 0 { return models.UserRoles{"user"} } return req.Roles }(), Status: func() models.UserStatus { if req.Status == "" { return models.UserStatusPending } return req.Status }(), Types: func() models.UserTypes { if len(req.Types) == 0 { return models.UserTypes{"internal"} } return req.Types }(), Avatar: req.Avatar, UUID: uuid.NewString(), Details: toUserDetails(req.Details), Preferences: func() *models.UserPreferences { if req.Preferences == nil { return nil } return &models.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") } if err := ac.mailService.Send(c, mail.Message{ To: user.Email, Subject: fmt.Sprintf("[%s] Registrazione completata", ac.mailService.AppName()), Template: "registration", TemplateData: mail.TemplateData{ AppName: ac.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(success(models.ToUserShort(&user))) } func (ac *AuthController) 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 := validateStruct(&req); err != nil { return err } db, err := dbFromCtx(c) if err != nil { return err } var user models.User if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return c.JSON(success(fiber.Map{"sent": true})) } return fiber.NewError(fiber.StatusInternalServerError, "failed to load user") } if user.Status == models.UserStatusDisabled { return c.JSON(success(fiber.Map{"sent": true})) } resetToken, err := generateSecureToken() if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to generate reset token") } now := time.Now().UTC() record := models.PasswordResetToken{ UserID: user.ID, TokenHash: 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(&models.PasswordResetToken{}).Error; err != nil { return err } return tx.Create(&record).Error }); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to store reset token") } if err := ac.mailService.Send(c, mail.Message{ To: user.Email, Subject: fmt.Sprintf("[%s] Recupero password", ac.mailService.AppName()), Template: "password_reset", TemplateData: mail.TemplateData{ AppName: ac.mailService.AppName(), UserName: user.Name, UserEmail: user.Email, ResetToken: resetToken, ResetURL: ac.mailService.ResetLink(resetToken), }, }); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to send reset email") } return c.JSON(success(SimpleResponse{Message: "password reset email sent"})) } func (ac *AuthController) 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 := validateStruct(&req); err != nil { return err } db, err := dbFromCtx(c) if err != nil { return err } hashedPassword, err := auth.HashPassword(req.Password) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password") } now := time.Now().UTC() tokenHash := hashToken(req.Token) if err := db.Transaction(func(tx *gorm.DB) error { var resetToken models.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(&models.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(&models.Session{}).Error; err != nil { return err } if err := tx.Where("user_id = ? AND id <> ?", resetToken.UserID, resetToken.ID).Delete(&models.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(success(SimpleResponse{Message: "password reset successful"})) } func (ac *AuthController) ValidToken(c fiber.Ctx) error { raw := strings.TrimSpace(string(c.Body())) if raw == "" { return fiber.NewError(fiber.StatusBadRequest, "token is required") } // Accept both plain text token payload and JSON string payload. 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 := dbFromCtx(c) if err != nil { return err } now := time.Now().UTC() tokenHash := hashToken(token) var resetToken models.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(success(SimpleResponse{Message: "valid reset token"})) } func generateSecureToken() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(buf), nil }