prompt 6
This commit is contained in:
parent
722dd85fc6
commit
036aadb09a
|
|
@ -0,0 +1,27 @@
|
|||
Implementa AUTH completo (server-rendered) e templates in /web/templates/public.
|
||||
|
||||
Routes:
|
||||
- GET/POST /signup
|
||||
- GET/POST /login
|
||||
- POST /logout
|
||||
- GET /verify-email?token=...
|
||||
- GET/POST /forgot-password
|
||||
- GET/POST /reset-password?token=...
|
||||
|
||||
Comportamento:
|
||||
- Signup crea user (role=user, verified=false), genera verify token (hash in DB), invia email (mailer).
|
||||
- Login: blocca se email non verificata.
|
||||
- Verify-email: valida token, set EmailVerified=true, elimina token.
|
||||
- Forgot-password: risposta sempre generica; se user esiste+verified, genera reset token e invia email.
|
||||
- Reset-password: valida token, aggiorna password, elimina token.
|
||||
|
||||
Crea templates:
|
||||
- public/login.html
|
||||
- public/signup.html
|
||||
- public/forgot_password.html
|
||||
- public/reset_password.html
|
||||
- public/verify_notice.html
|
||||
- public/home.html (opzionale)
|
||||
Aggiungi partial per flash (public/_flash.html) e includilo nel layout.
|
||||
|
||||
Usa repo/service per accesso DB e logica (non tutto nel controller).
|
||||
|
|
@ -51,7 +51,9 @@ func NewApp(cfg *config.Config) (*fiber.App, error) {
|
|||
}
|
||||
}
|
||||
|
||||
apphttp.RegisterRoutes(app, store, database, cfg)
|
||||
if err := apphttp.RegisterRoutes(app, store, database, cfg); err != nil {
|
||||
return nil, fmt.Errorf("register routes: %w", err)
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,196 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
httpmw "trustcontact/internal/http/middleware"
|
||||
"trustcontact/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type AuthController struct {
|
||||
authService *services.AuthService
|
||||
}
|
||||
|
||||
func NewAuthController(authService *services.AuthService) *AuthController {
|
||||
return &AuthController{authService: authService}
|
||||
}
|
||||
|
||||
func (ac *AuthController) ShowHome(c *fiber.Ctx) error {
|
||||
return renderPublic(c, "home.html", map[string]any{
|
||||
"Title": "Home",
|
||||
"NavSection": "public",
|
||||
})
|
||||
}
|
||||
|
||||
func (ac *AuthController) ShowSignup(c *fiber.Ctx) error {
|
||||
return renderPublic(c, "signup.html", map[string]any{
|
||||
"Title": "Sign up",
|
||||
"NavSection": "public",
|
||||
})
|
||||
}
|
||||
|
||||
func (ac *AuthController) Signup(c *fiber.Ctx) error {
|
||||
email := strings.TrimSpace(c.FormValue("email"))
|
||||
password := c.FormValue("password")
|
||||
|
||||
if err := ac.authService.Signup(c.UserContext(), email, password); err != nil {
|
||||
if errors.Is(err, services.ErrEmailAlreadyExists) {
|
||||
httpmw.SetTemplateData(c, "FlashError", "Email gia registrata")
|
||||
} else {
|
||||
httpmw.SetTemplateData(c, "FlashError", "Impossibile completare la registrazione")
|
||||
}
|
||||
return renderPublic(c, "signup.html", map[string]any{
|
||||
"Title": "Sign up",
|
||||
"NavSection": "public",
|
||||
"Email": email,
|
||||
})
|
||||
}
|
||||
|
||||
if err := httpmw.SetFlashSuccess(c, "Registrazione completata. Controlla la tua email per verificare l'account."); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Redirect("/verify-notice")
|
||||
}
|
||||
|
||||
func (ac *AuthController) ShowLogin(c *fiber.Ctx) error {
|
||||
return renderPublic(c, "login.html", map[string]any{
|
||||
"Title": "Login",
|
||||
"NavSection": "public",
|
||||
})
|
||||
}
|
||||
|
||||
func (ac *AuthController) Login(c *fiber.Ctx) error {
|
||||
email := strings.TrimSpace(c.FormValue("email"))
|
||||
password := c.FormValue("password")
|
||||
|
||||
user, err := ac.authService.Login(email, password)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrEmailNotVerified):
|
||||
httpmw.SetTemplateData(c, "FlashError", "Email non verificata. Controlla la posta.")
|
||||
case errors.Is(err, services.ErrInvalidCredentials):
|
||||
httpmw.SetTemplateData(c, "FlashError", "Credenziali non valide")
|
||||
default:
|
||||
httpmw.SetTemplateData(c, "FlashError", "Errore durante il login")
|
||||
}
|
||||
|
||||
return renderPublic(c, "login.html", map[string]any{
|
||||
"Title": "Login",
|
||||
"NavSection": "public",
|
||||
"Email": email,
|
||||
})
|
||||
}
|
||||
|
||||
if err := httpmw.SetSessionUserID(c, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := httpmw.SetFlashSuccess(c, "Login effettuato"); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Redirect("/private")
|
||||
}
|
||||
|
||||
func (ac *AuthController) Logout(c *fiber.Ctx) error {
|
||||
if err := httpmw.ClearSessionUser(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := httpmw.SetFlashSuccess(c, "Logout effettuato"); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Redirect("/login")
|
||||
}
|
||||
|
||||
func (ac *AuthController) VerifyEmail(c *fiber.Ctx) error {
|
||||
token := strings.TrimSpace(c.Query("token"))
|
||||
if token == "" {
|
||||
httpmw.SetTemplateData(c, "FlashError", "Token non valido")
|
||||
return renderPublic(c, "verify_notice.html", map[string]any{
|
||||
"Title": "Verifica email",
|
||||
"NavSection": "public",
|
||||
})
|
||||
}
|
||||
|
||||
if err := ac.authService.VerifyEmail(token); err != nil {
|
||||
httpmw.SetTemplateData(c, "FlashError", "Token non valido o scaduto")
|
||||
return renderPublic(c, "verify_notice.html", map[string]any{
|
||||
"Title": "Verifica email",
|
||||
"NavSection": "public",
|
||||
})
|
||||
}
|
||||
|
||||
if err := httpmw.SetFlashSuccess(c, "Email verificata. Ora puoi accedere."); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Redirect("/login")
|
||||
}
|
||||
|
||||
func (ac *AuthController) ShowVerifyNotice(c *fiber.Ctx) error {
|
||||
return renderPublic(c, "verify_notice.html", map[string]any{
|
||||
"Title": "Verifica email",
|
||||
"NavSection": "public",
|
||||
})
|
||||
}
|
||||
|
||||
func (ac *AuthController) ShowForgotPassword(c *fiber.Ctx) error {
|
||||
return renderPublic(c, "forgot_password.html", map[string]any{
|
||||
"Title": "Forgot password",
|
||||
"NavSection": "public",
|
||||
})
|
||||
}
|
||||
|
||||
func (ac *AuthController) ForgotPassword(c *fiber.Ctx) error {
|
||||
email := strings.TrimSpace(c.FormValue("email"))
|
||||
if err := ac.authService.ForgotPassword(c.UserContext(), email); err != nil {
|
||||
httpmw.SetTemplateData(c, "FlashError", "Impossibile elaborare la richiesta")
|
||||
return renderPublic(c, "forgot_password.html", map[string]any{
|
||||
"Title": "Forgot password",
|
||||
"NavSection": "public",
|
||||
"Email": email,
|
||||
})
|
||||
}
|
||||
|
||||
httpmw.SetTemplateData(c, "FlashSuccess", "Se l'account esiste, riceverai una email con le istruzioni.")
|
||||
return renderPublic(c, "forgot_password.html", map[string]any{
|
||||
"Title": "Forgot password",
|
||||
"NavSection": "public",
|
||||
})
|
||||
}
|
||||
|
||||
func (ac *AuthController) ShowResetPassword(c *fiber.Ctx) error {
|
||||
token := strings.TrimSpace(c.Query("token"))
|
||||
return renderPublic(c, "reset_password.html", map[string]any{
|
||||
"Title": "Reset password",
|
||||
"NavSection": "public",
|
||||
"Token": token,
|
||||
})
|
||||
}
|
||||
|
||||
func (ac *AuthController) ResetPassword(c *fiber.Ctx) error {
|
||||
token := strings.TrimSpace(c.Query("token"))
|
||||
password := c.FormValue("password")
|
||||
|
||||
if token == "" {
|
||||
httpmw.SetTemplateData(c, "FlashError", "Token non valido")
|
||||
return renderPublic(c, "reset_password.html", map[string]any{
|
||||
"Title": "Reset password",
|
||||
"NavSection": "public",
|
||||
})
|
||||
}
|
||||
|
||||
if err := ac.authService.ResetPassword(token, password); err != nil {
|
||||
httpmw.SetTemplateData(c, "FlashError", "Token non valido o scaduto")
|
||||
return renderPublic(c, "reset_password.html", map[string]any{
|
||||
"Title": "Reset password",
|
||||
"NavSection": "public",
|
||||
"Token": token,
|
||||
})
|
||||
}
|
||||
|
||||
if err := httpmw.SetFlashSuccess(c, "Password aggiornata. Effettua il login."); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Redirect("/login")
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func renderPublic(c *fiber.Ctx, page string, data map[string]any) error {
|
||||
viewData := map[string]any{}
|
||||
for k, v := range localsTemplateData(c) {
|
||||
viewData[k] = v
|
||||
}
|
||||
for k, v := range data {
|
||||
viewData[k] = v
|
||||
}
|
||||
|
||||
if _, ok := viewData["Title"]; !ok {
|
||||
viewData["Title"] = "Trustcontact"
|
||||
}
|
||||
if _, ok := viewData["NavSection"]; !ok {
|
||||
viewData["NavSection"] = "public"
|
||||
}
|
||||
|
||||
files := []string{
|
||||
"web/templates/layout.html",
|
||||
"web/templates/public/_flash.html",
|
||||
filepath.Join("web/templates/public", page),
|
||||
}
|
||||
|
||||
tmpl, err := template.ParseFiles(files...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&out, "layout.html", viewData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Type("html", "utf-8")
|
||||
return c.Send(out.Bytes())
|
||||
}
|
||||
|
||||
func localsTemplateData(c *fiber.Ctx) map[string]any {
|
||||
data, ok := c.Locals("template_data").(map[string]any)
|
||||
if !ok || data == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
|
@ -1,46 +1,54 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"trustcontact/internal/config"
|
||||
"trustcontact/internal/controllers"
|
||||
httpmw "trustcontact/internal/http/middleware"
|
||||
"trustcontact/internal/services"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/session"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterRoutes(app *fiber.App, store *session.Store, database *gorm.DB, _ *config.Config) {
|
||||
func RegisterRoutes(app *fiber.App, store *session.Store, database *gorm.DB, cfg *config.Config) error {
|
||||
app.Use(httpmw.SessionStoreMiddleware(store))
|
||||
app.Use(httpmw.CurrentUserMiddleware(store, database))
|
||||
app.Use(httpmw.ConsumeFlash())
|
||||
|
||||
authService, err := services.NewAuthService(database, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init auth service: %w", err)
|
||||
}
|
||||
authController := controllers.NewAuthController(authService)
|
||||
|
||||
app.Get("/healthz", func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
c.Locals("nav_section", "public")
|
||||
httpmw.SetTemplateData(c, "NavSection", "public")
|
||||
return c.SendString("public area")
|
||||
})
|
||||
|
||||
app.Get("/login", func(c *fiber.Ctx) error {
|
||||
c.Locals("nav_section", "public")
|
||||
httpmw.SetTemplateData(c, "NavSection", "public")
|
||||
return c.SendString("login page")
|
||||
})
|
||||
app.Get("/", authController.ShowHome)
|
||||
app.Get("/signup", authController.ShowSignup)
|
||||
app.Post("/signup", authController.Signup)
|
||||
app.Get("/login", authController.ShowLogin)
|
||||
app.Post("/login", authController.Login)
|
||||
app.Post("/logout", authController.Logout)
|
||||
app.Get("/verify-email", authController.VerifyEmail)
|
||||
app.Get("/verify-notice", authController.ShowVerifyNotice)
|
||||
app.Get("/forgot-password", authController.ShowForgotPassword)
|
||||
app.Post("/forgot-password", authController.ForgotPassword)
|
||||
app.Get("/reset-password", authController.ShowResetPassword)
|
||||
app.Post("/reset-password", authController.ResetPassword)
|
||||
|
||||
private := app.Group("/private", httpmw.RequireAuth())
|
||||
private.Get("/", func(c *fiber.Ctx) error {
|
||||
c.Locals("nav_section", "private")
|
||||
httpmw.SetTemplateData(c, "NavSection", "private")
|
||||
return c.SendString("private area")
|
||||
})
|
||||
|
||||
admin := app.Group("/admin", httpmw.RequireAuth(), httpmw.RequireAdmin())
|
||||
admin.Get("/", func(c *fiber.Ctx) error {
|
||||
c.Locals("nav_section", "admin")
|
||||
httpmw.SetTemplateData(c, "NavSection", "admin")
|
||||
return c.SendString("admin area")
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"trustcontact/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type EmailVerificationTokenRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewEmailVerificationTokenRepo(db *gorm.DB) *EmailVerificationTokenRepo {
|
||||
return &EmailVerificationTokenRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *EmailVerificationTokenRepo) Create(token *models.EmailVerificationToken) error {
|
||||
return r.db.Create(token).Error
|
||||
}
|
||||
|
||||
func (r *EmailVerificationTokenRepo) FindValidByHash(tokenHash string, now time.Time) (*models.EmailVerificationToken, error) {
|
||||
var token models.EmailVerificationToken
|
||||
err := r.db.Where("token_hash = ? AND expires_at > ?", tokenHash, now).First(&token).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func (r *EmailVerificationTokenRepo) DeleteByID(id uint) error {
|
||||
return r.db.Delete(&models.EmailVerificationToken{}, id).Error
|
||||
}
|
||||
|
||||
func (r *EmailVerificationTokenRepo) DeleteByUserID(userID uint) error {
|
||||
return r.db.Where("user_id = ?", userID).Delete(&models.EmailVerificationToken{}).Error
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"trustcontact/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PasswordResetTokenRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPasswordResetTokenRepo(db *gorm.DB) *PasswordResetTokenRepo {
|
||||
return &PasswordResetTokenRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *PasswordResetTokenRepo) Create(token *models.PasswordResetToken) error {
|
||||
return r.db.Create(token).Error
|
||||
}
|
||||
|
||||
func (r *PasswordResetTokenRepo) FindValidByHash(tokenHash string, now time.Time) (*models.PasswordResetToken, error) {
|
||||
var token models.PasswordResetToken
|
||||
err := r.db.Where("token_hash = ? AND expires_at > ?", tokenHash, now).First(&token).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func (r *PasswordResetTokenRepo) DeleteByID(id uint) error {
|
||||
return r.db.Delete(&models.PasswordResetToken{}, id).Error
|
||||
}
|
||||
|
||||
func (r *PasswordResetTokenRepo) DeleteByUserID(userID uint) error {
|
||||
return r.db.Where("user_id = ?", userID).Delete(&models.PasswordResetToken{}).Error
|
||||
}
|
||||
|
|
@ -27,3 +27,31 @@ func (r *UserRepo) FindByID(id uint) (*models.User, error) {
|
|||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) FindByEmail(email string) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := r.db.Where("email = ?", email).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) Create(user *models.User) error {
|
||||
return r.db.Create(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepo) SetEmailVerified(userID uint, verified bool) error {
|
||||
return r.db.Model(&models.User{}).
|
||||
Where("id = ?", userID).
|
||||
Update("email_verified", verified).Error
|
||||
}
|
||||
|
||||
func (r *UserRepo) UpdatePasswordHash(userID uint, passwordHash string) error {
|
||||
return r.db.Model(&models.User{}).
|
||||
Where("id = ?", userID).
|
||||
Update("password_hash", passwordHash).Error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,247 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"trustcontact/internal/auth"
|
||||
"trustcontact/internal/config"
|
||||
"trustcontact/internal/mailer"
|
||||
"trustcontact/internal/models"
|
||||
"trustcontact/internal/repo"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmailAlreadyExists = errors.New("email already exists")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrEmailNotVerified = errors.New("email not verified")
|
||||
ErrInvalidOrExpiredToken = errors.New("invalid or expired token")
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
cfg *config.Config
|
||||
users *repo.UserRepo
|
||||
verifyTokens *repo.EmailVerificationTokenRepo
|
||||
resetTokens *repo.PasswordResetTokenRepo
|
||||
mailer mailer.Mailer
|
||||
templateRender *mailer.TemplateRenderer
|
||||
nowFn func() time.Time
|
||||
}
|
||||
|
||||
func NewAuthService(database *gorm.DB, cfg *config.Config) (*AuthService, error) {
|
||||
sender, err := mailer.NewMailer(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AuthService{
|
||||
cfg: cfg,
|
||||
users: repo.NewUserRepo(database),
|
||||
verifyTokens: repo.NewEmailVerificationTokenRepo(database),
|
||||
resetTokens: repo.NewPasswordResetTokenRepo(database),
|
||||
mailer: sender,
|
||||
templateRender: mailer.NewTemplateRenderer(""),
|
||||
nowFn: func() time.Time {
|
||||
return time.Now().UTC()
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Signup(ctx context.Context, email, password string) error {
|
||||
email = normalizeEmail(email)
|
||||
if email == "" || strings.TrimSpace(password) == "" {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
existing, err := s.users.FindByEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
return ErrEmailAlreadyExists
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
EmailVerified: false,
|
||||
Role: models.RoleUser,
|
||||
}
|
||||
if err := s.users.Create(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.issueVerifyEmail(ctx, user)
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(email, password string) (*models.User, error) {
|
||||
email = normalizeEmail(email)
|
||||
user, err := s.users.FindByEmail(email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
ok, err := auth.ComparePassword(user.PasswordHash, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if !user.EmailVerified {
|
||||
return nil, ErrEmailNotVerified
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) VerifyEmail(token string) error {
|
||||
hash := auth.HashToken(token)
|
||||
record, err := s.verifyTokens.FindValidByHash(hash, s.nowFn())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if record == nil {
|
||||
return ErrInvalidOrExpiredToken
|
||||
}
|
||||
|
||||
if err := s.users.SetEmailVerified(record.UserID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.verifyTokens.DeleteByID(record.ID)
|
||||
}
|
||||
|
||||
func (s *AuthService) ForgotPassword(ctx context.Context, email string) error {
|
||||
email = normalizeEmail(email)
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := s.users.FindByEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil || !user.EmailVerified {
|
||||
return nil
|
||||
}
|
||||
|
||||
plainToken, err := auth.NewToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.resetTokens.DeleteByUserID(user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := &models.PasswordResetToken{
|
||||
UserID: user.ID,
|
||||
TokenHash: auth.HashToken(plainToken),
|
||||
ExpiresAt: auth.ResetTokenExpiresAt(s.nowFn()),
|
||||
}
|
||||
if err := s.resetTokens.Create(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.sendResetEmail(ctx, user, plainToken)
|
||||
}
|
||||
|
||||
func (s *AuthService) ResetPassword(token, newPassword string) error {
|
||||
if strings.TrimSpace(newPassword) == "" {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
hash := auth.HashToken(token)
|
||||
record, err := s.resetTokens.FindValidByHash(hash, s.nowFn())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if record == nil {
|
||||
return ErrInvalidOrExpiredToken
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.users.UpdatePasswordHash(record.UserID, passwordHash); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.resetTokens.DeleteByID(record.ID)
|
||||
}
|
||||
|
||||
func (s *AuthService) issueVerifyEmail(ctx context.Context, user *models.User) error {
|
||||
plainToken, err := auth.NewToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.verifyTokens.DeleteByUserID(user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := &models.EmailVerificationToken{
|
||||
UserID: user.ID,
|
||||
TokenHash: auth.HashToken(plainToken),
|
||||
ExpiresAt: auth.VerifyTokenExpiresAt(s.nowFn()),
|
||||
}
|
||||
if err := s.verifyTokens.Create(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
verifyURL := strings.TrimRight(s.cfg.BaseURL, "/") + "/verify-email?token=" + url.QueryEscape(plainToken)
|
||||
htmlBody, textBody, err := s.templateRender.RenderVerifyEmail(mailer.TemplateData{
|
||||
AppName: s.cfg.AppName,
|
||||
BaseURL: s.cfg.BaseURL,
|
||||
VerifyURL: verifyURL,
|
||||
UserEmail: user.Email,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("render verify email: %w", err)
|
||||
}
|
||||
|
||||
if err := s.mailer.Send(ctx, user.Email, "Verify your email", htmlBody, textBody); err != nil {
|
||||
return fmt.Errorf("send verify email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) sendResetEmail(ctx context.Context, user *models.User, plainToken string) error {
|
||||
resetURL := strings.TrimRight(s.cfg.BaseURL, "/") + "/reset-password?token=" + url.QueryEscape(plainToken)
|
||||
htmlBody, textBody, err := s.templateRender.RenderResetPassword(mailer.TemplateData{
|
||||
AppName: s.cfg.AppName,
|
||||
BaseURL: s.cfg.BaseURL,
|
||||
ResetURL: resetURL,
|
||||
UserEmail: user.Email,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("render reset email: %w", err)
|
||||
}
|
||||
|
||||
if err := s.mailer.Send(ctx, user.Email, "Reset your password", htmlBody, textBody); err != nil {
|
||||
return fmt.Errorf("send reset email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
|
@ -1,42 +1,40 @@
|
|||
{{- $flashSuccess := index . "FlashSuccess" -}}
|
||||
{{- $flashError := index . "FlashError" -}}
|
||||
{{- $nav := index . "NavSection" -}}
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{index . "Title"}}</title>
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f7fb; color: #1f2937; }
|
||||
nav { background: #111827; color: #fff; padding: 12px 16px; display: flex; gap: 12px; }
|
||||
nav a { color: #e5e7eb; text-decoration: none; }
|
||||
nav a.active { color: #fff; font-weight: 600; }
|
||||
.container { max-width: 960px; margin: 20px auto; padding: 0 16px; }
|
||||
.flash { padding: 12px 14px; border-radius: 8px; margin-bottom: 12px; }
|
||||
.flash.success { background: #dcfce7; color: #166534; }
|
||||
.flash.error { background: #fee2e2; color: #991b1b; }
|
||||
main { background: #fff; border-radius: 10px; padding: 20px; }
|
||||
.container { max-width: 920px; margin: 20px auto; padding: 0 16px; }
|
||||
.card { background: #fff; border-radius: 10px; padding: 20px; }
|
||||
form { display: grid; gap: 10px; max-width: 420px; }
|
||||
input { padding: 10px; border: 1px solid #d1d5db; border-radius: 8px; }
|
||||
button { padding: 10px 14px; border: 0; border-radius: 8px; background: #111827; color: #fff; cursor: pointer; }
|
||||
.muted { color: #6b7280; font-size: 0.95rem; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/" class="{{if eq $nav "public"}}active{{end}}">Public</a>
|
||||
<a href="/private" class="{{if eq $nav "private"}}active{{end}}">Private</a>
|
||||
<a href="/admin" class="{{if eq $nav "admin"}}active{{end}}">Admin</a>
|
||||
<a href="/" class="{{if eq .NavSection "public"}}active{{end}}">Public</a>
|
||||
<a href="/private" class="{{if eq .NavSection "private"}}active{{end}}">Private</a>
|
||||
<a href="/admin" class="{{if eq .NavSection "admin"}}active{{end}}">Admin</a>
|
||||
{{if .CurrentUser}}
|
||||
<form action="/logout" method="post" style="margin-left:auto;">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
{{if $flashSuccess}}
|
||||
<div class="flash success">{{$flashSuccess}}</div>
|
||||
{{end}}
|
||||
{{if $flashError}}
|
||||
<div class="flash error">{{$flashError}}</div>
|
||||
{{end}}
|
||||
|
||||
<main>
|
||||
{{index . "Content"}}
|
||||
</main>
|
||||
{{template "_flash.html" .}}
|
||||
<div class="card">
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
{{if .FlashSuccess}}
|
||||
<div style="background:#dcfce7;color:#166534;padding:12px;border-radius:8px;margin:0 0 12px;">{{.FlashSuccess}}</div>
|
||||
{{end}}
|
||||
{{if .FlashError}}
|
||||
<div style="background:#fee2e2;color:#991b1b;padding:12px;border-radius:8px;margin:0 0 12px;">{{.FlashError}}</div>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{{define "content"}}
|
||||
<h1>Password dimenticata</h1>
|
||||
<p class="muted">Inserisci la tua email. Se l'account esiste e risulta verificato, invieremo un link di reset.</p>
|
||||
<form action="/forgot-password" method="post">
|
||||
<label>Email</label>
|
||||
<input type="email" name="email" value="{{.Email}}" required>
|
||||
<button type="submit">Invia link reset</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{{define "content"}}
|
||||
<h1>Trustcontact</h1>
|
||||
<p class="muted">Boilerplate GoFiber + HTMX + Svelte CE + GORM.</p>
|
||||
<div class="row">
|
||||
<a href="/signup">Crea account</a>
|
||||
<a href="/login">Accedi</a>
|
||||
<a href="/forgot-password">Password dimenticata</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{{define "content"}}
|
||||
<h1>Login</h1>
|
||||
<form action="/login" method="post">
|
||||
<label>Email</label>
|
||||
<input type="email" name="email" value="{{.Email}}" required>
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required>
|
||||
<button type="submit">Accedi</button>
|
||||
</form>
|
||||
<p class="muted">Non hai un account? <a href="/signup">Registrati</a></p>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{{define "content"}}
|
||||
<h1>Reset password</h1>
|
||||
{{if .Token}}
|
||||
<form action="/reset-password?token={{.Token}}" method="post">
|
||||
<label>Nuova password</label>
|
||||
<input type="password" name="password" required>
|
||||
<button type="submit">Aggiorna password</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="muted">Token mancante o non valido.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{{define "content"}}
|
||||
<h1>Sign up</h1>
|
||||
<form action="/signup" method="post">
|
||||
<label>Email</label>
|
||||
<input type="email" name="email" value="{{.Email}}" required>
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required>
|
||||
<button type="submit">Crea account</button>
|
||||
</form>
|
||||
<p class="muted">Hai già un account? <a href="/login">Accedi</a></p>
|
||||
{{end}}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{{define "content"}}
|
||||
<h1>Verifica email</h1>
|
||||
<p class="muted">Controlla la casella di posta e apri il link di verifica ricevuto.</p>
|
||||
<p class="muted">Se il link è scaduto, ripeti la registrazione o contatta supporto.</p>
|
||||
<p><a href="/login">Vai al login</a></p>
|
||||
{{end}}
|
||||
Loading…
Reference in New Issue