How do I obfuscate the ids of my records in rails?

2019-01-13 14:36发布

问题:

I'm trying to figure out how to obfuscate the ids of my records in rails.

For example: a typical path might look like http://domain/records/1, so it's pretty easy for people to deduce how much traffic the site is getting if they just create a new record.

One solution that I've used is to hash the id with a salt, but since I'm not sure whether that function is bijective, I end up storing it in another column in my database and double check for uniqueness.

Another option I was thinking about was generating a random hash and storing that as another column. If it isn't unique ... just generate another one.

What's the best way of doing this?

回答1:

You could use the built-in OpenSSL library to encrypt and decrypt your identifiers, that way you would only need to overwrite to_param on your models. You'll also need to use Base64 to convert the encrypted data into plain text. I would stick this in a module so it can be reused:

require 'openssl'
require 'base64'

module Obfuscate
  def self.included(base)
    base.extend self
  end

  def cipher
    OpenSSL::Cipher::Cipher.new('aes-256-cbc')
  end

  def cipher_key
    'blah!'
  end

  def decrypt(value)
    c = cipher.decrypt
    c.key = Digest::SHA256.digest(cipher_key)
    c.update(Base64.decode64(value.to_s)) + c.final
  end

  def encrypt(value)
    c = cipher.encrypt
    c.key = Digest::SHA256.digest(cipher_key)
    Base64.encode64(c.update(value.to_s) + c.final)
  end
end

So now your models would need to look something like this:

class MyModel < ActiveRecord::Base
  include Obfuscate

  def to_param
    encrypt id
  end
end

Then in your controller when you need to find a record by the encrypted id, you would use something like this:

MyModel.find MyModel.decrypt(params[:id])

If you're looking to encrypt/decrypt ids without storing them in the database, this is probably the easiest way to go.



回答2:

Instead of numeric ids, use some kind of friendly url or human readable slug. There are lots of tools to choose from in this department. Not only are they more friendly to your users, but well chosen slugs can give a nice advantage with search engines.



回答3:

It's pretty easy to generate unique random identifiers for your records either using a randomized string generator or a simple call to Digest::SHA1.hexdigest which produces reasonably random and cryptographically unique results.

For instance, you can create a secondary column called ident or unique_id that stores your public identifiers. You can then over-write to_param to use this instead:

class MyModel < ActiveRecord::Base
  before_create :assign_ident

  def self.from_param(ident)
    find_by_ident(ident)
  end

  def to_param
    self.ident
  end

protected
  def assign_ident
    self.ident = Digest::SHA1.hexdigest(SecureRandom.random_number(1<<256).to_s)
  end
end

Theoretically there is a chance of collision on SHA1 but the odds are so astronomically low you're more liable to have a software crash because of a memory error or hardware malfunction. You can test this to see if it suits your needs by generating a few billion identities to see if they ever collide, which they shouldn't. A 256-bit random number should provide a sufficient amount of data for the SHA1 algorithm to chew on.



回答4:

After reading through @siannopollo's post, I created a Gem based on the idea of his post (but with some improvements): https://github.com/pencil/encrypted_id



回答5:

Here's a gem that keeps it numeric, requires no database migrations, and no routing changes: https://github.com/namick/obfuscate_id


I've found that this gem doesn't work in concert with some other gems, notably paper_trail. This is because of the way it replaces the find method, and paper_trail causes find to be called with the actual record id.

So I've been using the gem's "scatter_swap" functionality, but not the rest of it. Here's the model:

require 'obfuscate_id/scatter_swap'

class Page < ActiveRecord::Base
  # This is a random number that, if changed, will invalidate all existing URLs. Don't change it!
  @@obfuscate_spin = # random number here, which is essentially the encryption key

  ##
  # Generate URL parameter to be used in the URL as the "id"
  def to_param
    # Use the obfuscate_id gem's class to "spin" the id into something obfuscated
    spun_id = ScatterSwap.hash(self.id, @@obfuscate_spin)

    # Throw any additional attributes in here that are to be included in the URL.
    "#{spun_id} #{name}".parameterize
  end

  def self.find_by_slug!(slug)
    spun_id = slug[/^[0-9]+/]
    begin
      find_by_id! ScatterSwap.reverse_hash(spun_id, @@obfuscate_spin)
    rescue ActiveRecord::RecordNotFound => e
      raise ActiveRecord::RecordNotFound, "Couldn't find matching Page."
    end
  end
end

And in the controller:

class PagesController < InheritedResources::Base
  # Find the page using its URL slug
  before_filter :find_page, except: [:index, :create, :new]

  def find_page
    @page = Page.find_by_slug! params[:id]

    # If the URL doesn't match exactly, and this is a GET.
    # We'll redirect to the new, correct URL, but if this is a non-GET, let's let them finish their request instead.
    if params[:id] != @page.to_param && request.get?
      redirect_to url_for({ id: @page.to_param }), status: 301
    end
  end
end

As an alternative to the redirection that takes place there, you could simply include a canonical URL in the page. The redirection has the bug of ignoring any query parameters in the URL. This was not a problem for my project, as I didn't have any. But a canonical URL would be better.