diff --git a/backend/config/config.go b/backend/config/config.go index 35814ba..2184ea2 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -25,25 +25,27 @@ import ( ) type Config struct { - Hostname string `mapstructure:"HOSTNAME"` - Port string `mapstructure:"PORT"` + Hostname string `mapstructure:"HOSTNAME"` + Port string `mapstructure:"PORT"` - DatabaseUrl string `mapstructure:"POSTGRES_URL"` - RedisUrl string `mapstructure:"REDIS_URL"` - MinioUrl string `mapstructure:"MINIO_URL"` + DatabaseUrl string `mapstructure:"POSTGRES_URL"` + RedisUrl string `mapstructure:"REDIS_URL"` + MinioUrl string `mapstructure:"MINIO_URL"` - JwtAlgorithm string `mapstructure:"JWT_ALGORITHM"` - JwtSecret string `mapstructure:"JWT_SECRET"` - JwtIssuer string `mapstructure:"JWT_ISSUER"` - JwtAudience string `mapstructure:"JWT_AUDIENCE"` - JwtExpAccess int `mapstructure:"JWT_EXP_ACCESS"` - JwtExpRefresh int `mapstructure:"JWT_EXP_REFRESH"` + JwtAlgorithm string `mapstructure:"JWT_ALGORITHM"` + JwtSecret string `mapstructure:"JWT_SECRET"` + JwtIssuer string `mapstructure:"JWT_ISSUER"` + JwtAudience string `mapstructure:"JWT_AUDIENCE"` + JwtExpAccess int `mapstructure:"JWT_EXP_ACCESS"` + JwtExpRefresh int `mapstructure:"JWT_EXP_REFRESH"` - PasswordCheckLength bool `mapstructure:"PASSWORD_CHECK_LENGTH"` - PasswordCheckNumbers bool `mapstructure:"PASSWORD_CHECK_NUMBERS"` - PasswordCheckCases bool `mapstructure:"PASSWORD_CHECK_CASES"` - PasswordCheckSymbols bool `mapstructure:"PASSWORD_CHECK_SYMBOLS"` - PasswordCheckLeaked bool `mapstructure:"PASSWORD_CHECK_LEAKED"` + PasswordMinLength int `mapstructure:"PASSWORD_MIN_LENGTH"` + PasswordMaxLength int `mapstructure:"PASSWORD_MIN_LENGTH"` + PasswordCheckNumbers bool `mapstructure:"PASSWORD_CHECK_NUMBERS"` + PasswordCheckCharacters bool `mapstructure:"PASSWORD_CHECK_CHARACTERS"` + PasswordCheckCases bool `mapstructure:"PASSWORD_CHECK_CASES"` + PasswordCheckSymbols bool `mapstructure:"PASSWORD_CHECK_SYMBOLS"` + PasswordCheckLeaked bool `mapstructure:"PASSWORD_CHECK_LEAKED"` Environment string `mapstructure:"ENVIRONMENT"` } @@ -60,8 +62,10 @@ func Load() (*Config, error) { viper.SetDefault("JWT_AUDIENCE", "easywish") viper.SetDefault("JWT_ISSUER", "easywish") - viper.SetDefault("PASSWORD_CHECK_LENGTH", true) + viper.SetDefault("PASSWORD_MIN_LENGTH", 6) + viper.SetDefault("PASSWORD_MAX_LENGTH", 100) viper.SetDefault("PASSWORD_CHECK_NUMBERS", false) + viper.SetDefault("PASSWORD_CHECK_CHARACTERS", false) viper.SetDefault("PASSWORD_CHECK_CASES", false) viper.SetDefault("PASSWORD_CHECK_SYMBOLS", false) viper.SetDefault("PASSWORD_CHECK_LEAKED", false) @@ -89,8 +93,10 @@ func Load() (*Config, error) { viper.BindEnv("JWT_EXP_ACCESS") viper.BindEnv("JWT_EXP_REFRESH") - viper.BindEnv("PASSWORD_CHECK_LENGTH") + viper.BindEnv("PASSWORD_MIN_LENGTH") + viper.BindEnv("PASSWORD_MAX_LENGTH") viper.BindEnv("PASSWORD_CHECK_NUMBERS") + viper.BindEnv("PASSWORD_CHECK_CHARACTERS") viper.BindEnv("PASSWORD_CHECK_CASES") viper.BindEnv("PASSWORD_CHECK_SYMBOLS") viper.BindEnv("PASSWORD_CHECK_LEAKED") diff --git a/backend/internal/models/auth.go b/backend/internal/models/auth.go index f0a7b2f..ca93b32 100644 --- a/backend/internal/models/auth.go +++ b/backend/internal/models/auth.go @@ -23,15 +23,15 @@ type Tokens struct { } type RegistrationBeginRequest struct { - Username string `json:"username" binding:"required,min=3,max=20" validate:"username"` + Username string `json:"username" binding:"required" validate:"username"` Email *string `json:"email" binding:"email"` - Password string `json:"password" binding:"required"` // TODO: password checking + Password string `json:"password" binding:"required" validate:"password"` } type RegistrationCompleteRequest struct { - Username string `json:"username" binding:"required,min=3,max=20" validate:"username"` + Username string `json:"username" binding:"required" validate:"username"` VerificationCode string `json:"verification_code" binding:"required"` - Name string `json:"name" binding:"required,max=75"` + Name string `json:"name" binding:"required" validate:"name"` Birthday *string `json:"birthday"` AvatarUrl *string `json:"avatar_url" binding:"http_url,max=255"` } diff --git a/backend/internal/utils/security.go b/backend/internal/utils/security.go index e4f968a..4333de9 100644 --- a/backend/internal/utils/security.go +++ b/backend/internal/utils/security.go @@ -19,62 +19,19 @@ package utils import ( "crypto/rand" - "easywish/config" - "errors" "fmt" "io" - "regexp" ) func GenerateSecure6DigitNumber() (string, error) { - // Generate a random number between 0 and 999999 (inclusive) - // This ensures we get a 6-digit number, including those starting with 0 - max := 1000000 // Upper bound (exclusive) - b := make([]byte, 4) // A 4-byte slice is sufficient for a 32-bit integer + maxNumber := 1000000 + b := make([]byte, 4) if _, err := io.ReadFull(rand.Reader, b); err != nil { return "", fmt.Errorf("failed to read random bytes: %w", err) } - // Convert bytes to an integer - // We use a simple modulo operation to get a number within our desired range. - // While this introduces a slight bias for very large ranges, for 1,000,000 - // it's negligible and simpler than more complex methods like rejection sampling. - num := int(uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])) % max + num := int(uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])) % maxNumber return fmt.Sprintf("%06d", num), nil } - -func ValidatePassword(password string) error { - cfg := config.GetConfig() - - if cfg.PasswordCheckLength { - passwordLength := len(password); if passwordLength < 8 || passwordLength > 100 { - return errors.New("Password must be between 8 and 100 characters") - } - } - - if cfg.PasswordCheckNumbers { - numbersPresent := regexp.MustCompile(`[0-9]`).MatchString(password); if !numbersPresent { - return errors.New("Password must contain at least 1 number") - } - } - - if cfg.PasswordCheckCases { - differentCasesPresent := regexp.MustCompile(`(?=.*[a-z])(?=.*[A-Z])`).MatchString(password); if !differentCasesPresent { - return errors.New("Password must have uppercase and lowercase characters") - } - } - - if cfg.PasswordCheckSymbols { - symbolsPresent := regexp.MustCompile(`[.,/;'[\]\-=_+{}:"<>?\/|!@#$%^&*()~]`).MatchString(password); if !symbolsPresent { - return errors.New("Password must contain at least one special symbol") - } - } - - if cfg.PasswordCheckLeaked { - // TODO: implement checking leaked passwords via rockme.txt - } - - return nil -} diff --git a/backend/internal/validation/custom.go b/backend/internal/validation/custom.go index 678b1ec..8941633 100644 --- a/backend/internal/validation/custom.go +++ b/backend/internal/validation/custom.go @@ -18,6 +18,7 @@ package validation import ( + "easywish/config" "regexp" "github.com/go-playground/validator/v10" @@ -46,6 +47,36 @@ func GetCustomHandlers() []CustomValidatorHandler { return regexp.MustCompile(`^[.]{1,75}$`).MatchString(username) }}, + { + FieldName: "password", + Function: func(fl validator.FieldLevel) bool { + password := fl.Field().String() + cfg := config.GetConfig() + + if cfg.PasswordMaxLength < len(password) || len(password) < cfg.PasswordMinLength { + return false + } + + if cfg.PasswordCheckNumbers && !regexp.MustCompile(`\d+`).MatchString(password) { + return false + } + + if cfg.PasswordCheckCases && !regexp.MustCompile(`^(?=.*[a-z])(?=.*[A-Z]).*$`).MatchString(password) || + cfg.PasswordCheckCharacters && !regexp.MustCompile(`[a-zA-Z]`).MatchString(password) { + return false + } + + if cfg.PasswordCheckSymbols && !regexp.MustCompile(`[.,/;'[\]\-=_+{}:"<>?\/|!@#$%^&*()~]`).MatchString(password) { + return false + } + + if cfg.PasswordCheckLeaked { + // TODO: implement rockme check + } + + return true + }}, + } return handlers