htauth 2.0.0 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
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}"