sql store for ruby-openid

Andrew Arrow oneone at gmail.com
Tue Feb 12 07:10:03 PST 2008


I've been running openid in production for a few weeks now and the
db/cstore/nonce directory has WAY too many files in it.  I looked for
the SQL Store class but couldn't find it so I wrote this one.  I tried
to get really good indexes on the two tables so not only would lookups
be fast, but all selecting all the expired ones.  I used datamapper
with these models:

class OpenAssociation < DataMapper::Base
  property :proto,    :string, :size => 255
  property :domain,   :string, :size => 255
  property :url,      :string, :size => 255
  property :handle,   :string, :size => 255
  property :data,     :text

  property :valid_til, :datetime, :index => true

  index([:proto, :domain, :url, :handle], :unique)
  index([:proto, :domain, :url, :valid_til])
end

class OpenNonce < DataMapper::Base
  property :proto,    :string, :size => 255
  property :domain,   :string, :size => 255
  property :url,      :string, :size => 255
  property :salt,     :string, :size => 255

  property :valid_til, :datetime, :index => true

  index([:valid_til, :proto, :domain, :url, :salt], :unique)

end

which make tables like:

CREATE TABLE `open_associations` (
  `id` bigint(20) unsigned NOT NULL auto_increment,
  `proto` varchar(255) collate utf8_unicode_ci default NULL,
  `domain` varchar(255) collate utf8_unicode_ci default NULL,
  `url` varchar(255) collate utf8_unicode_ci default NULL,
  `handle` varchar(255) collate utf8_unicode_ci default NULL,
  `data` text collate utf8_unicode_ci,
  `valid_til` datetime default NULL,
  PRIMARY KEY  (`id`),
  UNIQUE KEY `open_associations_proto_domain_url_handle_index`
(`proto`,`domain`,`url`,`handle`),
  KEY `open_associations_valid_til_index` (`valid_til`),
  KEY `open_associations_proto_domain_url_valid_til_index`
(`proto`,`domain`,`url`,`valid_til`)
) ENGINE=InnoDB

CREATE TABLE `open_nonces` (
  `id` bigint(20) unsigned NOT NULL auto_increment,
  `proto` varchar(255) collate utf8_unicode_ci default NULL,
  `domain` varchar(255) collate utf8_unicode_ci default NULL,
  `url` varchar(255) collate utf8_unicode_ci default NULL,
  `salt` varchar(255) collate utf8_unicode_ci default NULL,
  `valid_til` datetime default NULL,
  PRIMARY KEY  (`id`),
  UNIQUE KEY `open_nonces_valid_til_proto_domain_url_salt_index`
(`valid_til`,`proto`,`domain`,`url`,`salt`),
  KEY `open_nonces_valid_til_index` (`valid_til`)
) ENGINE=InnoDB

Then I modified the filesystem store like so:

require 'openid/util'
require 'openid/store/interface'
require 'openid/store/nonce'
require 'openid/association'

class SqlStore < OpenID::Store::Interface
  @@FILENAME_ALLOWED =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-".split("")

  # store_association(server_url, association)
  # get_association(server_url, handle=nil)
  # remove_association(server_url, handle)
  # use_nonce(server_url, timestamp, salt)
  # cleanup_nonces
  # cleanup_associations
  # cleanup

  def get_new_association(server_url, handle)
    unless server_url.index('://')
      raise ArgumentError, "Bad server URL: #{server_url}"
    end

    oa = OpenAssociation.new
    oa.proto, rest = server_url.split('://', 2)
    oa.domain = filename_escape(rest.split('/',2)[0])
    oa.url = safe64(server_url)
    if handle
     oa.handle = safe64(handle)
    else
      oa.handle = ''
    end
    oa
  end

  def store_association(server_url, association)
    oa = get_new_association(server_url, association.handle)
    oa.data = association.serialize
    oa.valid_til = Time.at(association.issued.to_i + association.lifetime)
    oa.save
  end

  def get_association(server_url, handle=nil)
    oa = get_new_association(server_url, handle)
    if handle
      return _get_association(oa)
    end
    sql = %{ SELECT id FROM open_associations WHERE
             proto='#{oa.proto}' and
             domain='#{oa.domain}' and
             url='#{oa.url}' ORDER BY valid_til }
    last = OpenAssociation.find_by_sql(sql).last
    return _get_association(nil, last) if last
  end

  def _get_association(oa, id=nil)

    if not id
      sql = %{ SELECT id FROM open_associations WHERE
               proto='#{oa.proto}' and
               domain='#{oa.domain}' and
               url='#{oa.url}' and
               handle='#{oa.handle}' }
      id = OpenAssociation.find_by_sql(sql).first
      return if not id
    end

    sql = %{ SELECT data FROM open_associations WHERE
             id=#{id} }
    data = OpenAssociation.find_by_sql(sql).first

    begin
      association = OpenID::Association.deserialize(data)
    rescue
      database.execute("delete from open_associations where id=#{id}")
      return nil
    end

    if association.expires_in == 0
      database.execute("delete from open_associations where id=#{id}")
      return nil
    else
      return association
    end
  end

  def remove_association(server_url, handle)
    assoc = get_association(server_url, handle)

    if assoc.nil?
      return false
    else
      oa = get_new_association(server_url, handle)
      sql = %{ delete FROM open_associations WHERE
               proto='#{oa.proto}' and
               domain='#{oa.domain}' and
               url='#{oa.url}' and
               handle='#{oa.handle}' }
      database.execute(sql)
      return true
    end
  end

  # Return whether the nonce is valid
  def use_nonce(server_url, timestamp, salt)
    return false if (timestamp - Time.now.to_i).abs > OpenID::Nonce.skew

    on = OpenNonce.new
    if server_url and !server_url.empty?
      on.proto, rest = server_url.split('://',2)
    else
      on.proto, rest = '',''
    end
    raise "Bad server URL" unless on.proto && rest

    on.domain = filename_escape(rest.split('/',2)[0])
    on.url = safe64(server_url)
    on.salt = safe64(salt)
    on.valid_til = Time.at(timestamp + OpenID::Nonce.skew)

    on.save
  end

  # Remove expired entries from the database. This is potentially expensive,
  # so only run when it is acceptable to take time.
  def cleanup
    cleanup_associations
    cleanup_nonces
  end

  def cleanup_associations
    sql = %{ delete FROM open_associations WHERE
             valid_til < now() }
    database.execute(sql).affected_rows
  end

  def cleanup_nonces
    sql = %{ delete FROM open_nonces WHERE
             valid_til < now() }
    database.execute(sql).affected_rows
  end

  protected

  # Create a temporary file and return the File object and filename.
  #
  # create a safe filename from a url
  def filename_escape(s)
    s = '' if s.nil?
    filename_chunks = []
    s.split('').each do |c|
      if @@FILENAME_ALLOWED.index(c)
        filename_chunks << c
      else
        filename_chunks << sprintf("_%02X", c[0])
      end
    end
    filename_chunks.join("")
  end

  def safe64(s)
    s = OpenID::CryptUtil.sha1(s)
    s = OpenID::Util.to_base64(s)
    s.gsub!('+', '_')
    s.gsub!('/', '.')
    s.gsub!('=', '')
    return s
  end

end



More information about the Dev mailing list