Initial commit: Homelab API
This commit is contained in:
292
internal/service/auth.go
Normal file
292
internal/service/auth.go
Normal file
@@ -0,0 +1,292 @@
|
||||
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
|
||||
}
|
||||
|
||||
// Optional: require email verification
|
||||
// if !user.EmailVerified {
|
||||
// return nil, ErrEmailNotVerified
|
||||
// }
|
||||
|
||||
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) {
|
||||
return s.userRepo.GetByID(userID)
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
user.Username = req.Username
|
||||
}
|
||||
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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", 30*24*time.Hour)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user