backend-server-v2/internal/services/auth_service.go

291 lines
6.8 KiB
Go

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))
}