matthewtodd-has_digest 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 [name of plugin creator]
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,55 @@
1
+ =HasDigest
2
+
3
+ HasDigest is an +ActiveRecord+ macro that makes it easy to write things like encrypted passwords and api tokens +before_save+. Digest attributes may either stand alone or depend on the values of other attributes. Digests with dependencies are also magically salted for models having a +salt+ attribute.
4
+
5
+ See the documentation for the +has_digest+ method for options.
6
+
7
+
8
+ ==Installation
9
+
10
+ As a gem:
11
+
12
+ config.gem 'matthewtodd-has_digest', :lib => 'has_digest', :source => 'http://gems.github.com'
13
+
14
+ As a plugin:
15
+
16
+ script/plugin install git://github.com/matthewtodd/has_digest.git
17
+
18
+
19
+ ==Usage
20
+
21
+ In your model:
22
+
23
+ class User < ActiveRecord::Base
24
+ has_digest :encrypted_password, :depends => :password
25
+ end
26
+
27
+ In your migrations:
28
+
29
+ class CreateUsers < ActiveRecord::Migration
30
+ def self.up
31
+ create_table :users do |t|
32
+ t.string :salt, :encrypted_password, :limit => 40
33
+ end
34
+ end
35
+
36
+ def self.down
37
+ drop_table :users
38
+ end
39
+ end
40
+
41
+ In your tests:
42
+
43
+ class UserTest < ActiveSupport::TestCase
44
+ should_have_digest :encrypted_password, :depends => :password
45
+ end
46
+
47
+ Back in your model:
48
+
49
+ class User < ActiveRecord::Base
50
+ def authenticate(password)
51
+ encrypted_password == digest(salt, password)
52
+ end
53
+ end
54
+
55
+ Copyright (c) 2008 Matthew Todd, released under the MIT license
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'has_digest'
data/lib/has_digest.rb ADDED
@@ -0,0 +1,132 @@
1
+ require 'digest/sha1'
2
+
3
+ module HasDigest
4
+ def self.included(base) # :nodoc:
5
+ base.before_save :generate_has_digest_attributes
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ # Returns a 40-character hexadecimal token. When no +values+ are given, the
10
+ # token is seeded with a randomized form of the current time. When +values+
11
+ # are given, the token is seeded with them instead, unless any of them
12
+ # evaluate to +false+, in which case the returned token is +nil+.
13
+ #
14
+ # +digest+ is available as an instance method on any of your +ActiveRecord+ models, so you can use it as needed. For example:
15
+ # class User < ActiveRecord::Base
16
+ # def authenticate(password)
17
+ # encrypted_password == digest(salt, password)
18
+ # end
19
+ # end
20
+ def digest(*values)
21
+ if values.empty?
22
+ Digest::SHA1.hexdigest(Time.now.to_default_s.split(//).sort_by { Kernel.rand }.join)
23
+ elsif values.all?
24
+ Digest::SHA1.hexdigest("--#{values.join('--')}--")
25
+ else
26
+ nil
27
+ end
28
+ end
29
+
30
+ def generate_has_digest_attributes # :nodoc:
31
+ if new_record?
32
+ if attribute_names.include?('salt')
33
+ self[:salt] = digest
34
+ end
35
+
36
+ self.class.standalone_has_digest_attributes.each do |name, options|
37
+ self[name] = digest
38
+ end
39
+ end
40
+
41
+ lookup_value = lambda { |dependency| send(dependency) }
42
+ self.class.dependent_has_digest_attributes.each do |name, options|
43
+ dependencies = options[:dependencies]
44
+ synthetic_dependencies = options[:synthetic_dependencies]
45
+
46
+ if synthetic_dependencies.all?(&lookup_value)
47
+ self[name] = digest(*dependencies.map(&lookup_value))
48
+ end
49
+ end
50
+ end
51
+
52
+ module ClassMethods
53
+ # Gives the class it is called on a +before_save+ callback that writes a
54
+ # 40-character hexadecimal string into the given +attribute+. The
55
+ # generated string may depend on other (possibly synthetic) attributes of
56
+ # the model, being automatically regenerated when they change. One key is
57
+ # supported in the +options+ hash:
58
+ # * +depends+: either a single attribute name or a list of attribute
59
+ # names. If any of these values change, +attribute+ will be re-written.
60
+ # Setting any (non-synthetic) one of these attributes to +nil+ will
61
+ # effectively also set +attribute+ to +nil+.
62
+ #
63
+ # ===Magic Salting
64
+ # If the model in question has a +salt+ attribute, its +salt+ be
65
+ # automatically populated on create and automatically mixed into any
66
+ # digests with dependencies on other attributes, saving you a little bit
67
+ # of work when dealing with passwords.
68
+ #
69
+ # ===Magic Synthetic Attributes
70
+ # If the model in question doesn't have a database column for one of your
71
+ # digest dependencies, an +attr_accessor+ for that synthetic dependency
72
+ # will be created automatically. For example, if you write <tt>has_digest
73
+ # :encrypted_password, :depends => :password</tt> and don't have a
74
+ # +password+ column for your model, the +attr_accessor+ for +password+
75
+ # will be automatically created, saving you a redundant line of code.
76
+ #
77
+ # ===Examples
78
+ # # token will be generated on create
79
+ # class Order < ActiveRecord::Base
80
+ # has_digest :token
81
+ # end
82
+ #
83
+ # # encrypted_password will be generated on save whenever @password is not nil
84
+ # # (Automatically calls attr_accessor :password.)
85
+ # class User < ActiveRecord::Base
86
+ # has_digest :encrypted_password, :depends => :password
87
+ # end
88
+ #
89
+ # # remember_me_token will be generated on save whenever login or remember_me_until have changed.
90
+ # # User.update_attributes(:remember_me_until => nil) will set remember_me_token to nil.
91
+ # class User < ActiveRecord::Base
92
+ # has_digest :remember_me_token, :depends => [:login, :remember_me_until]
93
+ # end
94
+ #
95
+ # # api_token will be blank until user.update_attributes(:generate_api_token => true).
96
+ # # (Automatically calls attr_accessor :generate_api_token.)
97
+ # class User < ActiveRecord::Base
98
+ # has_digest :api_token, :depends => :generate_api_token
99
+ # end
100
+ def has_digest(attribute, options = {})
101
+ options.assert_valid_keys(:depends)
102
+
103
+ if options[:depends]
104
+ dependencies = []
105
+ dependencies << :salt if column_names.include?('salt')
106
+ dependencies << options[:depends]
107
+ dependencies.flatten!
108
+
109
+ synthetic_dependencies = dependencies - column_names.map(&:to_sym)
110
+ synthetic_dependencies.each { |name| attr_accessor name }
111
+
112
+ write_inheritable_hash :has_digest_attributes, attribute => { :dependencies => dependencies, :synthetic_dependencies => synthetic_dependencies }
113
+ else
114
+ write_inheritable_hash :has_digest_attributes, attribute => {}
115
+ end
116
+ end
117
+
118
+ def has_digest_attributes # :nodoc:
119
+ read_inheritable_attribute(:has_digest_attributes) || write_inheritable_attribute(:has_digest_attributes, {})
120
+ end
121
+
122
+ def dependent_has_digest_attributes # :nodoc:
123
+ has_digest_attributes.reject { |name, options| !options.has_key?(:dependencies) }
124
+ end
125
+
126
+ def standalone_has_digest_attributes # :nodoc:
127
+ has_digest_attributes.reject { |name, options| options.has_key?(:dependencies) }
128
+ end
129
+ end
130
+ end
131
+
132
+ ActiveRecord::Base.send(:include, HasDigest)
@@ -0,0 +1,35 @@
1
+ module HasDigest
2
+ module Shoulda
3
+ # Asserts that <tt>has_digest :name</tt> has been called with the given
4
+ # options, and that the necessary database columns are present. +options+
5
+ # may contain two keys:
6
+ # * +depends+: either a single attribute name or an array of attribtues
7
+ # names. (Specifying <tt>:salt</tt> here is unnecessary.)
8
+ # * +limit+: if your db column for the given digest doesn't have
9
+ # <tt>:limit => 40</tt>, you may specify its size here.
10
+ def should_have_digest(name, options = {})
11
+ options.assert_valid_keys(:depends, :limit)
12
+
13
+ context "#{model_class.name} with has_digest :#{name}" do
14
+ should_have_db_column name, :type => :string, :limit => (options[:limit] || 40)
15
+
16
+ should "generate digest for :#{name}" do
17
+ assert_not_nil self.class.model_class.has_digest_attributes[name]
18
+ end
19
+
20
+ if options[:depends]
21
+ dependencies = options[:depends]
22
+ dependencies = [dependencies] unless dependencies.respond_to?(:each)
23
+ dependencies.unshift(:salt) if model_class.column_names.include?('salt')
24
+
25
+ should "generate digest for :#{name} from #{dependencies.to_sentence}" do
26
+ attributes = self.class.model_class.has_digest_attributes[name] || {}
27
+ assert_equal dependencies, attributes[:dependencies]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ Test::Unit::TestCase.extend(HasDigest::Shoulda)
@@ -0,0 +1,123 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ class HasDigestTest < Test::Unit::TestCase
4
+ context 'Model with a standalone digest' do
5
+ setup do
6
+ @klass = model_with_attributes(:token) do
7
+ has_digest :token
8
+ end
9
+ end
10
+
11
+ context 'instance' do
12
+ setup { @instance = @klass.new }
13
+
14
+ context 'save' do
15
+ setup { @instance.save }
16
+ should_change '@instance.token', :to => /^\w{40}$/
17
+
18
+ context 'save again' do
19
+ setup { @instance.save }
20
+ should_not_change '@instance.token'
21
+ end
22
+ end
23
+
24
+ should 'not rely on the default date format' do
25
+ default = Time::DATE_FORMATS[:default]
26
+ Time::DATE_FORMATS[:default] = '.'
27
+ begin
28
+ assert_unique((1..1000).collect { @instance.digest })
29
+ ensure
30
+ Time::DATE_FORMATS[:default] = default
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ context 'Model with a single-attribute-based digest' do
37
+ setup do
38
+ @klass = model_with_attributes(:encrypted_password) do
39
+ has_digest :encrypted_password, :depends => :password
40
+ end
41
+ end
42
+
43
+ context 'saved instance' do
44
+ setup { @instance = @klass.create(:password => 'PASSWORD') }
45
+
46
+ should 'have digested attribute' do
47
+ assert_not_nil @instance.encrypted_password
48
+ end
49
+
50
+ context 'saved again' do
51
+ setup { @instance.save }
52
+ should_not_change '@instance.encrypted_password'
53
+ end
54
+
55
+ context 'updated' do
56
+ setup { @instance.update_attributes(:password => 'NEW PASSWORD') }
57
+ should_change '@instance.encrypted_password'
58
+ end
59
+
60
+ context 'loaded completely fresh' do
61
+ setup { @instance = @klass.find(@instance.id) }
62
+
63
+ context 'and saved' do
64
+ setup { @instance.save }
65
+ should_not_change '@instance.encrypted_password'
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ context 'Model with a multiple-attribute-based digest' do
72
+ setup do
73
+ @klass = model_with_attributes(:login, :remember_me_token, :remember_me_until) do
74
+ has_digest :remember_me_token, :depends => [:login, :remember_me_until]
75
+ end
76
+ end
77
+
78
+ context 'saved instance' do
79
+ setup { @instance = @klass.create(:login => 'bob', :remember_me_until => 2.weeks.from_now) }
80
+
81
+ should 'have digested attribute' do
82
+ assert_not_nil @instance.remember_me_token
83
+ end
84
+
85
+ context 'update one attribute' do
86
+ setup { @instance.update_attributes(:remember_me_until => 3.weeks.from_now) }
87
+ should_change '@instance.remember_me_token'
88
+ end
89
+
90
+ context 'update one attribute to nil' do
91
+ setup { @instance.update_attributes(:remember_me_until => nil) }
92
+
93
+ should 'change digested attribute to nil' do
94
+ assert_nil @instance.remember_me_token
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ context 'Model with a magic salt column and an attribute-based digest' do
101
+ setup do
102
+ @klass = model_with_attributes(:salt, :encrypted_password) do
103
+ has_digest :encrypted_password, :depends => :password
104
+ end
105
+ end
106
+
107
+ context 'saved instance' do
108
+ setup { @instance = @klass.create(:password => 'PASSWORD') }
109
+
110
+ should 'have digested salt attribute' do
111
+ assert_not_nil @instance.salt
112
+ end
113
+
114
+ should 'have digested encrypted_password attribute' do
115
+ assert_not_nil @instance.encrypted_password
116
+ end
117
+
118
+ should 'have used salt to digest encrypted password' do
119
+ assert_equal @instance.digest(@instance.salt, 'PASSWORD'), @instance.encrypted_password
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,29 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+ require 'mocha'
5
+ require 'active_record'
6
+ require File.join(File.dirname(__FILE__), '..', 'lib', 'has_digest.rb')
7
+
8
+ ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', :database => ':memory:')
9
+
10
+ def model_with_attributes(*attributes, &block)
11
+ ActiveRecord::Base.connection.create_table :models, :force => true do |table|
12
+ attributes.each do |attribute|
13
+ table.string attribute
14
+ end
15
+ end
16
+
17
+ ActiveRecord::Base.send(:include, HasDigest)
18
+ Object.send(:remove_const, 'Model') rescue nil
19
+ Object.const_set('Model', Class.new(ActiveRecord::Base))
20
+ Model.class_eval(&block) if block_given?
21
+
22
+ return Model
23
+ end
24
+
25
+ class Test::Unit::TestCase
26
+ def assert_unique(collection)
27
+ assert_same_elements collection.uniq, collection
28
+ end
29
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: matthewtodd-has_digest
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matthew Todd
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2008-11-11 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description:
17
+ email: matthew.todd@gmail.com
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files:
23
+ - README.rdoc
24
+ files:
25
+ - README.rdoc
26
+ - MIT-LICENSE
27
+ - init.rb
28
+ - lib/has_digest.rb
29
+ - shoulda_macros/has_digest.rb
30
+ - test/has_digest_test.rb
31
+ - test/test_helper.rb
32
+ has_rdoc: true
33
+ homepage:
34
+ post_install_message:
35
+ rdoc_options:
36
+ - --main
37
+ - README.rdoc
38
+ - --title
39
+ - has_digest-0.1.0
40
+ - --inline-source
41
+ - --line-numbers
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: "0"
49
+ version:
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: "0"
55
+ version:
56
+ requirements: []
57
+
58
+ rubyforge_project:
59
+ rubygems_version: 1.2.0
60
+ signing_key:
61
+ specification_version: 2
62
+ summary: Rails plugin that helps encrypt passwords and generate api tokens before save.
63
+ test_files: []
64
+