1# frozen_string_literal: false
2#
3# httpauth/htpasswd -- Apache compatible htpasswd file
4#
5# Author: IPR -- Internet Programming with Ruby -- writers
6# Copyright (c) 2003 Internet Programming with Ruby writers. All rights
7# reserved.
8#
9# $IPR: htpasswd.rb,v 1.4 2003/07/22 19:20:45 gotoyuzo Exp $
10
11require_relative 'userdb'
12require_relative 'basicauth'
13require 'tempfile'
14
15module WEBrick
16  module HTTPAuth
17
18    ##
19    # Htpasswd accesses apache-compatible password files.  Passwords are
20    # matched to a realm where they are valid.  For security, the path for a
21    # password database should be stored outside of the paths available to the
22    # HTTP server.
23    #
24    # Htpasswd is intended for use with WEBrick::HTTPAuth::BasicAuth.
25    #
26    # To create an Htpasswd database with a single user:
27    #
28    #   htpasswd = WEBrick::HTTPAuth::Htpasswd.new 'my_password_file'
29    #   htpasswd.set_passwd 'my realm', 'username', 'password'
30    #   htpasswd.flush
31
32    class Htpasswd
33      include UserDB
34
35      ##
36      # Open a password database at +path+
37
38      def initialize(path, password_hash: nil)
39        @path = path
40        @mtime = Time.at(0)
41        @passwd = Hash.new
42        @auth_type = BasicAuth
43        @password_hash = password_hash
44
45        case @password_hash
46        when nil
47          # begin
48          #   require "string/crypt"
49          # rescue LoadError
50          #   warn("Unable to load string/crypt, proceeding with deprecated use of String#crypt, consider using password_hash: :bcrypt")
51          # end
52          @password_hash = :crypt
53        when :crypt
54          # require "string/crypt"
55        when :bcrypt
56          require "bcrypt"
57        else
58          raise ArgumentError, "only :crypt and :bcrypt are supported for password_hash keyword argument"
59        end
60
61        File.open(@path,"a").close unless File.exist?(@path)
62        reload
63      end
64
65      ##
66      # Reload passwords from the database
67
68      def reload
69        mtime = File::mtime(@path)
70        if mtime > @mtime
71          @passwd.clear
72          File.open(@path){|io|
73            while line = io.gets
74              line.chomp!
75              case line
76              when %r!\A[^:]+:[a-zA-Z0-9./]{13}\z!
77                if @password_hash == :bcrypt
78                  raise StandardError, ".htpasswd file contains crypt password, only bcrypt passwords supported"
79                end
80                user, pass = line.split(":")
81              when %r!\A[^:]+:\$2[aby]\$\d{2}\$.{53}\z!
82                if @password_hash == :crypt
83                  raise StandardError, ".htpasswd file contains bcrypt password, only crypt passwords supported"
84                end
85                user, pass = line.split(":")
86              when /:\$/, /:{SHA}/
87                raise NotImplementedError,
88                      'MD5, SHA1 .htpasswd file not supported'
89              else
90                raise StandardError, 'bad .htpasswd file'
91              end
92              @passwd[user] = pass
93            end
94          }
95          @mtime = mtime
96        end
97      end
98
99      ##
100      # Flush the password database.  If +output+ is given the database will
101      # be written there instead of to the original path.
102
103      def flush(output=nil)
104        output ||= @path
105        tmp = Tempfile.create("htpasswd", File::dirname(output))
106        renamed = false
107        begin
108          each{|item| tmp.puts(item.join(":")) }
109          tmp.close
110          File::rename(tmp.path, output)
111          renamed = true
112        ensure
113          tmp.close
114          File.unlink(tmp.path) if !renamed
115        end
116      end
117
118      ##
119      # Retrieves a password from the database for +user+ in +realm+.  If
120      # +reload_db+ is true the database will be reloaded first.
121
122      def get_passwd(realm, user, reload_db)
123        reload() if reload_db
124        @passwd[user]
125      end
126
127      ##
128      # Sets a password in the database for +user+ in +realm+ to +pass+.
129
130      def set_passwd(realm, user, pass)
131        if @password_hash == :bcrypt
132          # Cost of 5 to match Apache default, and because the
133          # bcrypt default of 10 will introduce significant delays
134          # for every request.
135          @passwd[user] = BCrypt::Password.create(pass, :cost=>5)
136        else
137          @passwd[user] = make_passwd(realm, user, pass)
138        end
139      end
140
141      ##
142      # Removes a password from the database for +user+ in +realm+.
143
144      def delete_passwd(realm, user)
145        @passwd.delete(user)
146      end
147
148      ##
149      # Iterate passwords in the database.
150
151      def each # :yields: [user, password]
152        @passwd.keys.sort.each{|user|
153          yield([user, @passwd[user]])
154        }
155      end
156    end
157  end
158end
159