生成独特且难以猜测的“优惠券”代码

20
我的Rails应用需要为用户生成电子优惠券。每张优惠券都应该有一个可在我们系统中兑换的唯一优惠码。
例如,赠送一张免费墨西哥卷饼的优惠券。用户A收到了一张免费墨西哥卷饼的优惠券,然后用户B也收到了一张免费墨西哥卷饼的优惠券。这2张优惠券应具有独特的优惠码。
最好的方法是如何生成这样的代码,以防被轻易伪造?我不希望用户在输入随机数字后能够成功兑换他人的优惠券。
我想,可以像一个背面有唯一编号的礼品卡一样考虑这个问题。

代码是用于机器使用(例如在URL中)还是应该手动从打印输出中输入,或者介于两者之间(您希望用户成功地剪切和粘贴,而不会发现它很困难)? - Neil Slater
@NeilSlater 从打印出来的纸张上手动输入。 - Deekor
Eric Lippert展示了如何使用乘法逆元来实现这一点。请参见乘法逆元的实际用途。生成逆元后,您可以使用Base 64或Base 36进行编码,或者使用任何您喜欢的编码方式。 - Jim Mischel
8个回答

45
代码需要保证难以猜测,因为在给用户奖励之前,您唯一可以执行的验证是检查他们输入的代码是否存在于您的“发行”代码列表中。
这意味着,在该格式中所有可能的代码数量要比您想要发行的代码数量多得多。根据尝试代码的容易程度(考虑重复尝试的脚本),您可能需要所有可能的代码数量超过发行代码的数量一百万倍、十亿倍或更多。尽管这听起来高大上,但在相对较短的字符串中也是可能的。
这还意味着您使用的代码必须在所有可能的代码中尽可能随机地选择。这是为了避免用户发现大多数有效代码以“AAA”开头等规律。更加复杂的用户可能会察觉到您的“随机”代码使用了可以被黑客攻击的可篡改伪随机数生成器(Ruby 的默认 rand() 函数对于随机数据而言是快速且具有良好的统计特性,但却可以通过此种方式被攻击,所以不要使用它)。
这样一个安全代码的起点将是来自加密 PRNG 的输出。Ruby 有 securerandom 库,您可以使用它来获取原始代码,例如:
require 'securerandom'
SecureRandom.hex
# => "78c231af76a14ef9952406add6da5d42"

这段代码足够长,可以覆盖实际数量的优惠券(每个人都有数百万份),没有任何重复或易猜测的机会。但是,从物理副本输入有点麻烦。
一旦您知道如何生成随机且几乎无法猜测的代码,下一个问题是了解用户体验并决定在可用性方面可以牺牲多少安全性。 您需要考虑最终用户的价值,因此某些人可能会尝试获取有效代码的难度。我无法回答您的疑问,但可以就可用性提出一些一般性建议:
- 避免模棱两可的字符。例如,在印刷品上,有时很难区分 1、I 和 l 的区别。我们通常可以根据上下文理解它应该是什么,但是随机字符串没有这个上下文。如果通过测试 0 vs O、5 vs S 等来尝试几种变化,将会带来不好的用户体验。 - 只使用大小写字母中的一种,而不是同时使用。大小写敏感性将不会被某些用户理解或遵循。 - 匹配代码时接受变化。允许空格和破折号。甚至可以允许 0 和 O 具有相同的含义。这最好通过处理输入文本使其处于正确状态(例如大小写、去除分隔符等)来完成。 - 在印刷品上,将代码分成几个小部分,这样用户就可以更轻松地找到自己在字符串中的位置并一次性键入几个字符。 - 不要使代码太长。我建议使用 12 个字符,在 3 组 4 个字符中。 - 这里有一个有趣的方法 - 您可能想要扫描代码以查找可能的粗鲁单词,或避免会生成它们的字符。如果您的代码只包含 K、U、F 和 C 这些字符,则很可能会冒犯用户。通常情况下,这不是问题,因为用户看不到大多数计算机安全代码,但这些代码将会打印出来!
将所有这些结合起来,这就是我可能会生成可用代码的方式:
# Random, unguessable number as a base20 string
#  .rjust(12, '0') covers for unlikely, but possible small numbers
#  .reverse ensures we don't use first character (which may not take all values)
raw = SecureRandom.random_number( 2**80 ).to_s( 20 ).rjust(12, '0').reverse
# e.g. "3ecg4f2f3d2ei0236gi"


# Convert Ruby base 20 to better characters for user experience
long_code = raw.tr( '0123456789abcdefghij', '234679QWERTYUPADFGHX' )
# e.g. "6AUF7D4D6P4AH246QFH"


# Format the code for printing
short_code = long_code[0..3] + '-' + long_code[4..7] + '-' + long_code[8..11]
# e.g. "6AUF-7D4D-6P4A"

这个格式中有 20**12 个有效的代码,这意味着你可以发行十亿个自己的代码,并且用户仅有四百万分之一的概率猜对一个。在密码学领域,这是非常糟糕的(该代码对于快速本地攻击是不安全的),但对于提供免费墨西哥卷饼给注册用户的网页表单来说,在你注意到有人用脚本尝试四百万次后,这是可以接受的。


你的 raw_string 最多有19个字符。当 random_number 方法返回 2 时会发生什么? - wuarmin
1
@wuarmin:虽然很不可能,但你对此感到担忧是正确的。通常有更好的方式来进行生成,这样你就可以得到你想要的完全随机性以及一些带有任何前导零的字符串填充(在例子中将变为尾随的“2”)。 - Neil Slater
1
@wuarmin:我已经破解了它,即使随机数生成器给出一个不可行的小起始数字,它仍然可以工作。不过现在有更好的Ruby方式来实现这个功能。 - Neil Slater
1
@wuarmin 我刻意选择了一组目标可打印字母,它们在从页面上阅读时很难混淆。我没有选择“C”,因为我还有“F”和“U”,不想随机冒犯每 10,000 人中的一个。具体列表并不太重要,你可以做出不同的选择。具体映射根本不重要 - 保留字母没有任何好处,反正是随机的。 - Neil Slater
1
@wuarmin,我写的是“400万分之1”(而不是你所引用的“百万分之4”),2.44*10**-7大约等于1/4000000。 - Neil Slater
显示剩余2条评论

9

最近我写了一个名为coupon-code gem的东西,它做的事情与Algorithm::CouponCode CPAN模块相同。

优惠券代码不仅应该是唯一的,而且在保持安全性的同时易于阅读和输入。Neil的解释和解决方案非常好。这个gem提供了一种方便的方法来实现它,并附带了一个验证功能。

>> require 'coupon_code'
>> code = CouponCode.generate
=> "1K7Q-CTFM-LMTC"
>> CouponCode.validate(code)
=> "1K7Q-CTFM-LMTC"
>> CouponCode.validate('1K7Q-CTFM-LMTO') # Invalid code
=> nil

5
创建难以猜测的优惠券代码的关键在于具有大量可能的代码空间,而实际有效的代码只占其中一小部分。例如,我们可以考虑由8个字符长的字母数字字符串组成:
字母数字 = 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ - 共63个字符
在这种情况下,共有63^8 = 248155780267521 种可能的代码。这意味着如果您发行10亿个代码,猜测代码的概率将是10^9/63^8 = 0.000004... - 百万分之四。
然而,这并不能阻止人们运行脚本一直尝试直到找到有效的代码。为了防止这种暴力攻击,您需要对每个用户的尝试次数进行计数,并在达到某个限制时禁止其继续尝试。
如果您正在寻找一个库,能够全面定制输出优惠券代码(长度、字符集、前缀、后缀和模式),请查看voucher-code-generator-js - 一个用JavaScript编写的库。以下是使用示例:
voucher_codes.generate({
    length: 8,
    count: 1000,
});

它将生成1000个随机且唯一的代码,每个代码长度为8个字符。
另一个例子:
voucher_codes.generate({
    pattern: "###-###-###",
    count: 1000,
});

它将根据给定的模式生成1000个随机独特代码。 源代码相对简单。如果JS不是您最喜欢的语言,我敢打赌您可以轻松地将其重写为任何其他语言 ;) 如果您需要全面的优惠券代码管理解决方案(包括防止暴力攻击),您可能会对Voucherify感兴趣。

1
使用类似以下的内容:

去尝试一些东西:

class Coupon < ActiveRecord::Base
  before_save generate_token

  validates_uniqueness_of :token

  def generate_token
    self.token = "#{current_user.id}#{SecureRandom.urlsafe_base64(3)}"
  end

end

编辑:这里是更好的解答

0
获取一个时期时间戳并进行基本编码,只要您在某个位置保存了记录,就可以在使用时比较其有效性。
如果您需要可以手动输入,您始终可以将其缩减为前8个字符或更少的字符。

0

我曾经遇到过一个类似的用例,需要为系统中创建的每个对象(在这个问题中是优惠券)生成一个唯一/不重复的代码。我的要求如下:

  • 我希望代码的长度尽可能短。
  • 我意识到,代码的长度最终将至少与确定可能对象数量计数的数字位数一样长。例如,如果您生成了9999张优惠券,则代码实际上必须至少为4位数。
  • 不能是连续的/容易被猜测的。

我探索了几种生成密钥的方法,包括基于时间戳的方法,并发现大多数方法都会生成较长的代码。因此,我决定采用以下自己的逻辑。

  • 我创建了一个数据库表,其中只创建了一条记录,用于维护系统中迄今为止创建的对象数量。
  • 然后,我从[a-zA-Z0-9]中随机选择一个字符作为前缀和后缀,将其与该数字连接起来。这一步确保即使数字是顺序的,也不可能猜测代码,除非猜测出前缀和后缀。基于[a-zA-Z0-9]字符集,有3782(62*61)种可能的代码。上述字符集适合我使用,但您可以自由选择字符集。此主题的最佳答案中提供了一些建议。
  • 每次创建新对象时,在数据库中增加一个对象计数。

在这种方法中,代码的字符数将由以下因素确定:

number of characters of ( count of objects in the system so far ) + 2

所以,当你开始时字符数量为3,当你达到10个对象时它将变成4,当你达到100个对象时它将变成5,对于1000个对象它将变成6,以此类推。这样系统将根据使用情况自动扩展。

这种方法比先生成代码然后检查代码是否已经存在于数据库中的情况更好。在那种情况下,你需要一直生成代码直到找到一个尚未生成的代码。


0

你可以使用随机数,并检查它是否之前未生成过,通过将所有有效代码存储在数据库中。


一旦数据库变得庞大,那不会花费很多时间吗? - Deekor
@Deekor:你需要编写代码来存储已经使用过的代码。如果你使用大的随机数,碰撞会相当罕见。 - MrSmith42
在这种情况下,“罕见”并不够好。随机性并不直观:碰撞可能会在十年后发生,也可能在10次抽样后发生,这是(伪)随机函数的典型属性。数据库通常非常擅长查找重复项。我认为任何关系型数据库管理系统中唯一键的代码都经过了优化,以至于普通应用程序开发人员没有机会。将它们存储在受唯一索引保护的表列中。 - theking2

0

使用经过验证的生成器(http://en.wikipedia.org/wiki/List_of_pseudorandom_number_generators)生成随机数。

假设您每天发放333张优惠券,有效期为30天。所以您需要存储10000个数字,并确保一个伪造者无法偶然找到其中一个。

如果您的数字有10个有效数字(约32位,约8个十六进制数字),这种事件的概率为一百万分之一。当然您可以使用更多。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接