diff --git a/backend/config/config.go b/backend/config/config.go index 2fc265d..ff3b42b 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -8,30 +8,47 @@ import ( ) type Config struct { - Hostname string `mapstructure:"HOSTNAME"` - Port string `mapstructure:"PORT"` - 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"` - Environment string `mapstructure:"ENVIRONMENT"` + Hostname string `mapstructure:"HOSTNAME"` + Port string `mapstructure:"PORT"` + + 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"` + + 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"` + + Environment string `mapstructure:"ENVIRONMENT"` } func Load() (*Config, error) { viper.SetDefault("HOSTNAME", "localhost") viper.SetDefault("PORT", "8080") + viper.SetDefault("JWT_ALGORITHM", "HS256") viper.SetDefault("JWT_SECRET", "default_jwt_secret_please_change") viper.SetDefault("JWT_EXP_ACCESS", 5) viper.SetDefault("JWT_EXP_REFRESH", 10080) viper.SetDefault("JWT_AUDIENCE", "easywish") viper.SetDefault("JWT_ISSUER", "easywish") + + viper.SetDefault("PASSWORD_CHECK_LENGTH", true) + viper.SetDefault("PASSWORD_CHECK_NUMBERS", false) + viper.SetDefault("PASSWORD_CHECK_CASES", false) + viper.SetDefault("PASSWORD_CHECK_SYMBOLS", false) + viper.SetDefault("PASSWORD_CHECK_LEAKED", false) + viper.SetDefault("ENVIRONMENT", "production") viper.AutomaticEnv() @@ -39,18 +56,28 @@ func Load() (*Config, error) { // Viper's AutomaticEnv() expects lowercase keys for unmarshalling into structs by default, // while the environment variables and struct tags are in uppercase. // Here's the stupidity we have to do to fix it: + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.BindEnv("HOSTNAME") viper.BindEnv("PORT") + viper.BindEnv("POSTGRES_URL") viper.BindEnv("REDIS_URL") viper.BindEnv("MINIO_URL") + viper.BindEnv("JWT_ALGORITHM") viper.BindEnv("JWT_SECRET") viper.BindEnv("JWT_ISSUER") viper.BindEnv("JWT_AUDIENCE") viper.BindEnv("JWT_EXP_ACCESS") viper.BindEnv("JWT_EXP_REFRESH") + + viper.BindEnv("PASSWORD_CHECK_LENGTH") + viper.BindEnv("PASSWORD_CHECK_NUMBERS") + viper.BindEnv("PASSWORD_CHECK_CASES") + viper.BindEnv("PASSWORD_CHECK_SYMBOLS") + viper.BindEnv("PASSWORD_CHECK_LEAKED") + viper.BindEnv("ENVIRONMENT") required := []string{ diff --git a/backend/internal/models/auth.go b/backend/internal/models/auth.go index e9bdecc..b759276 100644 --- a/backend/internal/models/auth.go +++ b/backend/internal/models/auth.go @@ -1,31 +1,33 @@ package models type Tokens struct { - AccessToken string `json:"access_token"` + AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` } type RegistrationBeginRequest struct { - Username string `json:"username"` - Email *string `json:"email"` - Password string `json:"password"` // TODO: password checking + Username string `json:"username" binding:"required,min=3,max=20"` + Email *string `json:"email" binding:"email"` + Password string `json:"password" binding:"required,password"` // TODO: password checking } +// TODO: length check type RegistrationCompleteRequest struct { - Username string `json:"username"` - VerificationCode string `json:"verification_code"` - Name string `json:"name"` + Username string `json:"username" binding:"required"` + VerificationCode string `json:"verification_code" binding:"required"` + Name string `json:"name" binding:"required,max=75"` Birthday *string `json:"birthday"` - AvatarUrl *string `json:"avatar_url"` + AvatarUrl *string `json:"avatar_url" binding:"http_url,max=255"` } type RegistrationCompleteResponse struct { Tokens } +// TODO: length check type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` TOTP *string `json:"totp"` } @@ -33,8 +35,9 @@ type LoginResponse struct { Tokens } +// TODO: length check type RefreshRequest struct { - RefreshToken string `json:"refresh_token"` + RefreshToken string `json:"refresh_token" binding:"required"` } type RefreshResponse struct { diff --git a/backend/internal/utils/security.go b/backend/internal/utils/security.go new file mode 100644 index 0000000..4b18f05 --- /dev/null +++ b/backend/internal/utils/security.go @@ -0,0 +1,63 @@ +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 + + 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 + + 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 contain at least 1 number") + } + } + + 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/utils/securityCode.go b/backend/internal/utils/securityCode.go deleted file mode 100644 index 41a99e8..0000000 --- a/backend/internal/utils/securityCode.go +++ /dev/null @@ -1,26 +0,0 @@ -package utils - -import ( - "crypto/rand" - "fmt" - "io" -) - -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 - - 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 - - return fmt.Sprintf("%06d", num), nil -}