在Rails中创建唯一标识符的最佳方法是什么?

164

这是我所使用的内容。令牌不一定需要被听到即可猜测,它更像是一个短网址标识符,我希望保持其短小。我遵循了一些在网上找到的示例,在发生冲突的情况下,我认为下面的代码将重新创建令牌,但我并不确定。虽然这感觉有点粗糙,但我很想看到更好的建议。

def self.create_token
    random_number = SecureRandom.hex(3)
    "1X#{random_number}"

    while Tracker.find_by_token("1X#{random_number}") != nil
      random_number = SecureRandom.hex(3)
      "1X#{random_number}"
    end
    "1X#{random_number}"
  end

我的数据库列的令牌是一个唯一索引,我还在模型上使用了validates_uniqueness_of :token,但由于这些令牌是基于用户在应用程序中的操作自动生成批次的(他们下订单并购买令牌),因此让应用程序抛出错误是不可行的。

我可以考虑在结尾附加另一个字符串(例如基于时间生成的字符串),以减少碰撞的可能性,但我不希望令牌变得太长。

12个回答

349

--2022年年底更新--

我回答这个问题已经有一段时间了。事实上,我甚至七年没有看过这个答案。我也看到许多依赖Rails运行其业务的组织使用了这段代码。

说实话,现在我不认为我的早期解决方案或Rails的实现是一个好方法。它使用了回调函数,可能很难调试,并且是悲观的,即使SecureRandom.urlsafe_base64的碰撞几率非常低。这对长期和短期令牌都适用。

我建议一个可能更好的方法是持乐观态度。在所选择的数据库中设置令牌的唯一约束条件,然后尝试保存它。如果保存出现异常,请重试直到成功为止。

class ModelName < ActiveRecord::Base
  def persist_with_random_token!(attempts = 10)
    retries ||= 0
    token = SecureRandom.urlsafe_base64(nil, false)
    save!
  rescue ActiveRecord::RecordNotUnique => e
    raise if (retries += 1) > attempts

    Rails.logger.warn("random token, unlikely collision number #{retries}")
    token = SecureRandom.urlsafe_base64(16, false)
    retry
  end
end

这是什么结果?

  • 由于我们没有事先检查令牌的存在性,因此少了一个查询。
  • 总体而言,速度更快了。
  • 不使用回调函数,使得调试更加容易。
  • 如果发生冲突,则有后备机制。
  • 如果发生冲突,则会记录日志跟踪(指标)
    • 现在是时候清理旧的令牌吗?
    • 或者我们已经达到了需要转到SecureRandom.urlsafe_base64(32, false)的不太可能的记录数量吗?

-- 更新 --

2015年1月9日 起,该解决方案已经在 Rails 5ActiveRecord 安全令牌实现中实施。

-- Rails 4 & 3 --

只是为了以后参考,在使用 Ruby 1.9 和 ActiveRecord 时创建安全随机令牌并确保其唯一性:

class ModelName < ActiveRecord::Base

  before_create :generate_token

  protected

  def generate_token
    self.token = loop do
      random_token = SecureRandom.urlsafe_base64(nil, false)
      break random_token unless ModelName.exists?(token: random_token)
    end
  end

end

编辑:

@kain 建议并且我也同意在这个答案中用 loop do...break unless...end 替换 begin...end..while,因为前一种实现可能会在未来被移除。

编辑2:

对于Rails 4和concerns,我建议将其移至concern中。

# app/models/model_name.rb
class ModelName < ActiveRecord::Base
  include Tokenable
end

# app/models/concerns/tokenable.rb
module Tokenable
  extend ActiveSupport::Concern

  included do
    before_create :generate_token
  end

  protected

  def generate_token
    self.token = loop do
      random_token = SecureRandom.urlsafe_base64(nil, false)
      break random_token unless self.class.exists?(token: random_token)
    end
  end
end

7
由于random_token是在循环内定义的,所以这段精确的代码无法运行。 - Jonathan Mui
1
@Krule 现在你已经把它变成了一个 Concern,难道你不应该在方法中摆脱 ModelName 吗?也许用 self.class 替换它会更好吧?否则,它就不是很可重用了,对吧? - paracycle
@Jashwant,我在这里使用了urlsafe_base64来演示一个原则。据我所知,没有任何理由不使用uuid - Krule
@Krule,感谢您的快速回复。我是Rails的新手,因此很好奇。对于这个很棒的答案,我给一个+1。 - Jashwant
1
该解决方案并未被弃用,Secure Token 仅在 Rails 5 中实现,但无法在 Rails 4 或 Rails 3 中使用(本问题涉及到的版本)。 - Aleks
显示剩余18条评论

51

Ryan Bates在他的beta邀请码Railscast中使用了一小段不错的代码。这会生成一个40个字符的字母数字字符串。

Digest::SHA1.hexdigest([Time.now, rand].join)

3
还不错。通常我在寻找更短的字符串,以便用作URL的一部分。 - Slick23
是的,这至少很容易阅读和理解。在某些情况下(如测试邀请),40个字符很好用,到目前为止,这对我来说运作良好。 - Nate Bird
12
@Slick23,你也可以获取字符串的一部分:Digest::SHA1.hexdigest([Time.now, rand].join)[0..10] - Bijan
1
对于一个32位的IP地址,拥有一个查找表来存储由@thekingoftruth生成的所有可能的hexdigest是相当容易的,因此不要认为即使散列值的子字符串也是不可逆的。 - mwfearnley
@mwfearnley 当然可以。这远非不可逆,我也不会将其用于更重要的混淆中。 - thekingoftruth
显示剩余2条评论

36

这可能是一个迟到的回应,但为了避免使用循环,您也可以通过递归调用方法。对我来说,这看起来和感觉稍微更清洁一些。

class ModelName < ActiveRecord::Base

  before_create :generate_token

  protected

  def generate_token
    self.token = SecureRandom.urlsafe_base64
    generate_token if ModelName.exists?(token: self.token)
  end

end

30

我不确定是否理解了这个代码:if self.new_record? and self.access_token.nil? … 这是在检查令牌是否已经被存储吗? - Slick23
那段代码不是 SQL 查询,而是 Ruby 代码 —— 不管你使用的是哪种数据库都无所谓。就条件语句而言,它只在记录创建时生成一次,但你可以根据你的应用程序选择最合适的方式来实现。 - coreyward
4
在现有的令牌中,您始终需要进行额外的检查。我没有意识到这一点不明显。只需添加 validates_uniqueness_of :token 并在迁移中向表格添加唯一索引即可。请注意不要更改原意。 - coreyward
7
博客文章的作者在这里!是的:我总是添加数据库约束或类似的内容来确保在这种情况下的唯一性。 - Thibaut Barrère
1
对于那些正在寻找已经不存在的帖子的人...https://web.archive.org/web/20121026000606/http://blog.logeek.fr/2009/7/2/creating-small-unique-tokens-in-ruby - King'ori Maina
显示剩余4条评论

17

如果你想要得到唯一的结果,你可以使用类似以下这样的代码:

string = (Digest::MD5.hexdigest "#{ActiveSupport::SecureRandom.hex(10)}-#{DateTime.now.to_s}")

但是这将生成32个字符的字符串。

然而,还有另一种方法:

require 'base64'

def after_create
update_attributes!(:token => Base64::encode64(id.to_s))
end
例如,对于id为10000的情况,生成的令牌将类似于"MTAwMDA="(您可以轻松地解码它以获取id,只需进行制作)。
Base64::decode64(string)

我更关心确保生成的值不会与已经生成和存储的值发生冲突,而不是创建唯一字符串的方法。 - Slick23
生成的值不会与已生成的值发生冲突- base64是确定性的,因此如果您有唯一的ID,您将拥有唯一的令牌。 - Esse
我使用了 random_string = Digest::MD5.hexdigest("#{ActiveSupport::SecureRandom.hex(10)}-#{DateTime.now.to_s}-#{id}")[1..6] 这段代码,其中 ID 是令牌的ID。 - Slick23
12
在我看来,Base64::encode64(id.to_s)似乎违背了使用令牌的目的。很可能你正在使用令牌来隐藏id并使资源对没有该令牌的任何人都无法访问。然而,在这种情况下,有人只需运行Base64::encode64(<insert_id_here>),就可以立即获得您网站上每个资源的所有令牌。 - Sky
需要更改为以下内容才能正常工作:string = (Digest::MD5.hexdigest "#{SecureRandom.hex(10)}-#{DateTime.now.to_s}") - Qasim
如果有人正在寻找类似的东西,但需要加盐,请参见http://hashids.org。 - Felipe Zavan

13

这可能会有帮助:

SecureRandom.base64(15).tr('+/=', '0aZ')

如果你想删除任何特殊字符,则可以将第一个参数设置为'+/=',将任何字符放置在第二个参数'0aZ'中,并且这里的长度为15。

如果你想删除额外的空格和换行符,则可以添加以下内容:

SecureRandom.base64(15).tr('+/=', '0aZ').strip.delete("\n")

希望这能对任何人有所帮助。


3
如果你不想要像“+/=”这样奇怪的字符,你可以使用SecureRandom.hex(10)代替base64。 - Min Ming Lo
17
SecureRandom.urlsafe_base64实现了同样的功能。 - iterion
这个值总是唯一的吗?我需要将它保存在数据库列中并保证唯一性。还需要做其他什么吗? - Krishnadas PC

7

可以尝试这个方法:

从 Ruby 1.9 开始,uuid 生成已经内置。使用 SecureRandom.uuid 函数即可。
在 Ruby 中生成 GUIDs

这对我很有帮助。


6

你可以使用has_secure_token https://github.com/robertomiranda/has_secure_token

这个工具非常简单易用。

class User
  has_secure_token :token1, :token2
end

user = User.create
user.token1 => "44539a6a59835a4ee9d7b112b48cd76e"
user.token2 => "226dd46af6be78953bde1641622497a8"

nicely wrapped! Thanks :D - mswiszcz
1
我收到“未定义的本地变量'has_secure_token'”错误。有什么想法是为什么? - Adrian Matteo
3
我也遇到了同样的问题。据我所知,has_secure_token 是 Rails 5 中新增的功能,但我使用的是 4.x 版本。我按照这篇文章中的步骤操作,现在问题已经解决了。 - Tamara Bernad

5
创建一个合适的、mysql、varchar 32 GUID。
SecureRandom.uuid.gsub('-','').upcase

由于我们正在尝试替换单个字符“-”,因此可以使用tr而不是gsub。SecureRandom.uuid.tr('-','').upcase。请查看此链接以比较tr和gsub之间的区别:https://dev59.com/oV8d5IYBdhLWcg3wgiX4#26750460。 - Sree Raj

2
Rails 7已经内置了此功能。请参见下面的示例:
# Schema: User(token:string, auth_token:string)
class User < ActiveRecord::Base
  has_secure_token
  has_secure_token :auth_token, length: 36
end

user = User.new
user.save
user.token # => "pX27zsMN2ViQKta1bGfLmVJE"
user.auth_token # => "tU9bLuZseefXQ4yQxQo8wjtBvsAfPc78os6R"
user.regenerate_token # => true
user.regenerate_auth_token # => true

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