philnash/pwned

View on GitHub
lib/pwned/password_base.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true

require "digest"
require "net/http"

module Pwned
  ##
  # This class represents a password. It does all the work of talking to the
  # Pwned Passwords API to find out if the password has been pwned.
  # @see https://haveibeenpwned.com/API/v2#PwnedPasswords
  module PasswordBase
    ##
    # The base URL for the Pwned Passwords API
    API_URL = "https://api.pwnedpasswords.com/range/"

    ##
    # The number of characters from the start of the hash of the password that
    # are used to search for the range of passwords.
    HASH_PREFIX_LENGTH = 5

    ##
    # The total length of a SHA1 hash
    SHA1_LENGTH = 40

    ##
    # The default request headers that are used to make HTTP requests to the
    # API. A user agent is provided as requested in the documentation.
    # @see https://haveibeenpwned.com/API/v2#UserAgent
    DEFAULT_REQUEST_HEADERS = {
      "User-Agent" => "Ruby Pwned::Password #{Pwned::VERSION}"
    }.freeze

    ##
    # @example
    #     password = Pwned::Password.new("password")
    #     password.pwned? #=> true
    #     password.pwned? #=> true
    #
    # @return [Boolean] +true+ when the password has been pwned.
    # @raise [Pwned::Error] if there are errors with the HTTP request.
    # @raise [Pwned::TimeoutError] if the HTTP request times out.
    # @since 1.0.0
    def pwned?
      pwned_count > 0
    end

    ##
    # @example
    #     password = Pwned::Password.new("password")
    #     password.pwned_count #=> 3303003
    #
    # @return [Integer] the number of times the password has been pwned.
    # @raise [Pwned::Error] if there are errors with the HTTP request.
    # @raise [Pwned::TimeoutError] if the HTTP request times out.
    # @since 1.0.0
    def pwned_count
      @pwned_count ||= fetch_pwned_count
    end

    ##
    # Returns the full SHA1 hash of the given password in uppercase.
    # @return [String] The full SHA1 hash of the given password.
    # @since 1.0.0
    attr_reader :hashed_password

    private

    attr_reader :request_options, :request_headers, :request_proxy, :ignore_env_proxy

    def fetch_pwned_count
      for_each_response_line do |line|
        next unless line.start_with?(hashed_password_suffix)
        # Count starts after the suffix, followed by a colon
        return line[(SHA1_LENGTH-HASH_PREFIX_LENGTH+1)..-1].to_i
      end

      # The hash was not found, we can assume the password is not pwned [yet]
      0
    end

    def for_each_response_line(&block)
      begin
        with_http_response "#{API_URL}#{hashed_password_prefix}" do |response|
          response.value # raise if request was unsuccessful
          stream_response_lines(response, &block)
        end
      rescue Timeout::Error => e
        raise Pwned::TimeoutError, e.message
      rescue => e
        raise Pwned::Error, e.message
      end
    end

    def hashed_password_prefix
      @hashed_password[0...HASH_PREFIX_LENGTH]
    end

    def hashed_password_suffix
      @hashed_password[HASH_PREFIX_LENGTH..-1]
    end

    # Make a HTTP GET request given the URL and headers.
    # Yields a `Net::HTTPResponse`.
    def with_http_response(url, &block)
      uri = URI(url)

      request = Net::HTTP::Get.new(uri)
      request.initialize_http_header(request_headers)
      request_options[:use_ssl] = true

      environment_proxy = ignore_env_proxy ? nil : :ENV

      Net::HTTP.start(
        uri.host,
        uri.port,
        request_proxy&.host || environment_proxy,
        request_proxy&.port,
        request_proxy&.user,
        request_proxy&.password,
        request_options
      ) do |http|
        http.request(request, &block)
      end
    end

    # Stream a Net::HTTPResponse by line, handling lines that cross chunks.
    def stream_response_lines(response, &block)
      last_line = ""

      response.read_body do |chunk|
        chunk_lines = (last_line + chunk).lines
        # This could end with half a line, so save it for next time. If
        # chunk_lines is empty, pop returns nil, so this also ensures last_line
        # is always a string.
        last_line = chunk_lines.pop || ""
        chunk_lines.each(&block)
      end

      yield last_line unless last_line.empty?
    end
  end
end