diff --git a/services/auth/internal/errors/errors.go b/services/auth/internal/errors/errors.go index 077526a..a3f3749 100644 --- a/services/auth/internal/errors/errors.go +++ b/services/auth/internal/errors/errors.go @@ -3,11 +3,12 @@ package errors import "errors" var ( + ErrInvalidEmail = errors.New("invalid email") + ErrInvalidInput = errors.New("invalid input") + ErrConflict = errors.New("conflict") ErrInternal = errors.New("internal error") - ErrNotFound = errors.New("resource not found") ErrUnauthorized = errors.New("unauthorized") - ErrConflict = errors.New("resource already exists") - ErrTokenGeneration = errors.New("token generation failed ") - ErrInvalidEmail = errors.New("invalid Email") - ErrInvalidInput = errors.New("invalid input") + ErrTokenGeneration = errors.New("token generation failed") + ErrTooManyAttempts = errors.New("too many login attempts, account locked temporarily") + ErrNotFound = errors.New("not found") ) diff --git a/services/auth/internal/service/auth_service.go b/services/auth/internal/service/auth_service.go index 6752cfb..f5502f9 100644 --- a/services/auth/internal/service/auth_service.go +++ b/services/auth/internal/service/auth_service.go @@ -5,6 +5,7 @@ import ( "errors" "log/slog" "regexp" + "time" "golang.org/x/crypto/bcrypt" @@ -26,23 +27,48 @@ type authService struct { store storage.UserStorage logger *slog.Logger tokenSvc TokenService + // Add rate limiting map or store here if needed + loginAttempts map[string]int + lockoutTime map[string]time.Time + lockoutDuration time.Duration + maxAttempts int } func NewAuthService(store storage.UserStorage, logger *slog.Logger, tokenSvc TokenService) AuthService { l := logger.With("layer", "service", "component", "authService") - return &authService{store: store, logger: l, tokenSvc: tokenSvc} + return &authService{ + store: store, + logger: l, + tokenSvc: tokenSvc, + loginAttempts: make(map[string]int), + lockoutTime: make(map[string]time.Time), + lockoutDuration: 15 * time.Minute, + maxAttempts: 5, + } } func (s *authService) Register(ctx context.Context, email, password string) (*model.User, error) { s.logger.Info("Register called", slog.String("email", email)) - if !regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`).MatchString(email) { + if !regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`).MatchString(email) { s.logger.Error("Invalid email") return nil, appErr.ErrInvalidEmail } - if len(password) == 0 { - s.logger.Error("Invalid password") + if len(password) < 8 { + s.logger.Error("Password too short") + return nil, appErr.ErrInvalidInput + } + + // Enforce password complexity: at least one uppercase, one lowercase, one digit, one special char + var ( + hasUpper = regexp.MustCompile(`[A-Z]`).MatchString + hasLower = regexp.MustCompile(`[a-z]`).MatchString + hasDigit = regexp.MustCompile(`[0-9]`).MatchString + hasSpecial = regexp.MustCompile(`[\W_]`).MatchString + ) + if !hasUpper(password) || !hasLower(password) || !hasDigit(password) || !hasSpecial(password) { + s.logger.Error("Password does not meet complexity requirements") return nil, appErr.ErrInvalidInput } @@ -69,6 +95,18 @@ func (s *authService) Register(ctx context.Context, email, password string) (*mo func (s *authService) Login(ctx context.Context, email, password string) (*model.User, string, error) { s.logger.Info("Login called", slog.String("email", email)) + // Check if user is locked out + if lockoutUntil, locked := s.lockoutTime[email]; locked { + if time.Now().Before(lockoutUntil) { + s.logger.Warn("User account locked due to too many failed login attempts", slog.String("email", email)) + return nil, "", appErr.ErrTooManyAttempts + } else { + // Lockout expired, reset + delete(s.lockoutTime, email) + s.loginAttempts[email] = 0 + } + } + user, err := s.store.GetUserByEmail(ctx, email) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -82,9 +120,19 @@ func (s *authService) Login(ctx context.Context, email, password string) (*model // Compare the provided password with the stored hashed password if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + s.loginAttempts[email]++ + if s.loginAttempts[email] >= s.maxAttempts { + s.lockoutTime[email] = time.Now().Add(s.lockoutDuration) + s.logger.Warn("User account locked due to too many failed login attempts", slog.String("email", email)) + return nil, "", appErr.ErrTooManyAttempts + } s.logger.Warn("Invalid password", slog.String("email", email)) return nil, "", appErr.ErrUnauthorized } + + // Reset login attempts on successful login + s.loginAttempts[email] = 0 + token, err := s.tokenSvc.GenerateToken(user) if err != nil { s.logger.Error("Token generation failed ", slog.String("email", email)) diff --git a/services/auth/internal/service/auth_service_test.go b/services/auth/internal/service/auth_service_test.go index a7c013f..f5ff36a 100644 --- a/services/auth/internal/service/auth_service_test.go +++ b/services/auth/internal/service/auth_service_test.go @@ -52,7 +52,7 @@ func Test_authService_Register(t *testing.T) { args: args{ ctx: context.Background(), email: "test1@example.com", - password: "password123", + password: "Password@123", }, want: &model.User{ Email: "test1@example.com", @@ -74,7 +74,7 @@ func Test_authService_Register(t *testing.T) { args: args{ ctx: context.Background(), email: "test1@example.com", - password: "password123", + password: "Password@123", }, want: nil, wantErr: true, @@ -92,7 +92,7 @@ func Test_authService_Register(t *testing.T) { args: args{ ctx: context.Background(), email: "", - password: "password123", + password: "Password@123", }, want: nil, wantErr: true, @@ -110,7 +110,7 @@ func Test_authService_Register(t *testing.T) { args: args{ ctx: context.Background(), email: "test1.example.com", - password: "password123", + password: "Password@123", }, want: nil, wantErr: true, diff --git a/services/auth/internal/service/token_service.go b/services/auth/internal/service/token_service.go index feb9c60..e17b0e7 100644 --- a/services/auth/internal/service/token_service.go +++ b/services/auth/internal/service/token_service.go @@ -37,6 +37,7 @@ func (s *jwtService) GenerateToken(user *model.User) (string, error) { "email": user.Email, "exp": time.Now().Add(s.expiryTime).Unix(), "iat": time.Now().Unix(), + "nbf": time.Now().Unix(), // Not valid before now } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(s.secret)) @@ -44,6 +45,11 @@ func (s *jwtService) GenerateToken(user *model.User) (string, error) { func (s *jwtService) ValidateToken(tokenStr string) (string, string, error) { token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (any, error) { + // Validate the signing method to prevent algorithm confuses attack + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + s.logger.Error("Unexpected signing method", slog.String("method", token.Header["alg"].(string))) + return nil, jwt.ErrSignatureInvalid + } return []byte(s.secret), nil }) @@ -63,6 +69,29 @@ func (s *jwtService) ValidateToken(tokenStr string) (string, string, error) { return "", "", jwt.ErrTokenMalformed } email, ok := claims["email"].(string) + if !ok { + s.logger.Error("Invalid email claim", slog.String("email", email)) + return "", "", jwt.ErrTokenMalformed + } + + // validate time based claims + now := time.Now().Unix() + + // check expiry + if exp, ok := claims["exp"].(float64); ok { + if int64(exp) < now { + s.logger.Error("Token expired", slog.Int64("exp", int64(exp)), slog.Int64("now", now)) + return "", "", jwt.ErrTokenExpired + } + } + + // check not before + if nbf, ok := claims["nbf"].(float64); ok { + if int64(nbf) > now { + s.logger.Error("Token not valid yet", slog.Int64("nbf", int64(nbf)), slog.Int64("now", now)) + return "", "", jwt.ErrTokenNotValidYet + } + } return userID, email, nil } diff --git a/services/auth/internal/storage/user_storage.go b/services/auth/internal/storage/user_storage.go index 553be44..b050361 100644 --- a/services/auth/internal/storage/user_storage.go +++ b/services/auth/internal/storage/user_storage.go @@ -2,7 +2,6 @@ package storage import ( "context" - "fmt" "time" "github.com/jackc/pgx/v5/pgxpool" @@ -60,8 +59,6 @@ func (s *userStorage) GetUserByEmail(ctx context.Context, email string) (*model. if err := row.Scan(&user.ID, &user.Email, &user.Password, &user.CreatedAt); err != nil { return nil, err } - fmt.Println(user) - return &user, nil }