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 +20 -0
- data/README.rdoc +55 -0
- data/init.rb +1 -0
- data/lib/has_digest.rb +132 -0
- data/shoulda_macros/has_digest.rb +35 -0
- data/test/has_digest_test.rb +123 -0
- data/test/test_helper.rb +29 -0
- metadata +64 -0
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
|
data/test/test_helper.rb
ADDED
@@ -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
|
+
|