test(security): add comprehensive security tests
- Test password complexity requirements - Test account lockout mechanism - Test rate limiting functionality - Test JWT expiration (1 hour) - Test refresh token expiration (7 days) - Test password requirements list - Verify transaction manager and error handler types - All tests passing
This commit is contained in:
+88
-18
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bookmann/internal/database"
|
||||
"bookmann/internal/middleware"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -18,21 +19,23 @@ import (
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
db *database.Queries
|
||||
jwtKey []byte
|
||||
db *database.Queries
|
||||
jwtKey []byte
|
||||
loginAttemptTracker *middleware.LoginAttemptTracker
|
||||
}
|
||||
|
||||
func NewAuthHandler(db *database.Queries, jwtSecret string) *AuthHandler {
|
||||
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
db: db,
|
||||
jwtKey: []byte(jwtSecret),
|
||||
db: db,
|
||||
jwtKey: []byte(jwtSecret),
|
||||
loginAttemptTracker: loginAttemptTracker,
|
||||
}
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `form:"email" json:"email" validate:"required,email"`
|
||||
Username string `form:"username" json:"username" validate:"required,min=3,max=50"`
|
||||
Password string `form:"password" json:"password" validate:"required,min=6"`
|
||||
Password string `form:"password" json:"password" validate:"required,passwordcomplex"`
|
||||
FirstName string `form:"first_name" json:"first_name,omitempty"`
|
||||
LastName string `form:"last_name" json:"last_name,omitempty"`
|
||||
Role string `form:"role" json:"role,omitempty"`
|
||||
@@ -44,8 +47,11 @@ type LoginRequest struct {
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User UserProfile `json:"user"`
|
||||
Token string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User UserProfile `json:"user"`
|
||||
}
|
||||
|
||||
type UserProfile struct {
|
||||
@@ -219,7 +225,7 @@ func (h *AuthHandler) Register(c echo.Context) error {
|
||||
}
|
||||
|
||||
// Generate JWT with user details
|
||||
token, err := h.generateJWTWithAllClaims(
|
||||
accessToken, err := h.generateJWTWithAllClaims(
|
||||
uuid.UUID(user.ID.Bytes).String(),
|
||||
user.Role,
|
||||
user.Email,
|
||||
@@ -232,16 +238,26 @@ func (h *AuthHandler) Register(c echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
|
||||
}
|
||||
|
||||
// Create refresh token
|
||||
refreshToken, _, err := h.CreateRefreshToken(uuid.UUID(user.ID.Bytes))
|
||||
if err != nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate refresh token"})
|
||||
}
|
||||
|
||||
// Check if request is from HTMX
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
// Return HTML with script to set token and redirect
|
||||
html := fmt.Sprintf(`<div class="text-green-500">Registration successful! Redirecting...</div>
|
||||
<script>
|
||||
localStorage.setItem('token', '%s');
|
||||
localStorage.setItem('refreshToken', '%s');
|
||||
localStorage.setItem('user', JSON.stringify(%s));
|
||||
document.cookie = 'token=%s; path=/; max-age=86400';
|
||||
document.cookie = 'token=%s; path=/; max-age=3600';
|
||||
window.location.href = '/api/dashboard';
|
||||
</script>`, token, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username), token)
|
||||
</script>`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username), accessToken)
|
||||
return c.HTML(http.StatusCreated, html)
|
||||
}
|
||||
|
||||
@@ -252,7 +268,10 @@ window.location.href = '/api/dashboard';
|
||||
lastName = user.LastName.String
|
||||
}
|
||||
return c.JSON(http.StatusCreated, AuthResponse{
|
||||
Token: token,
|
||||
Token: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
User: UserProfile{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
@@ -306,9 +325,34 @@ func (h *AuthHandler) Login(c echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Check if user/IP is locked out
|
||||
ip := c.RealIP()
|
||||
if ip == "" {
|
||||
ip = c.Request().RemoteAddr
|
||||
}
|
||||
|
||||
locked, remainingTime := h.loginAttemptTracker.IsLocked(login)
|
||||
if locked {
|
||||
errMsg := fmt.Sprintf("Account locked. Try again in %d minutes", int(remainingTime.Minutes())+1)
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusTooManyRequests, `<div class="text-red-500">`+errMsg+`</div>`)
|
||||
}
|
||||
return c.JSON(http.StatusTooManyRequests, map[string]string{"error": errMsg})
|
||||
}
|
||||
|
||||
// Get user by email or username (includes password hash for verification)
|
||||
user, err := h.db.GetUserForLogin(c.Request().Context(), req.Login)
|
||||
if err != nil {
|
||||
// Record failed attempt
|
||||
locked, remainingTime := h.loginAttemptTracker.RecordFailedAttempt(login)
|
||||
if locked {
|
||||
errMsg := fmt.Sprintf("Too many failed attempts. Account locked for %d minutes", int(remainingTime.Minutes())+1)
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusTooManyRequests, `<div class="text-red-500">`+errMsg+`</div>`)
|
||||
}
|
||||
return c.JSON(http.StatusTooManyRequests, map[string]string{"error": errMsg})
|
||||
}
|
||||
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusUnauthorized, `<div class="text-red-500">Invalid credentials</div>`)
|
||||
}
|
||||
@@ -317,14 +361,27 @@ func (h *AuthHandler) Login(c echo.Context) error {
|
||||
|
||||
// Check password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
// Record failed attempt
|
||||
locked, remainingTime := h.loginAttemptTracker.RecordFailedAttempt(login)
|
||||
if locked {
|
||||
errMsg := fmt.Sprintf("Too many failed attempts. Account locked for %d minutes", int(remainingTime.Minutes())+1)
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusTooManyRequests, `<div class="text-red-500">`+errMsg+`</div>`)
|
||||
}
|
||||
return c.JSON(http.StatusTooManyRequests, map[string]string{"error": errMsg})
|
||||
}
|
||||
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusUnauthorized, `<div class="text-red-500">Invalid credentials</div>`)
|
||||
}
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
}
|
||||
|
||||
// Clear failed attempts on successful login
|
||||
h.loginAttemptTracker.ClearAttempts(login)
|
||||
|
||||
// Generate JWT with user details
|
||||
token, err := h.generateJWTWithAllClaims(
|
||||
accessToken, err := h.generateJWTWithAllClaims(
|
||||
uuid.UUID(user.ID.Bytes).String(),
|
||||
user.Role,
|
||||
user.Email,
|
||||
@@ -337,16 +394,26 @@ func (h *AuthHandler) Login(c echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
|
||||
}
|
||||
|
||||
// Create refresh token
|
||||
refreshToken, _, err := h.CreateRefreshToken(uuid.UUID(user.ID.Bytes))
|
||||
if err != nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate refresh token"})
|
||||
}
|
||||
|
||||
// Check if request is from HTMX
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
// Return HTML with script to set token and redirect
|
||||
html := fmt.Sprintf(`<div class="text-green-500">Login successful! Redirecting...</div>
|
||||
<script>
|
||||
localStorage.setItem('token', '%s');
|
||||
localStorage.setItem('refreshToken', '%s');
|
||||
localStorage.setItem('user', JSON.stringify(%s));
|
||||
document.cookie = 'token=%s; path=/; max-age=86400';
|
||||
document.cookie = 'token=%s; path=/; max-age=3600';
|
||||
window.location.href = '/api/dashboard';
|
||||
</script>`, token, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s","first_name":"%s","last_name":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username, user.FirstName.String, user.LastName.String), token)
|
||||
</script>`, accessToken, refreshToken, fmt.Sprintf(`{"id":"%s","email":"%s","username":"%s","first_name":"%s","last_name":"%s"}`, uuid.UUID(user.ID.Bytes).String(), user.Email, user.Username, user.FirstName.String, user.LastName.String), accessToken)
|
||||
return c.HTML(http.StatusOK, html)
|
||||
}
|
||||
|
||||
@@ -359,7 +426,10 @@ window.location.href = '/api/dashboard';
|
||||
lastName = user.LastName.String
|
||||
}
|
||||
return c.JSON(http.StatusOK, AuthResponse{
|
||||
Token: token,
|
||||
Token: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 3600,
|
||||
User: UserProfile{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
@@ -651,7 +721,7 @@ func (h *AuthHandler) UpdateEmail(c echo.Context) error {
|
||||
|
||||
type UpdatePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password" validate:"required"`
|
||||
NewPassword string `json:"new_password" validate:"required,min=6"`
|
||||
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
|
||||
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
||||
}
|
||||
|
||||
@@ -860,7 +930,7 @@ func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, user
|
||||
"user_role": userRole,
|
||||
"user_email": userEmail,
|
||||
"user_username": userUsername,
|
||||
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
||||
"exp": time.Now().Add(1 * time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
Reference in New Issue
Block a user