291 lines
6.8 KiB
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))
|
|
}
|