diff --git a/main.go b/main.go index b2b7441..b2fda94 100755 --- a/main.go +++ b/main.go @@ -71,6 +71,8 @@ func main() { // Partner &models.PartnerRecurso{}, &models.PartnerComunicado{}, + // Portal: recuperación de contraseña + &models.PortalPasswordResetToken{}, ) // Seed automático (idempotente) de módulos del sistema migrations.SeedRenovaciones() diff --git a/pkg/models/portal_user.go b/pkg/models/portal_user.go index bdf51fc..90f3892 100644 --- a/pkg/models/portal_user.go +++ b/pkg/models/portal_user.go @@ -1,7 +1,10 @@ package models import ( + "crypto/rand" + "encoding/hex" "errors" + "time" "github.com/sujit-baniya/fiber-boilerplate/app" "gorm.io/gorm" @@ -196,6 +199,58 @@ func GetClienteIDsForPortalUser(u *PortalUser) []uint { return []uint{} } +// ─── PortalPasswordResetToken ──────────────────────────────────────────────── + +type PortalPasswordResetToken struct { + ID uint `gorm:"primaryKey;autoIncrement"` + PortalUserID uint `gorm:"column:portal_user_id;index;not null"` + Token string `gorm:"column:token;uniqueIndex;not null"` + ExpiresAt time.Time `gorm:"column:expires_at;not null"` + Used bool `gorm:"column:used;default:false"` + CreatedAt time.Time +} + +func (PortalPasswordResetToken) TableName() string { return "portal_password_reset_tokens" } + +// CreatePortalResetToken genera un token aleatorio seguro de 32 bytes, lo persiste y lo retorna. +func CreatePortalResetToken(portalUserID uint) (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + token := hex.EncodeToString(b) + record := &PortalPasswordResetToken{ + PortalUserID: portalUserID, + Token: token, + ExpiresAt: time.Now().Add(1 * time.Hour), + } + if err := app.Http.Database.DB.Create(record).Error; err != nil { + return "", err + } + return token, nil +} + +// GetValidPortalResetToken busca el token, verifica que no esté usado ni vencido. +func GetValidPortalResetToken(token string) (*PortalPasswordResetToken, error) { + var record PortalPasswordResetToken + err := app.Http.Database.DB.Where("token = ?", token).First(&record).Error + if err != nil { + return nil, errors.New("token inválido") + } + if record.Used { + return nil, errors.New("este enlace ya fue utilizado") + } + if time.Now().After(record.ExpiresAt) { + return nil, errors.New("el enlace ha expirado, solicita uno nuevo") + } + return &record, nil +} + +// MarkPortalResetTokenUsed marca el token como utilizado. +func MarkPortalResetTokenUsed(id uint) error { + return app.Http.Database.DB.Model(&PortalPasswordResetToken{}).Where("id = ?", id).Update("used", true).Error +} + // CheckPortalLogin valida credenciales y retorna el portal user. func CheckPortalLogin(email, password string) (*PortalUser, error) { u, err := GetPortalUserByEmail(email) diff --git a/pkg/services/mail_service.go b/pkg/services/mail_service.go index 50f37e3..b165d9e 100755 --- a/pkg/services/mail_service.go +++ b/pkg/services/mail_service.go @@ -78,6 +78,31 @@ func GeneratePasswordResetURL(email string, baseURL string) string { return uri } +// SendPortalPasswordResetEmail envía al usuario del portal un enlace para restablecer su contraseña. +func SendPortalPasswordResetEmail(email, nombre, resetLink string) { + htmlBody := fmt.Sprintf(` + +
+
+
U
+

Restablecer contraseña

+

Portal de Clientes · U-site

+
+

Hola %s,

+

Recibimos una solicitud para restablecer la contraseña de tu cuenta. Haz clic en el siguiente botón para continuar:

+ Restablecer contraseña +

Este enlace es válido por 1 hora. Si no solicitaste este cambio, puedes ignorar este mensaje.

+

© 2025 U-site — Todos los derechos reservados

+
+`, nombre, resetLink) + + go func() { + if err := app.Http.Mail.Send(email, "Restablece tu contraseña - Portal U-site", htmlBody, ""); err != nil { + log.Printf("[Portal] Error enviando correo de reseteo a %s: %v", email, err) + } + }() +} + // SendPortalCredentialsEmail envía al usuario del portal su URL de acceso y contraseña inicial. func SendPortalCredentialsEmail(email, nombre, password string) { loginURL := absAppURL("/portal/login") diff --git a/resources/views/portal/forgot_password.html b/resources/views/portal/forgot_password.html new file mode 100644 index 0000000..4a83ed8 --- /dev/null +++ b/resources/views/portal/forgot_password.html @@ -0,0 +1,48 @@ + +
+
+ U +

¿Olvidaste tu contraseña?

+

Te enviaremos un enlace para restablecerla

+
+ + {{ if .error }} +
+ {{ .error }} +
+ {{ end }} + + {{ if .success }} +
+ {{ .success }} +
+ {{ end }} + +
+
+
+
+ + +
+ +
+
+
+ +

+ ← Volver al inicio de sesión +

+ +

+ © 2025 U-site — Todos los derechos reservados +

+
diff --git a/resources/views/portal/login.html b/resources/views/portal/login.html index 10a3e9f..cd9a4b2 100644 --- a/resources/views/portal/login.html +++ b/resources/views/portal/login.html @@ -12,6 +12,12 @@ {{ end }} + {{ if .success }} +
+ {{ .success }} +
+ {{ end }} +
@@ -35,6 +41,11 @@
+
+ + ¿Olvidaste tu contraseña? + +

diff --git a/resources/views/portal/reset_password.html b/resources/views/portal/reset_password.html new file mode 100644 index 0000000..a868398 --- /dev/null +++ b/resources/views/portal/reset_password.html @@ -0,0 +1,87 @@ + +

+
+ U +

Nueva contraseña

+

Elige una contraseña segura para tu cuenta

+
+ + {{ if .error }} +
+ {{ .error }} + {{ if not .token }} + + {{ end }} +
+ {{ end }} + + {{ if .token }} +
+
+ +
+
+ + +
+
+ + + +
+ +
+
+
+ {{ else }} +
+

Este enlace no es válido o ya fue utilizado.

+ + Solicitar nuevo enlace + +
+ {{ end }} + +

+ ← Volver al inicio de sesión +

+ +

+ © 2025 U-site — Todos los derechos reservados +

+
+ + diff --git a/rest/controllers/portal_controller.go b/rest/controllers/portal_controller.go index 981c203..5198f80 100644 --- a/rest/controllers/portal_controller.go +++ b/rest/controllers/portal_controller.go @@ -25,7 +25,8 @@ func PortalLoginPage(c *fiber.Ctx) error { return c.Redirect("/portal/dashboard") } return c.Render("portal/login", fiber.Map{ - "error": c.Query("error"), + "error": c.Query("error"), + "success": c.Query("success"), }, "layouts/portal_public") } @@ -47,6 +48,92 @@ func PortalLogout(c *fiber.Ctx) error { return c.Redirect("/portal/login") } +// ─── Recuperar contraseña ──────────────────────────────────────────────────── + +func PortalForgotPasswordPage(c *fiber.Ctx) error { + return c.Render("portal/forgot_password", fiber.Map{ + "success": c.Query("success"), + "error": c.Query("error"), + }, "layouts/portal_public") +} + +func PortalForgotPasswordPost(c *fiber.Ctx) error { + email := strings.TrimSpace(c.FormValue("email")) + + // Respuesta genérica siempre para no revelar si el email existe + successMsg := "Si el correo está registrado, recibirás un enlace para restablecer tu contraseña." + + u, err := models.GetPortalUserByEmail(email) + if err == nil && u != nil && u.Activo { + token, err := models.CreatePortalResetToken(u.ID) + if err == nil { + resetLink := services.GetPublicURL() + "/portal/reset-password?t=" + token + services.SendPortalPasswordResetEmail(u.Email, u.Nombre, resetLink) + } else { + log.Printf("[Portal] Error creando token de reseteo para %s: %v", email, err) + } + } + + return c.Redirect("/portal/forgot-password?success=" + url.QueryEscape(successMsg)) +} + +func PortalResetPasswordPage(c *fiber.Ctx) error { + token := c.Query("t") + if token == "" { + return c.Redirect("/portal/login") + } + _, err := models.GetValidPortalResetToken(token) + if err != nil { + return c.Render("portal/reset_password", fiber.Map{ + "error": err.Error(), + "token": "", + }, "layouts/portal_public") + } + return c.Render("portal/reset_password", fiber.Map{ + "token": token, + "error": "", + }, "layouts/portal_public") +} + +func PortalResetPasswordPost(c *fiber.Ctx) error { + token := c.FormValue("token") + password := c.FormValue("password") + confirm := c.FormValue("confirm") + + renderError := func(msg string) error { + return c.Render("portal/reset_password", fiber.Map{ + "token": token, + "error": msg, + }, "layouts/portal_public") + } + + if token == "" || password == "" || confirm == "" { + return renderError("Todos los campos son obligatorios.") + } + if password != confirm { + return renderError("Las contraseñas no coinciden.") + } + if len(password) < 8 { + return renderError("La contraseña debe tener al menos 8 caracteres.") + } + + record, err := models.GetValidPortalResetToken(token) + if err != nil { + return renderError(err.Error()) + } + + hashed, err := app.Http.Hash.Create(password) + if err != nil { + return renderError("Error al procesar la contraseña. Intenta de nuevo.") + } + if err := models.UpdatePortalUserPassword(record.PortalUserID, hashed); err != nil { + return renderError("Error al actualizar la contraseña. Intenta de nuevo.") + } + _ = models.MarkPortalResetTokenUsed(record.ID) + + return c.Redirect("/portal/login?success=" + url.QueryEscape("Contraseña actualizada. Ya puedes ingresar.")) +} + // ─── Dashboard ──────────────────────────────────────────────────────────────── func PortalDashboard(c *fiber.Ctx) error { diff --git a/rest/routes/portal.go b/rest/routes/portal.go index 75ce866..4b2c024 100644 --- a/rest/routes/portal.go +++ b/rest/routes/portal.go @@ -11,6 +11,10 @@ func PortalRoutes(app fiber.Router) { app.Get("/portal/login", controllers.PortalLoginPage) app.Post("/portal/login", controllers.PortalLoginPost) app.Get("/portal/logout", controllers.PortalLogout) + app.Get("/portal/forgot-password", controllers.PortalForgotPasswordPage) + app.Post("/portal/forgot-password", controllers.PortalForgotPasswordPost) + app.Get("/portal/reset-password", controllers.PortalResetPasswordPage) + app.Post("/portal/reset-password", controllers.PortalResetPasswordPost) // ─── Rutas protegidas ────────────────────────────────────────────────────── portal := app.Group("/portal").Use(middlewares.PortalAuth())