htauth 2.0.0 → 2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 64406e1178b07885f40df5db23c5962c040dff9a
4
- data.tar.gz: a4d90738e83e05cfc38e857426be2e95ed4e8700
2
+ SHA256:
3
+ metadata.gz: 1a0330f64106269682197c83a695b2efbb98b05e367ba56a90aef1794cdc27dd
4
+ data.tar.gz: 4fd5c4452211b4747543941477c65eb83a33ed075d4dd352b1763f00daaabcb5
5
5
  SHA512:
6
- metadata.gz: 680bdc7d37adccad0a9358fdf391440abce27e8adc65e50dfeb394d0fbd884f3c3d951c2688590e33ef841a4bfb699035b167c2b57a764d93858b0dce1eb878e
7
- data.tar.gz: 5c39dd6133939c4ce2eb3845fcac8135f355fc141930ade586bfeca16cc43d5fe4df556891cdb44be7de26ff862fd2aa8dd4b5ff22adb3bb5d10ba90eb61771b
6
+ metadata.gz: 0f5cee2bcf691c57abf009a02a5d383839c1b5ae9f38345785398a258f72936b593e1672f74e62bc10f87124f12b2783b0587c4dfc5d73b413391d996824d0b1
7
+ data.tar.gz: c583805c7ee4a2f33d7297df69dc118d8abc60b91ece304c04b84c2e873bed1c8abd8dff31624a8fffd32fe008e22dcc38aa47677dd7bae143f32eba73a02287
data/HISTORY.md CHANGED
@@ -1,4 +1,11 @@
1
1
  # Changelog
2
+ ## Version 2.1.0 - 2020-04-02
3
+
4
+ * Update minimum ruby versions to modern versions
5
+ * Support bcrypt password entries [#12](https://github.com/copiousfreetime/htauth/issues/12)
6
+ * Support authentication at the password file level
7
+ * implement --verify commandline option (-v in apache htpasswd)
8
+ * implement --stdin commandline option (-i in apache htpasswd)
2
9
 
3
10
  ## Version 2.0.0 - 2015-09-13
4
11
 
@@ -8,11 +8,13 @@ bin/htdigest-ruby
8
8
  bin/htpasswd-ruby
9
9
  lib/htauth.rb
10
10
  lib/htauth/algorithm.rb
11
+ lib/htauth/bcrypt.rb
11
12
  lib/htauth/cli.rb
12
13
  lib/htauth/cli/digest.rb
13
14
  lib/htauth/cli/passwd.rb
14
15
  lib/htauth/console.rb
15
16
  lib/htauth/crypt.rb
17
+ lib/htauth/descendant_tracker.rb
16
18
  lib/htauth/digest_entry.rb
17
19
  lib/htauth/digest_file.rb
18
20
  lib/htauth/entry.rb
@@ -24,6 +26,8 @@ lib/htauth/passwd_file.rb
24
26
  lib/htauth/plaintext.rb
25
27
  lib/htauth/sha1.rb
26
28
  lib/htauth/version.rb
29
+ spec/algorithm_spec.rb
30
+ spec/bcrypt_spec.rb
27
31
  spec/cli/digest_spec.rb
28
32
  spec/cli/passwd_spec.rb
29
33
  spec/crypt_spec.rb
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ## HTAuth
2
2
 
3
3
  * [Github](http://github.com/copiousfreetime/htauth/tree/master)
4
- * email jeremy at copiousfreetime dot org
4
+ * [![Build Status](https://travis-ci.org/copiousfreetime/htauth.svg?branch=master)](https://travis-ci.org/copiousfreetime/htauth)
5
5
 
6
6
  ## DESCRIPTION
7
7
 
@@ -26,32 +26,38 @@ Additionally, you can access all the functionality of *htdigest-ruby* and
26
26
 
27
27
  ### htpasswd-ruby command line application
28
28
 
29
-
30
29
  Usage:
31
- htpasswd-ruby [-cmdpsD] passwordfile username
32
- htpasswd-ruby -b[cmdpsD] passwordfile username password
33
-
34
- htpasswd-ruby -n[mdps] username
35
- htpasswd-ruby -nb[mdps] username password
36
-
37
- -b, --batch Batch mode, get the password from the command line, rather than prompt
38
- -c, --create Create a new file; this overwrites an existing file.
39
- -d, --crypt Force CRYPT encryption of the password (default).
40
- -D, --delete Delete the specified user.
41
- -h, --help Display this help.
42
- -m, --md5 Force MD5 encryption of the password (default on Windows).
43
- -n, --stdout Do not update the file; Display the results on stdout instead.
44
- -p, --plaintext Do not encrypt the password (plaintext).
45
- -s, --sha1 Force SHA encryption of the password.
46
- -v, --version Show version info.
30
+ htpasswd-ruby [-cimBdpsD] [-C cost] passwordfile username
31
+ htpasswd-ruby -b[cmBdpsD] [-C cost] passwordfile username password
32
+
33
+ htpasswd-ruby -n[imBdps] [-C cost] username
34
+ htpasswd-ruby -nb[mBdps] [-C cost] username password
35
+
36
+ -b, --batch Batch mode, get the password from the command line, rather than prompt
37
+ -B, --bcrypt Force bcrypt encryption of the password.
38
+ -C, --cost COST Set the computing time used for the bcrypt algorithm
39
+ (higher is more secure but slower, default: 5, valid: 4 to 31).
40
+ -c, --create Create a new file; this overwrites an existing file.
41
+ -d, --crypt Force CRYPT encryption of the password.
42
+ -D, --delete Delete the specified user.
43
+ -h, --help Display this help.
44
+ -i, --stdin Read the passwod from stdin without verivication (for script usage).
45
+ -m, --md5 Force MD5 encryption of the password (default).
46
+ -n, --stdout Do not update the file; Display the results on stdout instead.
47
+ -p, --plaintext Do not encrypt the password (plaintext).
48
+ -s, --sha1 Force SHA encryption of the password.
49
+ -v, --version Show version info.
50
+ --verify Verify password for the specified user
51
+
52
+ The SHA algorihtm does not use a salt and is less secure than the MD5 algorithm.
47
53
 
48
54
  ### htdigest-ruby command line application
49
55
 
50
56
  Usage: htdigest-ruby [options] passwordfile realm username
51
- -c, --create Create a new digest password file; this overwrites an existing file.
52
- -D, --delete Delete the specified user.
53
- -h, --help Display this help.
54
- -v, --version Show version info.
57
+ -c, --create Create a new digest password file; this overwrites an existing file.
58
+ -D, --delete Delete the specified user.
59
+ -h, --help Display this help.
60
+ -v, --version Show version info.
55
61
 
56
62
  ### API Usage
57
63
 
@@ -65,6 +71,14 @@ Additionally, you can access all the functionality of *htdigest-ruby* and
65
71
  pf.add('someotheruser', 'a different password', 'sha1')
66
72
  end
67
73
 
74
+ HTAuth::PasswdFile.open("some.htpasswd", HTAuth::File::ALTER) do |pf|
75
+ pf.update('someuser', 'a password', 'bcrypt')
76
+ end
77
+
78
+ HTAuth::PasswdFile.open("some.htpasswd") do |pf|
79
+ pf.authenticated?('someuser', 'a password')
80
+ end
81
+
68
82
  ## CREDITS
69
83
 
70
84
  * [The Apache Software Foundation](http://www.apache.org/)
data/Rakefile CHANGED
@@ -7,10 +7,14 @@ This.email = "jeremy@copiousfreetime.org"
7
7
  This.homepage = "http://github.com/copiousfreetime/#{ This.name }"
8
8
 
9
9
  This.ruby_gemspec do |spec|
10
- spec.add_development_dependency( 'rake' , '~> 10.1')
11
- spec.add_development_dependency( 'minitest' , '~> 5.0' )
12
- spec.add_development_dependency( 'rdoc' , '~> 4.0' )
13
- spec.add_development_dependency( 'simplecov', '~> 0.9' )
10
+ spec.add_dependency( 'bcrypt', '~> 3.1' )
11
+
12
+ spec.add_development_dependency( 'rake' , '~> 13.0')
13
+ spec.add_development_dependency( 'minitest' , '~> 5.5' )
14
+ spec.add_development_dependency( 'rdoc' , '~> 6.2' )
15
+ spec.add_development_dependency( 'simplecov', '~> 0.17' )
16
+
17
+ spec.license = "MIT"
14
18
  end
15
19
 
16
20
  load 'tasks/default.rake'
@@ -31,6 +31,7 @@ end
31
31
  require 'htauth/version'
32
32
  require 'htauth/algorithm'
33
33
  require 'htauth/console'
34
+ require 'htauth/bcrypt'
34
35
  require 'htauth/crypt'
35
36
  require 'htauth/digest_entry'
36
37
  require 'htauth/digest_file'
@@ -1,4 +1,5 @@
1
1
  require 'htauth/error'
2
+ require 'htauth/descendant_tracker'
2
3
  require 'securerandom'
3
4
  module HTAuth
4
5
  class InvalidAlgorithmError < Error; end
@@ -7,8 +8,13 @@ module HTAuth
7
8
  #
8
9
  class Algorithm
9
10
 
11
+ extend DescendantTracker
12
+
10
13
  SALT_CHARS = (%w[ . / ] + ("0".."9").to_a + ('A'..'Z').to_a + ('a'..'z').to_a).freeze
14
+ SALT_LENGTH = 8
11
15
 
16
+ # Public: flag for the bcrypt algorithm
17
+ BCRYPT = "bcrypt".freeze
12
18
  # Public: flag for the md5 algorithm
13
19
  MD5 = "md5".freeze
14
20
  # Public: flag for the sha1 algorithm
@@ -26,34 +32,36 @@ module HTAuth
26
32
 
27
33
 
28
34
  class << self
29
- def algorithm_from_name(a_name, params = {})
30
- raise InvalidAlgorithmError, "`#{a_name}' is an invalid encryption algorithm, use one of #{sub_klasses.keys.join(', ')}" unless sub_klasses[a_name.downcase]
31
- sub_klasses[a_name.downcase].new(params)
35
+ def algorithm_name
36
+ self.name.split("::").last.downcase
32
37
  end
33
38
 
34
- def algorithms_from_field(password_field)
35
- matches = []
36
-
37
- if password_field.index(sub_klasses[SHA1].new.prefix) then
38
- matches << sub_klasses[SHA1].new
39
- elsif password_field.index(sub_klasses[MD5].new.prefix) then
40
- p = password_field.split("$")
41
- matches << sub_klasses[MD5].new( :salt => p[2] )
42
- else
43
- matches << sub_klasses[PLAINTEXT].new
44
- matches << sub_klasses[CRYPT].new( :salt => password_field[0,2] )
39
+ def algorithm_from_name(a_name, params = {})
40
+ found = children.find { |c| c.algorithm_name == a_name }
41
+ if !found then
42
+ names = children.map { |c| c.algorithm_name }
43
+ raise InvalidAlgorithmError, "`#{a_name}' is an unknown encryption algorithm, use one of #{names.join(', ')}"
45
44
  end
46
-
47
- return matches
45
+ return found.new(params)
48
46
  end
49
47
 
50
- def inherited(sub_klass)
51
- k = sub_klass.name.split("::").last.downcase
52
- sub_klasses[k] = sub_klass
48
+ # NOTE: if it is plaintext, and the length is 13 - it may matched crypt
49
+ # and be tested that way. If that is the case - this is explicitly
50
+ # siding with crypt() as you shouldn't be using plaintext. Or
51
+ # crypt for that matter.
52
+ def algorithm_from_field(password_field)
53
+ match = find_child(:handles?, password_field)
54
+ match = ::HTAuth::Plaintext if match.nil? && ::HTAuth::Plaintext.entry_matches?(password_field)
55
+
56
+ raise InvalidAlgorithmError, "unknown encryption algorithm used for `#{password_field}`" if match.nil?
57
+
58
+ return match.new(:existing => password_field)
53
59
  end
54
60
 
55
- def sub_klasses
56
- @sub_klasses ||= {}
61
+ # Internal: Does this class handle this type of password entry
62
+ #
63
+ def handles?(password_entry)
64
+ raise NotImplementedError, "#{self.name} must implement #{self.name}.handles?(password_entry)"
57
65
  end
58
66
 
59
67
  # Internal: Constant time string comparison.
@@ -75,20 +83,15 @@ module HTAuth
75
83
  end
76
84
  end
77
85
 
78
- # Internal
79
- def prefix ; end
80
-
81
86
  # Internal
82
87
  def encode(password) ; end
83
88
 
84
89
  # Internal: 8 bytes of random items from SALT_CHARS
85
- def gen_salt
86
- chars = []
87
- 8.times { chars << SALT_CHARS[SecureRandom.random_number(SALT_CHARS.size)] }
88
- chars.join('')
90
+ def gen_salt(length = SALT_LENGTH)
91
+ Array.new(length) { SALT_CHARS.sample }.join('')
89
92
  end
90
93
 
91
- # Internal: this is not the Base64 encoding, this is the to64()
94
+ # Internal: this is not the Base64 encoding, this is the to64()
92
95
  # method from the apache protable runtime library
93
96
  def to_64(number, rounds)
94
97
  r = StringIO.new
@@ -0,0 +1,35 @@
1
+ require 'htauth/algorithm'
2
+ require 'bcrypt'
3
+
4
+ module HTAuth
5
+ # Internal: an implementation of the Bcrypt based encoding algorithm
6
+ # as used in the apache htpasswd -B option
7
+
8
+ class Bcrypt < Algorithm
9
+
10
+ attr_accessor :cost
11
+
12
+ DEFAULT_APACHE_COST = 5 # this is the default cost from htpasswd
13
+
14
+ def self.handles?(password_entry)
15
+ return ::BCrypt::Password.valid_hash?(password_entry)
16
+ end
17
+
18
+ def self.extract_cost_from_existing_password_field(existing)
19
+ password = ::BCrypt::Password.new(existing)
20
+ password.cost
21
+ end
22
+
23
+ def initialize(params = {})
24
+ if existing = (params['existing'] || params[:existing]) then
25
+ @cost = self.class.extract_cost_from_existing_password_field(existing)
26
+ else
27
+ @cost = params['cost'] || params[:cost] || DEFAULT_APACHE_COST
28
+ end
29
+ end
30
+
31
+ def encode(password)
32
+ ::BCrypt::Password.create(password, :cost => cost)
33
+ end
34
+ end
35
+ end
@@ -37,7 +37,7 @@ module HTAuth
37
37
 
38
38
  def option_parser
39
39
  if not @option_parser then
40
- @option_parser = OptionParser.new do |op|
40
+ @option_parser = OptionParser.new(nil, 14) do |op|
41
41
  op.banner = "Usage: #{op.program_name} [options] passwordfile realm username"
42
42
  op.on("-c", "--create", "Create a new digest password file; this overwrites an existing file.") do |c|
43
43
  options.file_mode = DigestFile::CREATE
@@ -27,26 +27,28 @@ module HTAuth
27
27
  @options.file_mode = File::ALTER
28
28
  @options.passwdfile = nil
29
29
  @options.algorithm = Algorithm::EXISTING
30
+ @options.algorithm_args = {}
31
+ @options.read_stdin_once= false
30
32
  @options.send_to_stdout = false
31
33
  @options.show_version = false
32
34
  @options.show_help = false
33
35
  @options.username = nil
34
- @options.delete_entry = false
35
36
  @options.password = ""
37
+ @options.operation = []
36
38
  end
37
39
  @options
38
40
  end
39
41
 
40
42
  def option_parser
41
43
  if not @option_parser then
42
- @option_parser = OptionParser.new do |op|
44
+ @option_parser = OptionParser.new(nil, 16) do |op|
43
45
  op.banner = <<-EOB
44
- Usage:
45
- #{op.program_name} [-cmdpsD] passwordfile username
46
- #{op.program_name} -b[cmdpsD] passwordfile username password
46
+ Usage:
47
+ #{op.program_name} [-cimBdpsD] [-C cost] passwordfile username
48
+ #{op.program_name} -b[cmBdpsD] [-C cost] passwordfile username password
47
49
 
48
- #{op.program_name} -n[mdps] username
49
- #{op.program_name} -nb[mdps] username password
50
+ #{op.program_name} -n[imBdps] [-C cost] username
51
+ #{op.program_name} -nb[mBdps] [-C cost] username password
50
52
  EOB
51
53
 
52
54
  op.separator ""
@@ -55,8 +57,27 @@ Usage:
55
57
  options.batch_mode = b
56
58
  end
57
59
 
60
+ op.on("-B", "--bcrypt", "Force bcrypt encryption of the password.") do |b|
61
+ options.algorithm = Algorithm::BCRYPT
62
+ end
63
+
64
+ op.on("-CCOST", "--cost COST", "Set the computing time used for the bcrypt algorithm",
65
+ "(higher is more secure but slower, default: 5, valid: 4 to 31).") do |c|
66
+ if c !~ /\A\d+\z/ then
67
+ raise ::OptionParser::ParseError, "the bcrypt cost must be an integer from 4 to 31, `#{c}` is invalid"
68
+ end
69
+
70
+ cost = c.to_i
71
+ if (4..31).include?(cost)
72
+ options.algorithm_args = { :cost => cost }
73
+ else
74
+ raise ::OptionParser::ParseError, "the bcrypt cost must be an integer from 4 to 31, `#{c}` is invalid"
75
+ end
76
+ end
77
+
58
78
  op.on("-c", "--create", "Create a new file; this overwrites an existing file.") do |c|
59
79
  options.file_mode = HTAuth::File::CREATE
80
+ options.operation << :add_or_update
60
81
  end
61
82
 
62
83
  op.on("-d", "--crypt", "Force CRYPT encryption of the password.") do |c|
@@ -64,13 +85,17 @@ Usage:
64
85
  end
65
86
 
66
87
  op.on("-D", "--delete", "Delete the specified user.") do |d|
67
- options.delete_entry = d
88
+ options.operation << :delete
68
89
  end
69
90
 
70
91
  op.on("-h", "--help", "Display this help.") do |h|
71
92
  options.show_help = h
72
93
  end
73
94
 
95
+ op.on("-i", "--stdin", "Read the passwod from stdin without verivication (for script usage).") do |i|
96
+ options.read_stdin_once = true
97
+ end
98
+
74
99
  op.on("-m", "--md5", "Force MD5 encryption of the password (default).") do |m|
75
100
  options.algorithm = Algorithm::MD5
76
101
  end
@@ -78,6 +103,7 @@ Usage:
78
103
  op.on("-n", "--stdout", "Do not update the file; Display the results on stdout instead.") do |n|
79
104
  options.send_to_stdout = true
80
105
  options.passwdfile = HTAuth::File::STDOUT_FLAG
106
+ options.operation << :stdout
81
107
  end
82
108
 
83
109
  op.on("-p", "--plaintext", "Do not encrypt the password (plaintext).") do |p|
@@ -92,9 +118,13 @@ Usage:
92
118
  options.show_version = v
93
119
  end
94
120
 
121
+ op.on("--verify", "Verify password for the specified user") do |v|
122
+ options.operation << :verify
123
+ end
124
+
95
125
  op.separator ""
96
126
 
97
- op.separator "The SHA algorihtm does not use a salt and is less secure than the MD5 algorithm"
127
+ op.separator "The SHA algorihtm does not use a salt and is less secure than the MD5 algorithm."
98
128
  end
99
129
  end
100
130
  @option_parser
@@ -114,14 +144,17 @@ Usage:
114
144
  begin
115
145
  option_parser.parse!(argv)
116
146
  show_version if options.show_version
117
- show_help if options.show_help
147
+ show_help if options.show_help
118
148
 
149
+ raise ::OptionParser::ParseError, "only one of --create, --stdout, --verify, --delete may be specified" if options.operation.size > 1
119
150
  raise ::OptionParser::ParseError, "Unable to send to stdout AND create a new file" if options.send_to_stdout and (options.file_mode == File::CREATE)
120
151
  raise ::OptionParser::ParseError, "a username is needed" if options.send_to_stdout and argv.size < 1
121
152
  raise ::OptionParser::ParseError, "a username and password are needed" if options.send_to_stdout and options.batch_mode and ( argv.size < 2 )
122
153
  raise ::OptionParser::ParseError, "a passwordfile, username and password are needed " if not options.send_to_stdout and options.batch_mode and ( argv.size < 3 )
123
154
  raise ::OptionParser::ParseError, "a passwordfile and username are needed" if argv.size < 2
155
+ raise ::OptionParser::ParseError, "options -i and -b are mutually exclusive" if options.batch_mode && options.read_stdin_once
124
156
 
157
+ options.operation = options.operation.shift || :add_or_update
125
158
  options.passwdfile = argv.shift unless options.send_to_stdout
126
159
  options.username = argv.shift
127
160
  options.password = argv.shift if options.batch_mode
@@ -133,33 +166,60 @@ Usage:
133
166
  end
134
167
  end
135
168
 
169
+ def fetch_password(width=20)
170
+ return options.password if options.batch_mode
171
+ console = Console.new
172
+ if options.read_stdin_once then
173
+ pw_in = console.read_answer
174
+ return pw_in
175
+ end
176
+
177
+ case options.operation
178
+ when :verify
179
+ pw_in = console.ask("Enter password: ".rjust(width))
180
+ raise PasswordError, "password '#{pw_in}' too long" if pw_in.length >= MAX_PASSWD_LENGTH
181
+ when :add_or_update
182
+ pw_in = console.ask("New password: ".rjust(width))
183
+ raise PasswordError, "password '#{pw_in}' too long" if pw_in.length >= MAX_PASSWD_LENGTH
184
+
185
+ pw_validate = console.ask("Re-type new password: ".rjust(width))
186
+ raise PasswordError, "They don't match, sorry." unless pw_in == pw_validate
187
+ end
188
+
189
+ return pw_in
190
+ end
191
+
136
192
  def run(argv, env = ENV)
137
193
  begin
138
194
  parse_options(argv)
195
+ console = Console.new
139
196
  passwd_file = PasswdFile.new(options.passwdfile, options.file_mode)
140
-
141
- if options.delete_entry then
197
+ case options.operation
198
+ when :delete
142
199
  passwd_file.delete(options.username)
143
- else
144
- unless options.batch_mode
145
- console = Console.new
146
-
147
- action = passwd_file.has_entry?(options.username) ? "Changing" : "Adding"
148
-
149
- console.say "#{action} password for #{options.username}."
150
-
151
- pw_in = console.ask(" New password: ")
152
- raise PasswordError, "password '#{pw_in}' too long" if pw_in.length >= MAX_PASSWD_LENGTH
153
-
154
- pw_validate = console.ask("Re-type new password: ")
155
- raise PasswordError, "They don't match, sorry." unless pw_in == pw_validate
156
- options.password = pw_in
200
+ passwd_file.save!
201
+ when :verify
202
+ if passwd_file.has_entry?(options.username) then
203
+ pw_in = fetch_password
204
+ if passwd_file.authenticated?(options.username, pw_in) then
205
+ $stderr.puts "Password for user #{options.username} correct."
206
+ else
207
+ raise HTAuth::Error, "Password verification for user #{options.username} failed."
208
+ end
209
+ else
210
+ raise HTAuth::Error, "User #{options.username} not found"
157
211
  end
158
- passwd_file.add_or_update(options.username, options.password, options.algorithm)
212
+ when :add_or_update
213
+ options.password = fetch_password
214
+ action = passwd_file.has_entry?(options.username) ? "Changing" : "Adding"
215
+ console.say "#{action} password for #{options.username}."
216
+ passwd_file.add_or_update(options.username, options.password, options.algorithm, options.algorithm_args)
217
+ passwd_file.save!
218
+ when :stdout
219
+ options.password = fetch_password
220
+ passwd_file.add_or_update(options.username, options.password, options.algorithm, options.algorithm_args)
221
+ passwd_file.save!
159
222
  end
160
-
161
- passwd_file.save!
162
-
163
223
  rescue HTAuth::FileAccessError => fae
164
224
  msg = "Password file failure (#{options.passwdfile}) "
165
225
  $stderr.puts "#{msg}: #{fae.message}"