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") ErrInvalidLanguage = errors.New("invalid language") ErrInvalidTheme = errors.New("invalid theme") ) var supportedLanguages = map[string]struct{}{ "it": {}, "en": {}, "en_us": {}, "de": {}, "fr": {}, "de_ch": {}, "fr_ch": {}, } 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) UpdateUserLanguage(userID string, lang string) error { normalized := NormalizeLanguage(lang) if !IsSupportedLanguage(normalized) { return ErrInvalidLanguage } return s.users.UpsertLanguagePreference(userID, normalized) } func (s *AuthService) UpdateUserTheme(userID string, theme string) error { normalized := NormalizeTheme(theme) if normalized != "dark" && normalized != "light" { return ErrInvalidTheme } return s.users.UpsertDarkPreference(userID, normalized == "dark") } 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)) } func NormalizeLanguage(lang string) string { normalized := strings.ToLower(strings.TrimSpace(lang)) return strings.ReplaceAll(normalized, "-", "_") } func IsSupportedLanguage(lang string) bool { _, ok := supportedLanguages[NormalizeLanguage(lang)] return ok } func NormalizeTheme(theme string) string { return strings.ToLower(strings.TrimSpace(theme)) }