Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
internal/db/.env
tmp/
.obsidian/
TODO.md
11 changes: 11 additions & 0 deletions docs/LEARNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## Date: 2025-11-14
### Password Validation

[OWASP](https://owasp.org/) no longer recommends strict composition rules like:
“must contain 1 uppercase”
“must contain 1 lowercase”
“must contain 1 number”
“must contain 1 special character”
Why?

Because composition rules do NOT significantly improve security and they make passwords harder for users to remember!
2 changes: 1 addition & 1 deletion internal/domain/user/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
type User struct {
ID int `gorm:"primaryKey" json:"id"`
Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
Password string `gorm:"size:255;not null" json:"password,omitempty"`
Password string `gorm:"size:255;not null" json:"-"`
Tasks []task.Task `json:"tasks" gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Expand Down
8 changes: 4 additions & 4 deletions internal/dto/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package dto

type CreateUserRequest struct {
Email string `json:"email" binding:"required,email" example:"[email protected]"`
Password string `json:"password" binding:"required,min=6" example:"strongpassword123"`
Password string `json:"password" binding:"required,min=8,max=128" example:"strongpassword123"`
}

type CreateUserResponse struct {
Expand All @@ -12,7 +12,7 @@ type CreateUserResponse struct {

type AuthRequest struct {
Email string `json:"email" binding:"required,email" example:"[email protected]"`
Password string `json:"password" binding:"required" example:"strongpassword123"`
Password string `json:"password" binding:"required,min=6" example:"strongpassword123"`
}

type AuthResponse struct {
Expand All @@ -31,8 +31,8 @@ type DeleteUserResponse struct {

type UpdatePasswordRequest struct {
ID int `json:"id" binding:"required" example:"1"`
OldPassword string `json:"old_password" binding:"required" example:"oldpassword123"`
NewPassword string `json:"new_password" binding:"required,min=6" example:"newsecurepassword456"`
OldPassword string `json:"old_password" binding:"required,min=8,max=128" example:"oldpassword123"`
NewPassword string `json:"new_password" binding:"required,min=8,max=128" example:"newsecurepassword456"`
}

type UpdatePasswordResponse struct {
Expand Down
24 changes: 24 additions & 0 deletions internal/handler/user/user_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ var _ UserHandlerInterface = (*UserHandler)(nil)
func (h *UserHandler) Register(c *gin.Context) {
var req dto.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {

if err.Error() == "EOF" {
c.JSON(http.StatusBadRequest, common.ErrorResponse{
Message: "Request body cannot be empty",
})
return
}

c.JSON(http.StatusBadRequest, common.ErrorResponse{Message: err.Error()})
return
}
Expand Down Expand Up @@ -70,6 +78,14 @@ func (h *UserHandler) Register(c *gin.Context) {
func (h *UserHandler) Login(c *gin.Context) {
var req dto.AuthRequest
if err := c.ShouldBindJSON(&req); err != nil {

if err.Error() == "EOF" {
c.JSON(http.StatusBadRequest, common.ErrorResponse{
Message: "Request body cannot be empty",
})
return
}

c.JSON(http.StatusBadRequest, common.ErrorResponse{Message: err.Error()})
return
}
Expand Down Expand Up @@ -103,6 +119,14 @@ func (h *UserHandler) Login(c *gin.Context) {
func (h *UserHandler) UpdatePassword(c *gin.Context) {
var req dto.UpdatePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {

if err.Error() == "EOF" {
c.JSON(http.StatusBadRequest, common.ErrorResponse{
Message: "Request body cannot be empty",
})
return
}

c.JSON(http.StatusBadRequest, common.ErrorResponse{Message: err.Error()})
return
}
Expand Down
29 changes: 29 additions & 0 deletions internal/handler/user/user_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"taskflow/internal/auth"
"taskflow/internal/common"
"taskflow/internal/dto"
user_service "taskflow/internal/service/user"
Expand Down Expand Up @@ -75,6 +76,15 @@ func TestUserHandler_Register(t *testing.T) {
Message: "invalid character '}' looking for beginning of value",
},
},
{
name: "failure case - empty body",
requestBody: ``,
setupMock: func() *user_service.UserServiceMock { return new(user_service.UserServiceMock) },
expectedStatus: http.StatusBadRequest,
expectedBody: common.ErrorResponse{
Message: "Request body cannot be empty",
},
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -114,3 +124,22 @@ func TestUserHandler_Register(t *testing.T) {
})
}
}

func TestUserHandler_Login(t *testing.T) {
tests := []struct {
name string // description of this test case
// Named input parameters for receiver constructor.
s user_service.UserServiceInterface
ua auth.UserAuthInterface
// Named input parameters for target function.
c *gin.Context
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := NewUserHandler(tt.s, tt.ua)
h.Login(tt.c)
})
}
}
3 changes: 1 addition & 2 deletions internal/repository/gorm/gorm_user/user_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}

// TODO:
// var _ UserRepositoryInterface = (*UserRepository)(nil)
var _ UserRepositoryInterface = (*UserRepository)(nil)

func (r *UserRepository) Create(u *user.User) error {
return r.db.Create(u).Error
Expand Down
13 changes: 9 additions & 4 deletions internal/service/user/user_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"taskflow/internal/repository/gorm/gorm_user"
"taskflow/pkg"
"taskflow/pkg/jwt"
"taskflow/pkg/validator"

"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
Expand All @@ -32,8 +33,10 @@ func (s *UserService) CreateUser(req *dto.CreateUserRequest) (*dto.CreateUserRes
if req.Password == "" {
return nil, errors.New("password is required")
}
if len(req.Password) < 6 {
return nil, errors.New("password must be at least 6 characters")

validator := validator.NewPasswordValidator()
if err := validator.Validate(req.Password); err != nil {
return nil, errors.New("password validation failed, choose a stronger password")
}

req.Email = strings.ToLower(strings.TrimSpace(req.Email))
Expand Down Expand Up @@ -103,8 +106,10 @@ func (s *UserService) UpdatePassword(req *dto.UpdatePasswordRequest) (*dto.Updat
if req.NewPassword == "" {
return nil, errors.New("new password is required")
}
if len(req.NewPassword) < 6 {
return nil, errors.New("new password must be at least 6 characters")

validator := validator.NewPasswordValidator()
if err := validator.Validate(req.NewPassword); err != nil {
return nil, errors.New("password validation failed, choose a stronger password")
}

u, err := s.repo.GetByID(req.ID)
Expand Down
15 changes: 14 additions & 1 deletion internal/service/user/user_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ func TestCreateUser(t *testing.T) {
},
wantErr: false,
},
{
name: "weak password",
req: &dto.CreateUserRequest{Email: "[email protected]", Password: "12345678"},
mockSetup: nil,
wantErr: true,
},
{
name: "duplicate email",
req: &dto.CreateUserRequest{Email: "[email protected]", Password: "secret123"},
Expand Down Expand Up @@ -139,14 +145,21 @@ func TestUpdatePassword(t *testing.T) {
req: &dto.UpdatePasswordRequest{
ID: 2,
OldPassword: "wrongpass",
NewPassword: "newpass",
NewPassword: "newstrongpassword",
},
mockSetup: func(m *gorm_user.MockUserRepository) {
u := &user.User{ID: 2, Email: "[email protected]", Password: oldHash}
m.On("GetByID", 2).Return(u, nil).Once()
},
wantErr: true,
},

{
name: "weak new password",
req: &dto.UpdatePasswordRequest{OldPassword: "someoldpassword", NewPassword: "12345678"},
mockSetup: nil,
wantErr: true,
},
}

for _, tt := range tests {
Expand Down
2 changes: 1 addition & 1 deletion pkg/jwt/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ var (
)

const (
TokenExpiration = 24 * time.Hour
TokenExpiration = 30 * time.Minute
UserIDClaimKey = "user_id"
ExpirationClaimKey = "exp"
)
Expand Down
130 changes: 130 additions & 0 deletions pkg/validator/password_validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package validator

import (
"errors"
"strings"
"unicode"
)

var (
ErrPasswordTooShort = errors.New("password must be at least 8 characters long")
ErrPasswordTooLong = errors.New("password must not exceed 128 characters")
ErrPasswordTooCommon = errors.New("password is too common and easily guessable")
ErrPasswordRepeating = errors.New("password contains too many repeating characters")
ErrPasswordAllNumeric = errors.New("password cannot be all numeric")
ErrPasswordWhitespace = errors.New("password cannot contain leading or trailing whitespace")
)

var commonPasswords = map[string]bool{
"password": true,
"12345678": true,
"123456789": true,
"password1": true,
"password123": true,
"qwerty": true,
"abc123": true,
"monkey": true,
"letmein": true,
"trustno1": true,
"dragon": true,
"baseball": true,
"iloveyou": true,
"master": true,
"sunshine": true,
"ashley": true,
"bailey": true,
"shadow": true,
"superman": true,
}

// PasswordValidator provides comprehensive password validation
type PasswordValidator struct {
MinLength int
MaxLength int
CheckCommon bool
CheckRepeating bool
CheckAllNumeric bool
CheckWhitespace bool
}

// NewPasswordValidator creates a validator with NIST compliant defaults
func NewPasswordValidator() *PasswordValidator {
return &PasswordValidator{
MinLength: 8,
MaxLength: 128,
CheckCommon: true,
CheckRepeating: true,
CheckAllNumeric: true,
CheckWhitespace: true,
}
}

// Validate performs comprehensive password validation
func (v *PasswordValidator) Validate(password string) error {
if v.CheckWhitespace && (strings.HasPrefix(password, " ") || strings.HasSuffix(password, " ")) {
return ErrPasswordWhitespace
}

if len(password) < v.MinLength {
return ErrPasswordTooShort
}
if len(password) > v.MaxLength {
return ErrPasswordTooLong
}

if v.CheckCommon && v.isCommonPassword(password) {
return ErrPasswordTooCommon
}

if v.CheckAllNumeric && v.isAllNumeric(password) {
return ErrPasswordAllNumeric
}

if v.CheckRepeating && v.hasExcessiveRepeating(password) {
return ErrPasswordRepeating
}

return nil
}

// isCommonPassword checks against known common passwords
func (v *PasswordValidator) isCommonPassword(password string) bool {
lower := strings.ToLower(password)
return commonPasswords[lower]
}

// isAllNumeric checks if password is all numeric
func (v *PasswordValidator) isAllNumeric(password string) bool {
for _, r := range password {
if !unicode.IsDigit(r) {
return false
}
}
return len(password) > 0
}

// hasExcessiveRepeating checks for patterns like "aaaa" or "1111"
func (v *PasswordValidator) hasExcessiveRepeating(password string) bool {
if len(password) < 4 {
return false
}

consecutiveCount := 1
for i := 1; i < len(password); i++ {
if password[i] == password[i-1] {
consecutiveCount++
if consecutiveCount >= 4 {
return true
}
} else {
consecutiveCount = 1
}
}
return false
}

// Custom Gin validator function
func ValidatePassword(password string) error {
validator := NewPasswordValidator()
return validator.Validate(password)
}
Loading
Loading