From ae3e7287630f7ea4f42942abe562e6e9ae6157da Mon Sep 17 00:00:00 2001 From: konstantin Date: Sun, 19 Mar 2023 21:04:10 +0100 Subject: [PATCH] Introduce Luhn Checksum Validation (#1009) --- README.md | 1 + baked_in.go | 63 ++++++++++++++++++++++++++++++++++------------- doc.go | 8 ++++++ validator_test.go | 45 +++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 9c631d6..acdf773 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ Baked-in Validations | jwt | JSON Web Token (JWT) | | latitude | Latitude | | longitude | Longitude | +| luhn_checksum | Luhn Algorithm Checksum (for strings and (u)int) | | postcode_iso3166_alpha2 | Postcode | | postcode_iso3166_alpha2_field | Postcode | | rgb | RGB String | diff --git a/baked_in.go b/baked_in.go index 028bc84..48d71b9 100644 --- a/baked_in.go +++ b/baked_in.go @@ -223,6 +223,7 @@ var ( "semver": isSemverFormat, "dns_rfc1035_label": isDnsRFC1035LabelFormat, "credit_card": isCreditCard, + "luhn_checksum": hasLuhnChecksum, "mongodb": isMongoDB, "cron": isCron, } @@ -2681,6 +2682,29 @@ func isDnsRFC1035LabelFormat(fl FieldLevel) bool { return dnsRegexRFC1035Label.MatchString(val) } +// digitsHaveLuhnChecksum returns true if and only if the last element of the given digits slice is the Luhn checksum of the previous elements +func digitsHaveLuhnChecksum(digits []string) bool { + size := len(digits) + sum := 0 + for i, digit := range digits { + value, err := strconv.Atoi(digit) + if err != nil { + return false + } + if size%2 == 0 && i%2 == 0 || size%2 == 1 && i%2 == 1 { + v := value * 2 + if v >= 10 { + sum += 1 + (v % 10) + } else { + sum += v + } + } else { + sum += value + } + } + return (sum % 10) == 0 +} + // isMongoDB is the validation function for validating if the current field's value is valid mongoDB objectID func isMongoDB(fl FieldLevel) bool { val := fl.Field().String() @@ -2705,24 +2729,29 @@ func isCreditCard(fl FieldLevel) bool { return false } - sum := 0 - for i, digit := range ccDigits { - value, err := strconv.Atoi(digit) - if err != nil { - return false - } - if size%2 == 0 && i%2 == 0 || size%2 == 1 && i%2 == 1 { - v := value * 2 - if v >= 10 { - sum += 1 + (v % 10) - } else { - sum += v - } - } else { - sum += value - } + return digitsHaveLuhnChecksum(ccDigits) +} + +// hasLuhnChecksum is the validation for validating if the current field's value has a valid Luhn checksum +func hasLuhnChecksum(fl FieldLevel) bool { + field := fl.Field() + var str string // convert to a string which will then be split into single digits; easier and more readable than shifting/extracting single digits from a number + switch field.Kind() { + case reflect.String: + str = field.String() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + str = strconv.FormatInt(field.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + str = strconv.FormatUint(field.Uint(), 10) + default: + panic(fmt.Sprintf("Bad field type %T", field.Interface())) } - return (sum % 10) == 0 + size := len(str) + if size < 2 { // there has to be at least one digit that carries a meaning + the checksum + return false + } + digits := strings.Split(str, "") + return digitsHaveLuhnChecksum(digits) } // isCron is the validation function for validating if the current field's value is a valid cron expression diff --git a/doc.go b/doc.go index bffabc4..66d5e98 100644 --- a/doc.go +++ b/doc.go @@ -1357,6 +1357,13 @@ This validates that a string value contains a valid credit card number using Luh Usage: credit_card +# Luhn Checksum + + Usage: luhn_checksum + +This validates that a string or (u)int value contains a valid checksum using the Luhn algorithm. + + #MongoDb ObjectID This validates that a string is a valid 24 character hexadecimal string. @@ -1372,6 +1379,7 @@ This validates that a string value contains a valid cron expression. Alias Validators and Tags +Alias Validators and Tags NOTE: When returning an error, the tag returned in "FieldError" will be the alias tag unless the dive tag is part of the alias. Everything after the dive tag is not reported as the alias tag. Also, the "ActualTag" in the before diff --git a/validator_test.go b/validator_test.go index a4beb9f..fddf79b 100644 --- a/validator_test.go +++ b/validator_test.go @@ -12692,6 +12692,51 @@ func TestCreditCardFormatValidation(t *testing.T) { } } +func TestLuhnChecksumValidation(t *testing.T) { + testsUint := []struct { + value interface{} `validate:"luhn_checksum"` // the type is interface{} because the luhn_checksum works on both strings and numbers + tag string + expected bool + }{ + {uint64(586824160825533338), "luhn_checksum", true}, // credit card numbers are just special cases of numbers with luhn checksum + {586824160825533338, "luhn_checksum", true}, + {"586824160825533338", "luhn_checksum", true}, + {uint64(586824160825533328), "luhn_checksum", false}, + {586824160825533328, "luhn_checksum", false}, + {"586824160825533328", "luhn_checksum", false}, + {10000000116, "luhn_checksum", true}, // but there may be shorter numbers (11 digits) + {"10000000116", "luhn_checksum", true}, + {10000000117, "luhn_checksum", false}, + {"10000000117", "luhn_checksum", false}, + {uint64(12345678123456789011), "luhn_checksum", true}, // or longer numbers (19 digits) + {"12345678123456789011", "luhn_checksum", true}, + {1, "luhn_checksum", false}, // single digits (checksum only) are not allowed + {"1", "luhn_checksum", false}, + {-10, "luhn_checksum", false}, // negative ints are not allowed + {"abcdefghijklmnop", "luhn_checksum", false}, + } + + validate := New() + + for i, test := range testsUint { + errs := validate.Var(test.value, test.tag) + if test.expected { + if !IsEqual(errs, nil) { + t.Fatalf("Index: %d luhn_checksum failed Error: %s", i, errs) + } + } else { + if IsEqual(errs, nil) { + t.Fatalf("Index: %d luhn_checksum failed Error: %s", i, errs) + } else { + val := getError(errs, "", "") + if val.Tag() != "luhn_checksum" { + t.Fatalf("Index: %d luhn_checksum failed Error: %s", i, errs) + } + } + } + } +} + func TestMultiOrOperatorGroup(t *testing.T) { tests := []struct { Value int `validate:"eq=1|gte=5,eq=1|lt=7"`