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

248 lines
5.7 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")
)
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))
}