package service import ( "errors" "time" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" "github.com/daniil/homelab-api/internal/model" "github.com/daniil/homelab-api/internal/repository" ) var ( ErrInvalidCredentials = errors.New("invalid credentials") ErrInvalidToken = errors.New("invalid token") ErrWeakPassword = errors.New("password must be at least 8 characters") ErrEmailNotVerified = errors.New("email not verified") ) type AuthService struct { userRepo *repository.UserRepository emailTokenRepo *repository.EmailTokenRepository emailService *EmailService jwtSecret string } func NewAuthService( userRepo *repository.UserRepository, emailTokenRepo *repository.EmailTokenRepository, emailService *EmailService, jwtSecret string, ) *AuthService { return &AuthService{ userRepo: userRepo, emailTokenRepo: emailTokenRepo, emailService: emailService, jwtSecret: jwtSecret, } } func (s *AuthService) Register(req *model.RegisterRequest) (*model.AuthResponse, error) { if len(req.Password) < 8 { return nil, ErrWeakPassword } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { return nil, err } user := &model.User{ Email: req.Email, Username: req.Username, PasswordHash: string(hash), EmailVerified: false, } if err := s.userRepo.Create(user); err != nil { return nil, err } // Send verification email if err := s.sendVerificationEmail(user); err != nil { // Log error but don't fail registration _ = err } return s.generateAuthResponse(user) } func (s *AuthService) Login(req *model.LoginRequest) (*model.AuthResponse, error) { user, err := s.userRepo.GetByEmail(req.Email) if err != nil { if errors.Is(err, repository.ErrUserNotFound) { return nil, ErrInvalidCredentials } return nil, err } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { return nil, ErrInvalidCredentials } return s.generateAuthResponse(user) } func (s *AuthService) Refresh(refreshToken string) (*model.AuthResponse, error) { claims, err := s.validateToken(refreshToken, "refresh") if err != nil { return nil, err } userID, ok := claims["user_id"].(float64) if !ok { return nil, ErrInvalidToken } user, err := s.userRepo.GetByID(int64(userID)) if err != nil { return nil, err } return s.generateAuthResponse(user) } func (s *AuthService) GetUser(userID int64) (*model.User, error) { user, err := s.userRepo.GetByID(userID) if err != nil { return nil, err } user.ProcessForJSON() return user, nil } func (s *AuthService) UpdateProfile(userID int64, req *model.UpdateProfileRequest) (*model.User, error) { user, err := s.userRepo.GetByID(userID) if err != nil { return nil, err } if req.Username != nil && *req.Username != "" { user.Username = *req.Username } if err := s.userRepo.Update(user); err != nil { return nil, err } user.ProcessForJSON() return user, nil } func (s *AuthService) ChangePassword(userID int64, req *model.ChangePasswordRequest) error { if len(req.NewPassword) < 8 { return ErrWeakPassword } user, err := s.userRepo.GetByID(userID) if err != nil { return err } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.OldPassword)); err != nil { return ErrInvalidCredentials } hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { return err } return s.userRepo.UpdatePassword(userID, string(hash)) } // Email verification func (s *AuthService) sendVerificationEmail(user *model.User) error { // Delete any existing verification tokens s.emailTokenRepo.DeleteByUserAndType(user.ID, "verification") // Create new token (24 hours) token, err := s.emailTokenRepo.Create(user.ID, "verification", 24*time.Hour) if err != nil { return err } return s.emailService.SendVerificationEmail(user.Email, user.Username, token.Token) } func (s *AuthService) VerifyEmail(req *model.VerifyEmailRequest) error { token, err := s.emailTokenRepo.Validate(req.Token, "verification") if err != nil { return err } if err := s.userRepo.SetEmailVerified(token.UserID); err != nil { return err } return s.emailTokenRepo.MarkUsed(token.ID) } func (s *AuthService) ResendVerification(req *model.ResendVerificationRequest) error { user, err := s.userRepo.GetByEmail(req.Email) if err != nil { // Don't reveal if user exists return nil } if user.EmailVerified { return nil } return s.sendVerificationEmail(user) } // Password reset func (s *AuthService) ForgotPassword(req *model.ForgotPasswordRequest) error { user, err := s.userRepo.GetByEmail(req.Email) if err != nil { // Don't reveal if user exists return nil } // Delete any existing reset tokens s.emailTokenRepo.DeleteByUserAndType(user.ID, "reset") // Create new token (1 hour) token, err := s.emailTokenRepo.Create(user.ID, "reset", 1*time.Hour) if err != nil { return err } return s.emailService.SendPasswordResetEmail(user.Email, user.Username, token.Token) } func (s *AuthService) ResetPassword(req *model.ResetPasswordRequest) error { if len(req.NewPassword) < 8 { return ErrWeakPassword } token, err := s.emailTokenRepo.Validate(req.Token, "reset") if err != nil { return err } hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { return err } if err := s.userRepo.UpdatePassword(token.UserID, string(hash)); err != nil { return err } return s.emailTokenRepo.MarkUsed(token.ID) } func (s *AuthService) generateAuthResponse(user *model.User) (*model.AuthResponse, error) { accessToken, err := s.generateToken(user.ID, "access", 15*time.Minute) if err != nil { return nil, err } refreshToken, err := s.generateToken(user.ID, "refresh", 365*24*time.Hour) if err != nil { return nil, err } user.ProcessForJSON() return &model.AuthResponse{ User: user, AccessToken: accessToken, RefreshToken: refreshToken, }, nil } func (s *AuthService) generateToken(userID int64, tokenType string, expiry time.Duration) (string, error) { claims := jwt.MapClaims{ "user_id": userID, "type": tokenType, "exp": time.Now().Add(expiry).Unix(), "iat": time.Now().Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(s.jwtSecret)) } func (s *AuthService) validateToken(tokenString, expectedType string) (jwt.MapClaims, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, ErrInvalidToken } return []byte(s.jwtSecret), nil }) if err != nil || !token.Valid { return nil, ErrInvalidToken } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return nil, ErrInvalidToken } if tokenType, ok := claims["type"].(string); !ok || tokenType != expectedType { return nil, ErrInvalidToken } return claims, nil }