Skip to content

Commit 40fcd40

Browse files
authored
Merge pull request #10 from shibbirmcc/feature/improve-login-handler-best-practices
feat: improve login handler with security best practices
2 parents 9c48f97 + 7a906ec commit 40fcd40

File tree

2 files changed

+202
-21
lines changed

2 files changed

+202
-21
lines changed

handlers/login.go

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,100 @@
11
package handlers
22

33
import (
4+
"log"
45
"net/http"
6+
"strings"
57

68
"github.com/gin-gonic/gin"
79
"github.com/shibbirmcc/user-auth-and-permissions/models"
810
)
911

12+
// LoginResponse represents the structure of a successful login response
13+
type LoginResponse struct {
14+
Success bool `json:"success"`
15+
Message string `json:"message"`
16+
Token string `json:"token,omitempty"`
17+
}
18+
19+
// ErrorResponse represents the structure of an error response
20+
type ErrorResponse struct {
21+
Success bool `json:"success"`
22+
Message string `json:"message"`
23+
Error string `json:"error,omitempty"`
24+
}
25+
1026
func (h *UserHandler) LoginUser(c *gin.Context) {
1127
var input models.LoginRequest
1228

29+
// Bind and validate JSON input
1330
if err := c.ShouldBindJSON(&input); err != nil {
14-
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
31+
// Log the validation error for debugging (without sensitive data)
32+
log.Printf("Login validation error for email %s: %v", input.Email, err)
33+
34+
// Return user-friendly validation error
35+
errorMsg := "Invalid input data"
36+
if strings.Contains(err.Error(), "email") {
37+
errorMsg = "Please provide a valid email address"
38+
} else if strings.Contains(err.Error(), "password") {
39+
errorMsg = "Password is required"
40+
}
41+
42+
c.JSON(http.StatusBadRequest, ErrorResponse{
43+
Success: false,
44+
Message: errorMsg,
45+
Error: "validation_failed",
46+
})
47+
return
48+
}
49+
50+
// Additional input validation
51+
if strings.TrimSpace(input.Email) == "" {
52+
log.Printf("Login attempt with empty email from IP: %s", c.ClientIP())
53+
c.JSON(http.StatusBadRequest, ErrorResponse{
54+
Success: false,
55+
Message: "Email is required",
56+
Error: "missing_email",
57+
})
58+
return
59+
}
60+
61+
if strings.TrimSpace(input.Password) == "" {
62+
log.Printf("Login attempt with empty password for email: %s from IP: %s", input.Email, c.ClientIP())
63+
c.JSON(http.StatusBadRequest, ErrorResponse{
64+
Success: false,
65+
Message: "Password is required",
66+
Error: "missing_password",
67+
})
1568
return
1669
}
1770

71+
// Attempt login
1872
token, err := h.userLoginService.Login(input)
1973
if err != nil {
20-
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
74+
// Log failed login attempt for security monitoring
75+
log.Printf("Failed login attempt for email: %s from IP: %s - Error: %v", input.Email, c.ClientIP(), err)
76+
77+
// Return generic error message to prevent information disclosure
78+
c.JSON(http.StatusUnauthorized, ErrorResponse{
79+
Success: false,
80+
Message: "Invalid email or password",
81+
Error: "authentication_failed",
82+
})
2183
return
2284
}
2385

24-
c.JSON(http.StatusOK, gin.H{"token": token})
86+
// Log successful login for security monitoring
87+
log.Printf("Successful login for email: %s from IP: %s", input.Email, c.ClientIP())
88+
89+
// Set security headers
90+
c.Header("X-Content-Type-Options", "nosniff")
91+
c.Header("X-Frame-Options", "DENY")
92+
c.Header("X-XSS-Protection", "1; mode=block")
93+
94+
// Return successful response
95+
c.JSON(http.StatusOK, LoginResponse{
96+
Success: true,
97+
Message: "Login successful",
98+
Token: token,
99+
})
25100
}

handlers/login_test.go

Lines changed: 124 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ func TestLoginUser(t *testing.T) {
5454
// Call the handler
5555
handler.LoginUser(c)
5656
assert.Equal(t, http.StatusOK, w.Code)
57+
58+
// Verify response structure
59+
var response LoginResponse
60+
err := json.Unmarshal(w.Body.Bytes(), &response)
61+
assert.NoError(t, err)
62+
assert.True(t, response.Success)
63+
assert.Equal(t, "Login successful", response.Message)
64+
assert.NotEmpty(t, response.Token)
65+
66+
// Verify security headers
67+
assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
68+
assert.Equal(t, "DENY", w.Header().Get("X-Frame-Options"))
69+
assert.Equal(t, "1; mode=block", w.Header().Get("X-XSS-Protection"))
5770
})
5871

5972
t.Run("bad request with invalid JSON", func(t *testing.T) {
@@ -63,18 +76,6 @@ func TestLoginUser(t *testing.T) {
6376
mockLoginService := services.NewUserLoginService(mockDBService)
6477
handler := &UserHandler{userRegistrationService: *mockRegService, userLoginService: *mockLoginService}
6578

66-
user := &models.User{
67-
Email: mocks.TestUserEmail,
68-
Password: mocks.TestUserPasswordHash,
69-
}
70-
userDetails := &models.UserDetail{
71-
FirstName: mocks.TestUserFirstName,
72-
LastName: mocks.TestUserLastName,
73-
}
74-
75-
mockDBService.On("FindUserByEmail", user.Email).Return(user, nil)
76-
mockDBService.On("FindUserDetailsByUserID", user.ID).Return(userDetails, nil)
77-
7879
// Create a request with invalid JSON
7980
req, _ := http.NewRequest(http.MethodPost, "/auth/login", bytes.NewBuffer([]byte("{invalid-json")))
8081
req.Header.Set("Content-Type", "application/json")
@@ -89,10 +90,12 @@ func TestLoginUser(t *testing.T) {
8990

9091
// Assert the results
9192
assert.Equal(t, http.StatusBadRequest, w.Code)
92-
var response map[string]string
93+
var response ErrorResponse
9394
err := json.Unmarshal(w.Body.Bytes(), &response)
9495
assert.NoError(t, err)
95-
assert.Contains(t, response["error"], "invalid character")
96+
assert.False(t, response.Success)
97+
assert.Equal(t, "Invalid input data", response.Message)
98+
assert.Equal(t, "validation_failed", response.Error)
9699
})
97100

98101
t.Run("Invalid Email format Error Test", func(t *testing.T) {
@@ -124,10 +127,12 @@ func TestLoginUser(t *testing.T) {
124127

125128
// Assert the results
126129
assert.Equal(t, http.StatusBadRequest, w.Code)
127-
var response map[string]string
130+
var response ErrorResponse
128131
err := json.Unmarshal(w.Body.Bytes(), &response)
129132
assert.NoError(t, err)
130-
assert.Contains(t, response["error"], "Error:Field validation for 'Email' failed on the 'email' tag")
133+
assert.False(t, response.Success)
134+
assert.Equal(t, "Please provide a valid email address", response.Message)
135+
assert.Equal(t, "validation_failed", response.Error)
131136
})
132137

133138
t.Run("Unauthorized Login Test", func(t *testing.T) {
@@ -169,9 +174,110 @@ func TestLoginUser(t *testing.T) {
169174

170175
// Assert the results
171176
assert.Equal(t, http.StatusUnauthorized, w.Code)
172-
var response map[string]string
177+
var response ErrorResponse
178+
err := json.Unmarshal(w.Body.Bytes(), &response)
179+
assert.NoError(t, err)
180+
assert.False(t, response.Success)
181+
assert.Equal(t, "Invalid email or password", response.Message)
182+
assert.Equal(t, "authentication_failed", response.Error)
183+
})
184+
185+
t.Run("Empty email validation", func(t *testing.T) {
186+
mockDBService := new(mocks.MockDatabaseOperationService)
187+
mockPasswordDeliveryService := &mocks.MockPasswordDeliveryService{ShouldFail: false}
188+
mockRegService := services.NewUserRegistrationService(mockPasswordDeliveryService, mockDBService)
189+
mockLoginService := services.NewUserLoginService(mockDBService)
190+
handler := &UserHandler{userRegistrationService: *mockRegService, userLoginService: *mockLoginService}
191+
192+
// Sample request data with empty email (spaces are treated as invalid email format by binding validation)
193+
loginRequest := models.LoginRequest{
194+
Email: " ",
195+
Password: "password123",
196+
}
197+
198+
// Create a request
199+
requestBody, _ := json.Marshal(loginRequest)
200+
req, _ := http.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody))
201+
req.Header.Set("Content-Type", "application/json")
202+
203+
// Create a ResponseRecorder to capture the response
204+
w := httptest.NewRecorder()
205+
c, _ := gin.CreateTestContext(w)
206+
c.Request = req
207+
208+
handler.LoginUser(c)
209+
210+
// Assert the results - binding validation catches this first as invalid email format
211+
assert.Equal(t, http.StatusBadRequest, w.Code)
212+
var response ErrorResponse
213+
err := json.Unmarshal(w.Body.Bytes(), &response)
214+
assert.NoError(t, err)
215+
assert.False(t, response.Success)
216+
assert.Equal(t, "Please provide a valid email address", response.Message)
217+
assert.Equal(t, "validation_failed", response.Error)
218+
})
219+
220+
t.Run("Missing email field validation", func(t *testing.T) {
221+
mockDBService := new(mocks.MockDatabaseOperationService)
222+
mockPasswordDeliveryService := &mocks.MockPasswordDeliveryService{ShouldFail: false}
223+
mockRegService := services.NewUserRegistrationService(mockPasswordDeliveryService, mockDBService)
224+
mockLoginService := services.NewUserLoginService(mockDBService)
225+
handler := &UserHandler{userRegistrationService: *mockRegService, userLoginService: *mockLoginService}
226+
227+
// Create request with missing email field entirely
228+
requestBody := []byte(`{"password": "password123"}`)
229+
req, _ := http.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody))
230+
req.Header.Set("Content-Type", "application/json")
231+
232+
// Create a ResponseRecorder to capture the response
233+
w := httptest.NewRecorder()
234+
c, _ := gin.CreateTestContext(w)
235+
c.Request = req
236+
237+
handler.LoginUser(c)
238+
239+
// Assert the results - missing required field triggers general validation error
240+
assert.Equal(t, http.StatusBadRequest, w.Code)
241+
var response ErrorResponse
242+
err := json.Unmarshal(w.Body.Bytes(), &response)
243+
assert.NoError(t, err)
244+
assert.False(t, response.Success)
245+
assert.Equal(t, "Invalid input data", response.Message)
246+
assert.Equal(t, "validation_failed", response.Error)
247+
})
248+
249+
t.Run("Empty password validation", func(t *testing.T) {
250+
mockDBService := new(mocks.MockDatabaseOperationService)
251+
mockPasswordDeliveryService := &mocks.MockPasswordDeliveryService{ShouldFail: false}
252+
mockRegService := services.NewUserRegistrationService(mockPasswordDeliveryService, mockDBService)
253+
mockLoginService := services.NewUserLoginService(mockDBService)
254+
handler := &UserHandler{userRegistrationService: *mockRegService, userLoginService: *mockLoginService}
255+
256+
// Sample request data with empty password
257+
loginRequest := models.LoginRequest{
258+
Email: mocks.TestUserEmail,
259+
Password: " ",
260+
}
261+
262+
// Create a request
263+
requestBody, _ := json.Marshal(loginRequest)
264+
req, _ := http.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody))
265+
req.Header.Set("Content-Type", "application/json")
266+
267+
// Create a ResponseRecorder to capture the response
268+
w := httptest.NewRecorder()
269+
c, _ := gin.CreateTestContext(w)
270+
c.Request = req
271+
272+
handler.LoginUser(c)
273+
274+
// Assert the results
275+
assert.Equal(t, http.StatusBadRequest, w.Code)
276+
var response ErrorResponse
173277
err := json.Unmarshal(w.Body.Bytes(), &response)
174278
assert.NoError(t, err)
175-
assert.Contains(t, response["error"], "Invalid credentials")
279+
assert.False(t, response.Success)
280+
assert.Equal(t, "Password is required", response.Message)
281+
assert.Equal(t, "missing_password", response.Error)
176282
})
177283
}

0 commit comments

Comments
 (0)