package service import ( "crypto/sha1" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "regexp" "strings" "unicode/utf8" passwordvalidator "github.com/wagslane/go-password-validator" ) const ( minPasswordLength = 12 // Entropy checks prevent short passwords anyway maxPasswordLength = 72 // Limitation of bcrypt minPasswordEntropy = 60.0 minUsernameLength = 3 maxUsernameLength = 20 hibpAPI = "https://api.pwnedpasswords.com/range" // Doesn't require an API key ) var ( usernameRegex = regexp.MustCompile("^[a-z0-9_]+$") ) func respondJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } func respondError(w http.ResponseWriter, status int, message string) { respondJSON(w, status, map[string]string{"error": message}) } /* Client-side check: ``` function estimateEntropy(password: string): number { const pool: number = getCharsetSize(password); // Character diversity (R) const entropy: number = password.length * Math.log2(pool); // E = L * log_2(R) return entropy; // Value (E) that can be compared against a hardcoded threshold (e.g. 60) } ``` */ // Validate the given password using a hybrid approach: length (max. set due to bcrypt's input // upper limit of 72 bytes), entropy, and HIBP API. func validatePassword(password string) error { if len(password) < minPasswordLength { return fmt.Errorf("password must be at least %d characters", minPasswordLength) } if len(password) > maxPasswordLength { return fmt.Errorf("password cannot be longer than %d characters", maxPasswordLength) } // Formatted error message will contain tips to increase the password strength (safe to show) err := passwordvalidator.Validate(password, minPasswordEntropy) if err != nil { return err } if compromised, _ := isPasswordCompromised(password); compromised { return errors.New("password is compromised") } return nil } // Send the first five bytes of the password's SHA-1 hash to HIBP API, then check if the rest of // the hash is present in the APi's response data (k-Anonymity model). func isPasswordCompromised(password string) (bool, error) { hash := sha1.Sum([]byte(password)) hashStr := strings.ToUpper(hex.EncodeToString(hash[:])) prefix, suffix := hashStr[:5], hashStr[5:] resp, err := http.Get(fmt.Sprintf("%s/%s", hibpAPI, prefix)) if err != nil { return false, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return false, err } return strings.Contains(strings.ToUpper(string(body)), suffix), nil } // Normalize the username by making it lowercase and trimming any leading or trailing whitespace. func normalizeUsername(username string) string { return strings.ToLower(strings.TrimSpace(username)) } /* Client-side check (additionally input should automatically perform the normalization steps): ``` function validateUsername(username: string): string { const min: number = 3, max: number = 20; if (username.length < min) return "Too short"; if (username.length > max) return "Too long"; if (!/^[a-zA-Z0-9_]+$/.test(username)) return "Invalid characters"; return "Valid"; } ``` */ // Validate the given username by making sure it only contains alphanumeric characters or // underscores and adheres the hardcoded minimum and maximum length rules. func validateUsername(username string) error { if utf8.RuneCountInString(username) < minUsernameLength { return fmt.Errorf("username must be at least %d characters", minUsernameLength) } if utf8.RuneCountInString(username) > maxUsernameLength { return fmt.Errorf("username cannot be longer than %d characters", maxUsernameLength) } if !usernameRegex.MatchString(username) { return errors.New("username can only contain numbers, letters, and underscores") } return nil }