salt-and-pepper 0.2.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +4 -0
- data/Gemfile +4 -0
- data/README.textile +138 -0
- data/Rakefile +4 -0
- data/lib/salt-and-pepper.rb +12 -0
- data/lib/salt_pepper/hashed_string.rb +99 -0
- data/lib/salt_pepper/model_extensions.rb +82 -0
- data/lib/salt_pepper/random.rb +46 -0
- data/lib/version.rb +3 -0
- data/salt-and-pepper.gemspec +24 -0
- data/spec/hashed_string_spec.rb +151 -0
- data/spec/model_extensions_spec.rb +275 -0
- data/spec/random_spec.rb +79 -0
- data/spec/spec_helper.rb +1 -0
- metadata +112 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.textile
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
h1. Salt and Pepper
|
2
|
+
|
3
|
+
Provides automatic password hashing for ActiveRecord (>= 3.0) and a couple of methods for generating random strings, tokens, etc.
|
4
|
+
Features:
|
5
|
+
|
6
|
+
* Mark columns for auto-hashing with a single line of code.
|
7
|
+
* Automatic salting of hashes. No separate column is required for the salt.
|
8
|
+
* Does not break validations on the hashed columns (only a small change is required).
|
9
|
+
* Super easy hash verification.
|
10
|
+
* Tested using RSpec 2
|
11
|
+
|
12
|
+
<code>Digest::SHA256</code> is used for hashing and <code>ActiveRecord::SecureRandom</code> is used for generating random stuff.
|
13
|
+
|
14
|
+
h2. Installation
|
15
|
+
|
16
|
+
Just add it to your Gemfile and run <code>bundle install</code>:
|
17
|
+
|
18
|
+
<pre>
|
19
|
+
gem "salt-and-pepper"
|
20
|
+
</pre>
|
21
|
+
|
22
|
+
h2. Usage
|
23
|
+
|
24
|
+
To enable automatic hashing for a column, call <code>hash_column</code> in your model:
|
25
|
+
|
26
|
+
<pre>
|
27
|
+
class User < ActiveRecord::Base
|
28
|
+
hash_column :password
|
29
|
+
end
|
30
|
+
</pre>
|
31
|
+
|
32
|
+
You can specify multiple columns in one line or in separate lines:
|
33
|
+
|
34
|
+
<pre>
|
35
|
+
class User < ActiveRecord::Base
|
36
|
+
hash_column :password, :security_token
|
37
|
+
|
38
|
+
# or
|
39
|
+
hash_column :password
|
40
|
+
hash_column :security_token
|
41
|
+
end
|
42
|
+
</pre>
|
43
|
+
|
44
|
+
h3. Options
|
45
|
+
|
46
|
+
You can pass the <code>:length</code> option to change the length of the stored hash.
|
47
|
+
Numbers between 96 and 192 are accepted, the default value is 128. Make sure the database column can store a string that long!
|
48
|
+
|
49
|
+
<pre>
|
50
|
+
# set length for both columns
|
51
|
+
hash_column :password, :security_token, :length => 100
|
52
|
+
|
53
|
+
# or adjust them individually
|
54
|
+
hash_column :password, :length => 160
|
55
|
+
hash_column :secret, :length => 120
|
56
|
+
</pre>
|
57
|
+
|
58
|
+
By default, blank _(= empty or whitespace-only)_ strings will be converted to <code>nil</code>, and will not be hashed.
|
59
|
+
If you _really_ want blank strings to be hashed, use the <code>:hash_blank_strings</code> option:
|
60
|
+
|
61
|
+
<pre>
|
62
|
+
# Default behavior:
|
63
|
+
# nil => nil
|
64
|
+
# empty string => nil
|
65
|
+
# whitespace-only string => nil
|
66
|
+
|
67
|
+
hash_column :password, :hash_blank_strings => true
|
68
|
+
|
69
|
+
# New behavior:
|
70
|
+
# nil => nil
|
71
|
+
# empty string => 77c0a93ad8e5f42cf676...
|
72
|
+
# whitespace-only string => 1b8d091174299844b1a4...
|
73
|
+
</pre>
|
74
|
+
|
75
|
+
h3. Verification
|
76
|
+
|
77
|
+
Just compare the two values:
|
78
|
+
|
79
|
+
<pre>
|
80
|
+
if @user.password == "secret"
|
81
|
+
# password is valid
|
82
|
+
end
|
83
|
+
</pre>
|
84
|
+
|
85
|
+
A full example:
|
86
|
+
|
87
|
+
<pre>
|
88
|
+
# app/models/user.rb
|
89
|
+
class User < ActiveRecord::Base
|
90
|
+
hash_column :password
|
91
|
+
end
|
92
|
+
|
93
|
+
# app/controllers/sessions_controller.rb
|
94
|
+
def create
|
95
|
+
@user = User.find_by_username(params[:username])
|
96
|
+
if @user.present? && @user.password == params[:password]
|
97
|
+
# login user here
|
98
|
+
else
|
99
|
+
redirect_to new_session_path, :alert => "Invalid username or password."
|
100
|
+
end
|
101
|
+
end
|
102
|
+
</pre>
|
103
|
+
|
104
|
+
h3. Validating hashed columns
|
105
|
+
|
106
|
+
Salt and Pepper provides the <code>validate_[column]?</code> method for deciding whether validations on the column should be performed.
|
107
|
+
Use it to prevent running your sophisticated length-checking algorithms on a 128-character hash :). Skipping validation of hashed values is safe because they were already checked at the time they were set.
|
108
|
+
|
109
|
+
<pre>
|
110
|
+
class User < ActiveRecord::Base
|
111
|
+
encrypt :password
|
112
|
+
validates :password, :length => { :within => 6..100 }, :if => :validate_password?
|
113
|
+
end
|
114
|
+
</pre>
|
115
|
+
|
116
|
+
h3. Generating random stuff
|
117
|
+
|
118
|
+
Salt and Pepper has a couple of handy methods for generating random numbers, codes, tokens, etc:
|
119
|
+
|
120
|
+
<pre>
|
121
|
+
SaltPepper.number(6) # => 4 (identical to: 0..5)
|
122
|
+
SaltPepper.number(10..20) # => 11
|
123
|
+
SaltPepper.alpha_code # => "SNPBJSDG"
|
124
|
+
SaltPepper.alpha_code(4) # => "FKNP"
|
125
|
+
SaltPepper.numeric_code # => "01570475"
|
126
|
+
SaltPepper.numeric_code(20) # => "70110124996934848762"
|
127
|
+
SaltPepper.code # => "29Y3WSEC" (alphanumeric)
|
128
|
+
SaltPepper.code(5) # => "89U1F"
|
129
|
+
SaltPepper.code(10, 'a'..'z') # => "mqxeozlelw"
|
130
|
+
SaltPepper.code(15, (0..1).to_a + ('a'..'b').to_a) # => "0ab1b0b1b01a0a1"
|
131
|
+
SaltPepper.token # => "a0d5828f79e9e22dbc1f896e49f8183a"
|
132
|
+
SaltPepper.token(16) # => "caa4a085edb19499"
|
133
|
+
</pre>
|
134
|
+
|
135
|
+
h2. License
|
136
|
+
|
137
|
+
Released under the MIT license.
|
138
|
+
Copyright (C) Máté Solymosi 2011
|
data/Rakefile
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
require "active_support"
|
2
|
+
require "active_support/core_ext"
|
3
|
+
require "active_record"
|
4
|
+
|
5
|
+
require "salt_pepper/random"
|
6
|
+
require "salt_pepper/hashed_string"
|
7
|
+
require "salt_pepper/model_extensions"
|
8
|
+
|
9
|
+
module SaltPepper
|
10
|
+
class ArgumentError < ArgumentError; end
|
11
|
+
class ValueHashedError < ArgumentError; end
|
12
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
module SaltPepper
|
2
|
+
class HashedString
|
3
|
+
|
4
|
+
DefaultOptions = { :length => 128 }
|
5
|
+
Length = 96..192
|
6
|
+
|
7
|
+
attr_reader :hsh, :salt
|
8
|
+
|
9
|
+
def initialize(str, options = DefaultOptions)
|
10
|
+
raise ArgumentError, "Input must be a String" unless str.is_a?(String)
|
11
|
+
options.reverse_merge! DefaultOptions
|
12
|
+
HashedString.check_options! options
|
13
|
+
@salt = SaltPepper::Random.salt(options[:length])
|
14
|
+
@hsh = HashedString.hsh(str, @salt)
|
15
|
+
end
|
16
|
+
|
17
|
+
def to_s
|
18
|
+
""
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_yaml
|
22
|
+
result
|
23
|
+
end
|
24
|
+
|
25
|
+
def result
|
26
|
+
hsh + salt
|
27
|
+
end
|
28
|
+
|
29
|
+
def inspect
|
30
|
+
result.inspect
|
31
|
+
end
|
32
|
+
|
33
|
+
def ==(obj)
|
34
|
+
return self.eql?(obj) if obj.is_a?(HashedString)
|
35
|
+
return HashedString.hsh(obj, @salt) == @hsh if obj.is_a?(String)
|
36
|
+
false
|
37
|
+
end
|
38
|
+
|
39
|
+
def eql?(other)
|
40
|
+
result == other.result
|
41
|
+
end
|
42
|
+
|
43
|
+
def set_attributes(h, s)
|
44
|
+
@hsh, @salt = h, s
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.from_hash(h)
|
48
|
+
raise ArgumentError, "Hash must be a String or a HashedString" unless h.is_a?(String) || h.is_a?(HashedString)
|
49
|
+
h = h.result if h.is_a?(HashedString)
|
50
|
+
raise ArgumentError, "Length should be within #{Length.inspect}" unless Length.include?(h.length)
|
51
|
+
hs = h[0...64]
|
52
|
+
sl = h[64...h.length]
|
53
|
+
n = HashedString.new("")
|
54
|
+
n.set_attributes(hs, sl)
|
55
|
+
n
|
56
|
+
end
|
57
|
+
|
58
|
+
def length
|
59
|
+
self.class.raise_value_hashed_error
|
60
|
+
end
|
61
|
+
|
62
|
+
def =~(regexp)
|
63
|
+
self.class.raise_value_hashed_error
|
64
|
+
end
|
65
|
+
|
66
|
+
protected
|
67
|
+
|
68
|
+
def self.hsh(password, salt)
|
69
|
+
Digest::SHA256.hexdigest(password + salt)
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def self.check_options!(options)
|
75
|
+
options.each do |name, value|
|
76
|
+
case name.to_s
|
77
|
+
when "length" then
|
78
|
+
raise ArgumentError, "Length should be a Fixnum" unless value.is_a?(Fixnum)
|
79
|
+
raise ArgumentError, "Length should be within #{Length.inspect}" unless Length.include?(value)
|
80
|
+
else
|
81
|
+
raise ArgumentError, "Invalid option: #{name.to_s}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
true
|
85
|
+
end
|
86
|
+
|
87
|
+
def self.raise_value_hashed_error
|
88
|
+
raise SaltPepper::ValueHashedError, "This attribute is currently hashed, so it cannot be accessed or validated. Add :if => :validate_[column name]? to your validator to prevent it from running when the attribute is hashed."
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
class String
|
95
|
+
def ==(obj)
|
96
|
+
return obj == self if obj.is_a?(SaltPepper::HashedString)
|
97
|
+
super
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module SaltPepper
|
2
|
+
module ModelExtensions
|
3
|
+
|
4
|
+
DefaultHashColumnOptions = { :length => SaltPepper::HashedString::DefaultOptions[:length], :hash_blank_strings => false }
|
5
|
+
|
6
|
+
module ClassMethods
|
7
|
+
|
8
|
+
def hashed_columns
|
9
|
+
read_inheritable_attribute :hashed_columns
|
10
|
+
end
|
11
|
+
|
12
|
+
def hash_column(*args)
|
13
|
+
options = args.extract_options!
|
14
|
+
options.keys.each { |k| raise ArgumentError, "Invalid option: #{k.to_s}" unless DefaultHashColumnOptions.keys.include?(k.to_sym) }
|
15
|
+
raise ArgumentError, "No columns specified" if args.empty?
|
16
|
+
raise ArgumentError, "Primary key cannot be hashed" if (["id", self.primary_key] - (args.map { |a| a.to_s })).length < 2
|
17
|
+
args.each do |arg|
|
18
|
+
raise ArgumentError, "Column name should be a symbol or a string" unless arg.is_a?(String) || arg.is_a?(Symbol)
|
19
|
+
raise ArgumentError, "'#{arg.to_s}' is not a valid column name" unless self.column_names.include?(arg.to_s)
|
20
|
+
|
21
|
+
write_inheritable_hash(:hashed_columns, { arg.to_sym => options.symbolize_keys.reverse_merge(DefaultHashColumnOptions) })
|
22
|
+
self.cached_attributes.delete arg.to_s
|
23
|
+
|
24
|
+
self.class_eval <<-EVAL
|
25
|
+
def validate_#{arg.to_s}?
|
26
|
+
!hashed?("#{arg.to_s}")
|
27
|
+
end
|
28
|
+
EVAL
|
29
|
+
end
|
30
|
+
if !self._save_callbacks.map { |c| c.filter.to_sym }.include?(:hash_before_save)
|
31
|
+
self.before_save :hash_before_save
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.included(klass)
|
38
|
+
klass.extend(ClassMethods)
|
39
|
+
klass.write_inheritable_hash(:hashed_columns, {})
|
40
|
+
klass.instance_eval <<-EVAL
|
41
|
+
after_initialize :initialize_hashed_columns
|
42
|
+
EVAL
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def hash_before_save
|
48
|
+
self.class.hashed_columns.each do |column, options|
|
49
|
+
next if hashed?(column)
|
50
|
+
@attributes[column.to_s] = nil if read_attribute(column).blank? && !options[:hash_blank_strings]
|
51
|
+
perform_hashing_on_column(column, options[:length]) unless read_attribute(column).nil?
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def perform_hashing_on_column(column, length)
|
56
|
+
@attributes[column.to_s] = HashedString.new(read_attribute(column), :length => length)
|
57
|
+
end
|
58
|
+
|
59
|
+
def convert_column_to_hashed_string(column)
|
60
|
+
@attributes[column.to_s] = HashedString.from_hash(read_attribute(column))
|
61
|
+
end
|
62
|
+
|
63
|
+
def hashed?(column)
|
64
|
+
read_attribute(column.to_s).is_a?(HashedString)
|
65
|
+
end
|
66
|
+
|
67
|
+
def initialize_hashed_columns
|
68
|
+
unless self.new_record?
|
69
|
+
self.class.hashed_columns.keys.each do |k|
|
70
|
+
convert_column_to_hashed_string(k) unless read_attribute(k).blank?
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
ActiveSupport.on_load :active_record do
|
79
|
+
class ActiveRecord::Base
|
80
|
+
include SaltPepper::ModelExtensions
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module SaltPepper
|
2
|
+
module Random
|
3
|
+
|
4
|
+
def self.token(size = 32)
|
5
|
+
hex(size)
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.code(size = 8, chars = ('A'..'Z').to_a + (0..9).to_a)
|
9
|
+
chars = chars.to_a if chars.is_a?(Range)
|
10
|
+
chars = chars.chars.to_a.uniq if chars.is_a?(String)
|
11
|
+
(1..size).map { chars[self.number(chars.length)] }.join
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.numeric_code(size = 8)
|
15
|
+
self.code(size, 0..9)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.alpha_code(size = 8)
|
19
|
+
self.code(size, 'A'..'Z')
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.number(max_or_range)
|
23
|
+
return max_or_range.begin + ActiveSupport::SecureRandom.random_number(max_or_range.end - max_or_range.begin + 1) if max_or_range.is_a?(Range)
|
24
|
+
ActiveSupport::SecureRandom.random_number(max_or_range)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def self.salt(size = DefaultOptions[:length])
|
30
|
+
hex(size - 64)
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.hex(size)
|
34
|
+
ActiveSupport::SecureRandom.hex(size / 2)
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
|
39
|
+
(Random.methods(false) - Random.private_methods(false)).each do |m|
|
40
|
+
module_eval <<-EVAL, __FILE__, __LINE__
|
41
|
+
def self.#{m}(*args)
|
42
|
+
Random.#{m}(*args)
|
43
|
+
end
|
44
|
+
EVAL
|
45
|
+
end
|
46
|
+
end
|
data/lib/version.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "salt-and-pepper"
|
7
|
+
s.version = SaltPepper::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Mate Solymosi"]
|
10
|
+
s.email = ["mate@solymosi.eu"]
|
11
|
+
s.homepage = "http://github.com/SMWEB/salt-and-pepper"
|
12
|
+
s.summary = %q{Super easy password salting and hashing for ActiveRecord (Rails)}
|
13
|
+
s.description = %q{Super easy password salting and hashing for ActiveRecord (Rails)}
|
14
|
+
|
15
|
+
s.rubyforge_project = "salt-and-pepper"
|
16
|
+
|
17
|
+
s.files = `git ls-files`.split("\n")
|
18
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
19
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
20
|
+
s.require_paths = ["lib"]
|
21
|
+
|
22
|
+
s.add_dependency("activesupport", ">= 3.0.0")
|
23
|
+
s.add_dependency("activerecord", ">= 3.0.0")
|
24
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
require File.expand_path('spec_helper.rb', File.dirname(__FILE__))
|
2
|
+
|
3
|
+
describe SaltPepper::HashedString do
|
4
|
+
|
5
|
+
before(:each) do
|
6
|
+
@hash = SaltPepper::HashedString.new("secret")
|
7
|
+
end
|
8
|
+
|
9
|
+
describe "initialize" do
|
10
|
+
|
11
|
+
it "raises error invalid parameters" do
|
12
|
+
lambda { SaltPepper::HashedString.new(nil) }.should raise_error(SaltPepper::ArgumentError)
|
13
|
+
lambda { SaltPepper::HashedString.new(3) }.should raise_error(SaltPepper::ArgumentError)
|
14
|
+
lambda { SaltPepper::HashedString.new([]) }.should raise_error(SaltPepper::ArgumentError)
|
15
|
+
end
|
16
|
+
|
17
|
+
it "accepts valid parameters" do
|
18
|
+
lambda { SaltPepper::HashedString.new("") }.should_not raise_error(SaltPepper::ArgumentError)
|
19
|
+
lambda { SaltPepper::HashedString.new("secret") }.should_not raise_error(SaltPepper::ArgumentError)
|
20
|
+
end
|
21
|
+
|
22
|
+
it "generates a valid salt with default length" do
|
23
|
+
@hash.salt.length.should == SaltPepper::HashedString::DefaultOptions[:length] - 64
|
24
|
+
end
|
25
|
+
|
26
|
+
it "generates a valid salt with custom length" do
|
27
|
+
SaltPepper::HashedString.new("secret", :length => 192).salt.length.should == 128
|
28
|
+
end
|
29
|
+
|
30
|
+
it "raises error for invalid options" do
|
31
|
+
lambda { SaltPepper::HashedString.new("secret", :length => 95) }.should raise_error(SaltPepper::ArgumentError)
|
32
|
+
lambda { SaltPepper::HashedString.new("secret", :length => 197) }.should raise_error(SaltPepper::ArgumentError)
|
33
|
+
lambda { SaltPepper::HashedString.new("secret", :length => true) }.should raise_error(SaltPepper::ArgumentError)
|
34
|
+
lambda { SaltPepper::HashedString.new("secret", :length => "oops") }.should raise_error(SaltPepper::ArgumentError)
|
35
|
+
lambda { SaltPepper::HashedString.new("secret", :oops => true) }.should raise_error(SaltPepper::ArgumentError)
|
36
|
+
end
|
37
|
+
|
38
|
+
it "hashes the input string" do
|
39
|
+
@hash.hsh.should_not be_blank
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
describe "hsh" do
|
45
|
+
it "returns the hash" do
|
46
|
+
@hash.hsh.should == @hash.result[0...64]
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
describe "salt" do
|
51
|
+
it "returns the salt" do
|
52
|
+
@hash.salt.should == @hash.result[64...128]
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
describe "to_s" do
|
57
|
+
it "returns empty string" do
|
58
|
+
@hash.to_s.should == ""
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
describe "to_yaml" do
|
63
|
+
it "returns the result" do
|
64
|
+
@hash.to_yaml.should == @hash.result
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
describe "inspect" do
|
69
|
+
it "returns result.inspect" do
|
70
|
+
@hash.inspect.should == @hash.result.inspect
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
describe "set_attributes" do
|
75
|
+
it "sets attributes correctly" do
|
76
|
+
@hash.set_attributes("hehe", "haha")
|
77
|
+
@hash.hsh.should == "hehe"
|
78
|
+
@hash.salt.should == "haha"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
describe "==" do
|
83
|
+
|
84
|
+
it "returns true if String is given and verification succeeds" do
|
85
|
+
@hash.should == "secret"
|
86
|
+
end
|
87
|
+
|
88
|
+
it "returns false if String is given and verification fails" do
|
89
|
+
@hash.should_not == "oops"
|
90
|
+
end
|
91
|
+
|
92
|
+
it "returns true if another HashedString is given and they match" do
|
93
|
+
h = SaltPepper::HashedString.from_hash(@hash)
|
94
|
+
@hash.should == h
|
95
|
+
end
|
96
|
+
|
97
|
+
it "returns false if another HashedString is given but they don't match" do
|
98
|
+
h = SaltPepper::HashedString.new("oops")
|
99
|
+
@hash.should_not == h
|
100
|
+
end
|
101
|
+
|
102
|
+
it "returns false if something other than a String or HashedString is given" do
|
103
|
+
@hash.should_not == 10
|
104
|
+
@hash.should_not == []
|
105
|
+
@hash.should_not == {}
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
109
|
+
|
110
|
+
describe "from_hash" do
|
111
|
+
|
112
|
+
before(:each) do
|
113
|
+
@f = SaltPepper::HashedString.from_hash(@hash)
|
114
|
+
end
|
115
|
+
|
116
|
+
it "raises error for invalid parameters" do
|
117
|
+
lambda { SaltPepper::HashedString.from_hash(nil) }.should raise_error(SaltPepper::ArgumentError)
|
118
|
+
lambda { SaltPepper::HashedString.from_hash(10) }.should raise_error(SaltPepper::ArgumentError)
|
119
|
+
lambda { SaltPepper::HashedString.from_hash("o" * 95) }.should raise_error(SaltPepper::ArgumentError)
|
120
|
+
lambda { SaltPepper::HashedString.from_hash("o" * 197) }.should raise_error(SaltPepper::ArgumentError)
|
121
|
+
end
|
122
|
+
|
123
|
+
it "separates the hash and the salt correctly" do
|
124
|
+
@f.hsh.should == @hash.hsh
|
125
|
+
@f.salt.should == @hash.salt
|
126
|
+
@f.should == @hash
|
127
|
+
@f.should == "secret"
|
128
|
+
end
|
129
|
+
|
130
|
+
end
|
131
|
+
|
132
|
+
describe "eql?" do
|
133
|
+
it "works correctly" do
|
134
|
+
SaltPepper::HashedString.from_hash(@hash.result).eql?(@hash).should == true
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
describe "String extension" do
|
139
|
+
it "works correctly" do
|
140
|
+
"secret".should == @hash
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
describe "validation help messages" do
|
145
|
+
it "work correctly" do
|
146
|
+
lambda { @hash.length }.should raise_error(SaltPepper::ValueHashedError)
|
147
|
+
lambda { @hash =~ /.*/ }.should raise_error(SaltPepper::ValueHashedError)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
end
|
@@ -0,0 +1,275 @@
|
|
1
|
+
require File.expand_path('spec_helper.rb', File.dirname(__FILE__))
|
2
|
+
|
3
|
+
describe SaltPepper::ModelExtensions do
|
4
|
+
|
5
|
+
before(:each) do
|
6
|
+
ActiveRecord::Base.establish_connection :adapter => "sqlite3", :database => ":memory:"
|
7
|
+
ActiveRecord::Base.connection.create_table :users do |t|
|
8
|
+
t.string :password
|
9
|
+
t.string :token
|
10
|
+
t.string :required, :null => false, :default => "something"
|
11
|
+
end
|
12
|
+
|
13
|
+
class User; end
|
14
|
+
|
15
|
+
@user = Class.new(ActiveRecord::Base) do
|
16
|
+
self.table_name = "users"
|
17
|
+
@_model_name = ActiveModel::Name.new(User)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
describe "included?" do
|
22
|
+
|
23
|
+
it "should have SaltPepper::ModelExtensions included" do
|
24
|
+
@user.included_modules.include?(SaltPepper::ModelExtensions)
|
25
|
+
end
|
26
|
+
|
27
|
+
it "should add the hash_column class method to the class it's included in" do
|
28
|
+
@user.respond_to?("hash_column").should == true
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
|
33
|
+
describe "hash_column" do
|
34
|
+
|
35
|
+
it "should add a single attribute to the list" do
|
36
|
+
@user.hash_column :password
|
37
|
+
@user.hashed_columns.count.should == 1
|
38
|
+
@user.hashed_columns.keys.include?(:password).should == true
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should add multiple attributes to the list" do
|
42
|
+
@user.hash_column :password, :token
|
43
|
+
@user.hashed_columns.count.should == 2
|
44
|
+
@user.hashed_columns.keys.include?(:password).should == true
|
45
|
+
@user.hashed_columns.keys.include?(:token).should == true
|
46
|
+
end
|
47
|
+
|
48
|
+
it "should work properly when called multiple times" do
|
49
|
+
@user.hash_column :password
|
50
|
+
@user.hash_column :token
|
51
|
+
@user.hashed_columns.count.should == 2
|
52
|
+
@user.hashed_columns.keys.include?(:password).should == true
|
53
|
+
@user.hashed_columns.keys.include?(:token).should == true
|
54
|
+
end
|
55
|
+
|
56
|
+
it "should allow valid parameters" do
|
57
|
+
@user.hash_column :password, :length => 100, :hash_blank_strings => true
|
58
|
+
@user.hashed_columns.count.should == 1
|
59
|
+
@user.hashed_columns[:password].should == { :length => 100, :hash_blank_strings => true }
|
60
|
+
end
|
61
|
+
|
62
|
+
it "should apply defaults for unset options" do
|
63
|
+
@user.hash_column :password
|
64
|
+
@user.hashed_columns[:password].should == SaltPepper::ModelExtensions::DefaultHashColumnOptions
|
65
|
+
end
|
66
|
+
|
67
|
+
it "should not add the same attribute twice" do
|
68
|
+
2.times { @user.hash_column :password }
|
69
|
+
@user.hashed_columns.count.should == 1
|
70
|
+
end
|
71
|
+
|
72
|
+
it "should not add an attribute without an existing column to the list" do
|
73
|
+
lambda { @user.hash_column :oops }.should raise_error(ArgumentError)
|
74
|
+
@user.hashed_columns.should be_empty
|
75
|
+
end
|
76
|
+
|
77
|
+
it "should not allow invalid parameters" do
|
78
|
+
lambda { @user.hash_column :password, :oops => true }.should raise_error(ArgumentError)
|
79
|
+
end
|
80
|
+
|
81
|
+
it "should not allow primary key column to be hashed" do
|
82
|
+
lambda { @user.hash_column :id }.should raise_error(ArgumentError)
|
83
|
+
end
|
84
|
+
|
85
|
+
it "should delete attribute from cached attributes list when present" do
|
86
|
+
@user.cached_attributes.add "password"
|
87
|
+
@user.hash_column :password
|
88
|
+
@user.cached_attributes.include?("password").should == false
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
|
93
|
+
describe "after_initialize" do
|
94
|
+
|
95
|
+
before(:each) do
|
96
|
+
@user.hash_column :password
|
97
|
+
@u = @user.new
|
98
|
+
end
|
99
|
+
|
100
|
+
it "should replace the hashed values to a HashedString if not empty and not new_record?" do
|
101
|
+
@u.password = "secret"
|
102
|
+
@u.save!
|
103
|
+
@u = @user.first
|
104
|
+
@u.password.is_a?(SaltPepper::HashedString).should == true
|
105
|
+
@u.password.respond_to?(:result).should == true
|
106
|
+
@u.password.should == "secret"
|
107
|
+
end
|
108
|
+
|
109
|
+
it "should not replace empty values to a HashedString" do
|
110
|
+
@u.password.is_a?(SaltPepper::HashedString).should == false
|
111
|
+
@u.save!
|
112
|
+
@u = @user.first
|
113
|
+
@u.password.is_a?(SaltPepper::HashedString).should == false
|
114
|
+
end
|
115
|
+
|
116
|
+
end
|
117
|
+
|
118
|
+
describe "column hashing on save" do
|
119
|
+
|
120
|
+
it "hashes column on save" do
|
121
|
+
@user.hash_column :password
|
122
|
+
@u = @user.new
|
123
|
+
@u.password.should == nil
|
124
|
+
@u.validate_password?.should == true
|
125
|
+
@u.password = "secret"
|
126
|
+
@u.password.class.should == String
|
127
|
+
@u.password.should == "secret"
|
128
|
+
@u.validate_password?.should == true
|
129
|
+
@u.save!
|
130
|
+
@u.password.class.should == SaltPepper::HashedString
|
131
|
+
@u.password.should == "secret"
|
132
|
+
@u.password.should_not == "oops"
|
133
|
+
@u.validate_password?.should == false
|
134
|
+
end
|
135
|
+
|
136
|
+
it "does not rehash column if its value was not changed" do
|
137
|
+
@user.hash_column :password
|
138
|
+
@u = @user.new
|
139
|
+
@u.password = "secret"
|
140
|
+
@u.save!
|
141
|
+
@u.validate_password?.should == false
|
142
|
+
@u.save!
|
143
|
+
@u.validate_password?.should == false
|
144
|
+
@u.password.should == "secret"
|
145
|
+
@u.password = @u.password
|
146
|
+
@u.validate_password?.should == false
|
147
|
+
@u.save!
|
148
|
+
@u.password.should == "secret"
|
149
|
+
end
|
150
|
+
|
151
|
+
it "does not hash column if it is nil or blank" do
|
152
|
+
@user.hash_column :password
|
153
|
+
@u = @user.new
|
154
|
+
@u.save!
|
155
|
+
@u.password.should be_nil
|
156
|
+
@u.validate_password?.should == true
|
157
|
+
@u.password = ""
|
158
|
+
@u.save!
|
159
|
+
@u.password.should be_nil
|
160
|
+
@u.validate_password?.should == true
|
161
|
+
@u.password = "secret"
|
162
|
+
@u.save!
|
163
|
+
@u.validate_password?.should == false
|
164
|
+
@u.password.should == "secret"
|
165
|
+
@u.password = ""
|
166
|
+
@u.save!
|
167
|
+
@u.password.should be_nil
|
168
|
+
@u.validate_password?.should == true
|
169
|
+
@u.password = nil
|
170
|
+
@u.save!
|
171
|
+
@u.password.should be_nil
|
172
|
+
@u.validate_password?.should == true
|
173
|
+
end
|
174
|
+
|
175
|
+
it "correctly hashes columns with :hash_blank_strings => true" do
|
176
|
+
@user.hash_column :password, :hash_blank_strings => true
|
177
|
+
@u = @user.new
|
178
|
+
@u.password = nil
|
179
|
+
@u.save!
|
180
|
+
@u.password.should be_nil
|
181
|
+
@u.validate_password?.should == true
|
182
|
+
@u.password = "secret"
|
183
|
+
@u.save!
|
184
|
+
@u.validate_password?.should == false
|
185
|
+
@u.password = ""
|
186
|
+
@u.save!
|
187
|
+
@u.password.should == ""
|
188
|
+
@u.password.class.should == SaltPepper::HashedString
|
189
|
+
end
|
190
|
+
|
191
|
+
it "works with validations on the column" do
|
192
|
+
@user.hash_column :password
|
193
|
+
@user.validates :password, :presence => true, :length => { :within => 5..10 }, :if => :validate_password?
|
194
|
+
@u = @user.new
|
195
|
+
@u.password = ""
|
196
|
+
@u.save.should == false
|
197
|
+
@u.password = "abc"
|
198
|
+
@u.save.should == false
|
199
|
+
@u.password = "ooooooooops"
|
200
|
+
@u.save.should == false
|
201
|
+
@u.password = "secret"
|
202
|
+
@u.save.should == true
|
203
|
+
@u.save.should == true
|
204
|
+
@u.password = @u.password
|
205
|
+
@u.save.should == true
|
206
|
+
@u = @user.first
|
207
|
+
@u.save.should == true
|
208
|
+
@u.password = "abc"
|
209
|
+
@u.save.should == false
|
210
|
+
end
|
211
|
+
|
212
|
+
it "works with multiple columns" do
|
213
|
+
@user.hash_column :password, :token
|
214
|
+
@u = @user.new
|
215
|
+
@u.password = "secret"
|
216
|
+
@u.save!
|
217
|
+
@u.password.should == "secret"
|
218
|
+
@u.token.should be_nil
|
219
|
+
@u.token = "other"
|
220
|
+
@u.save!
|
221
|
+
@u.password.should == "secret"
|
222
|
+
@u.token.should == "other"
|
223
|
+
@u.save!
|
224
|
+
@u.password.should == "secret"
|
225
|
+
@u.token.should == "other"
|
226
|
+
end
|
227
|
+
|
228
|
+
it "remains in a consistent state if saving fails" do
|
229
|
+
@user.hash_column :password
|
230
|
+
@u = @user.new
|
231
|
+
@u.required = nil
|
232
|
+
@u.password = "secret"
|
233
|
+
@u.save! rescue nil
|
234
|
+
@u.password.should == "secret"
|
235
|
+
@u.validate_password?.should == false
|
236
|
+
@u.required = "something"
|
237
|
+
@u.save!
|
238
|
+
@u.password.should == "secret"
|
239
|
+
@u.validate_password?.should == false
|
240
|
+
end
|
241
|
+
|
242
|
+
it "works with finds" do
|
243
|
+
@user.hash_column :password
|
244
|
+
@u = @user.new
|
245
|
+
@u.password = "secret"
|
246
|
+
@u.save
|
247
|
+
@v = @user.first
|
248
|
+
@v.validate_password?.should == false
|
249
|
+
@u.password.should == "secret"
|
250
|
+
@v.password = nil
|
251
|
+
@v.save
|
252
|
+
@w = @user.first
|
253
|
+
@w.validate_password?.should == true
|
254
|
+
@w.password.should be_nil
|
255
|
+
end
|
256
|
+
|
257
|
+
it "works with change tracking" do
|
258
|
+
@user.hash_column :password
|
259
|
+
@u = @user.new
|
260
|
+
@u.password = "secret"
|
261
|
+
@u.save
|
262
|
+
@u = @user.first
|
263
|
+
@u.changed_attributes.count.should be_zero
|
264
|
+
@u.password = @u.password
|
265
|
+
@u.reset_password!
|
266
|
+
@u.password.class.should == SaltPepper::HashedString
|
267
|
+
@u.password = "other"
|
268
|
+
@u.reset_password!
|
269
|
+
@u.password.class.should == SaltPepper::HashedString
|
270
|
+
@u.validate_password?.should == false
|
271
|
+
end
|
272
|
+
|
273
|
+
end
|
274
|
+
|
275
|
+
end
|
data/spec/random_spec.rb
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
require File.expand_path('spec_helper.rb', File.dirname(__FILE__))
|
2
|
+
|
3
|
+
describe SaltPepper::Random do
|
4
|
+
|
5
|
+
describe "number" do
|
6
|
+
|
7
|
+
it "(most likely) works when only a max value is specified" do
|
8
|
+
100.times { (0..1).include?(SaltPepper::Random.number(2)).should == true }
|
9
|
+
end
|
10
|
+
|
11
|
+
it "(most likely) works when a range is specified" do
|
12
|
+
100.times { (9..10).include?(SaltPepper::Random.number(9..10)).should == true }
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
|
17
|
+
describe "code" do
|
18
|
+
|
19
|
+
it "works without any parameters" do
|
20
|
+
SaltPepper::Random.code.length.should == 8
|
21
|
+
end
|
22
|
+
|
23
|
+
it "returns uppercase alphanumeric characters" do
|
24
|
+
SaltPepper::Random.code(1000).should =~ /\A[A-Z0-9]+\z/
|
25
|
+
end
|
26
|
+
|
27
|
+
it "works with a size parameter" do
|
28
|
+
SaltPepper::Random.code(10).length.should == 10
|
29
|
+
end
|
30
|
+
|
31
|
+
it "works with size and chars parameters" do
|
32
|
+
SaltPepper::Random.code(100, 0..1).chars.to_a.uniq.sort.should == ["0", "1"].sort
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
describe "numeric_code" do
|
38
|
+
|
39
|
+
it "returns numbers only" do
|
40
|
+
SaltPepper::Random.numeric_code(100).should =~ /\A[0-9]+\z/
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
describe "alpha_code" do
|
46
|
+
|
47
|
+
it "returns uppercase letters only" do
|
48
|
+
SaltPepper::Random.alpha_code(100).should =~ /\A[A-Z]+\z/
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
|
53
|
+
describe "token" do
|
54
|
+
|
55
|
+
it "works without parameters" do
|
56
|
+
SaltPepper::Random.token.length.should == 32
|
57
|
+
end
|
58
|
+
|
59
|
+
it "works with a size parameter" do
|
60
|
+
SaltPepper::Random.token(100).length.should == 100
|
61
|
+
end
|
62
|
+
|
63
|
+
it "returns hex characters" do
|
64
|
+
SaltPepper::Random.token(1000).should =~ /\A[a-f0-9]*\z/
|
65
|
+
end
|
66
|
+
|
67
|
+
end
|
68
|
+
|
69
|
+
describe "methods" do
|
70
|
+
it "are available on SaltPepper itself" do
|
71
|
+
SaltPepper.respond_to?(:number).should == true
|
72
|
+
SaltPepper.respond_to?(:code).should == true
|
73
|
+
SaltPepper.respond_to?(:alpha_code).should == true
|
74
|
+
SaltPepper.respond_to?(:numeric_code).should == true
|
75
|
+
SaltPepper.respond_to?(:token).should == true
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'salt-and-pepper'
|
metadata
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: salt-and-pepper
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 19
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 2
|
9
|
+
- 2
|
10
|
+
version: 0.2.2
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Mate Solymosi
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2011-04-24 00:00:00 +02:00
|
19
|
+
default_executable:
|
20
|
+
dependencies:
|
21
|
+
- !ruby/object:Gem::Dependency
|
22
|
+
name: activesupport
|
23
|
+
prerelease: false
|
24
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
hash: 7
|
30
|
+
segments:
|
31
|
+
- 3
|
32
|
+
- 0
|
33
|
+
- 0
|
34
|
+
version: 3.0.0
|
35
|
+
type: :runtime
|
36
|
+
version_requirements: *id001
|
37
|
+
- !ruby/object:Gem::Dependency
|
38
|
+
name: activerecord
|
39
|
+
prerelease: false
|
40
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ">="
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
hash: 7
|
46
|
+
segments:
|
47
|
+
- 3
|
48
|
+
- 0
|
49
|
+
- 0
|
50
|
+
version: 3.0.0
|
51
|
+
type: :runtime
|
52
|
+
version_requirements: *id002
|
53
|
+
description: Super easy password salting and hashing for ActiveRecord (Rails)
|
54
|
+
email:
|
55
|
+
- mate@solymosi.eu
|
56
|
+
executables: []
|
57
|
+
|
58
|
+
extensions: []
|
59
|
+
|
60
|
+
extra_rdoc_files: []
|
61
|
+
|
62
|
+
files:
|
63
|
+
- .gitignore
|
64
|
+
- Gemfile
|
65
|
+
- README.textile
|
66
|
+
- Rakefile
|
67
|
+
- lib/salt-and-pepper.rb
|
68
|
+
- lib/salt_pepper/hashed_string.rb
|
69
|
+
- lib/salt_pepper/model_extensions.rb
|
70
|
+
- lib/salt_pepper/random.rb
|
71
|
+
- lib/version.rb
|
72
|
+
- salt-and-pepper.gemspec
|
73
|
+
- spec/hashed_string_spec.rb
|
74
|
+
- spec/model_extensions_spec.rb
|
75
|
+
- spec/random_spec.rb
|
76
|
+
- spec/spec_helper.rb
|
77
|
+
has_rdoc: true
|
78
|
+
homepage: http://github.com/SMWEB/salt-and-pepper
|
79
|
+
licenses: []
|
80
|
+
|
81
|
+
post_install_message:
|
82
|
+
rdoc_options: []
|
83
|
+
|
84
|
+
require_paths:
|
85
|
+
- lib
|
86
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
87
|
+
none: false
|
88
|
+
requirements:
|
89
|
+
- - ">="
|
90
|
+
- !ruby/object:Gem::Version
|
91
|
+
hash: 3
|
92
|
+
segments:
|
93
|
+
- 0
|
94
|
+
version: "0"
|
95
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
96
|
+
none: false
|
97
|
+
requirements:
|
98
|
+
- - ">="
|
99
|
+
- !ruby/object:Gem::Version
|
100
|
+
hash: 3
|
101
|
+
segments:
|
102
|
+
- 0
|
103
|
+
version: "0"
|
104
|
+
requirements: []
|
105
|
+
|
106
|
+
rubyforge_project: salt-and-pepper
|
107
|
+
rubygems_version: 1.6.2
|
108
|
+
signing_key:
|
109
|
+
specification_version: 3
|
110
|
+
summary: Super easy password salting and hashing for ActiveRecord (Rails)
|
111
|
+
test_files: []
|
112
|
+
|