scoping http auth schemes out of its plugins, made them usable in proxy

In order to expose other auth schemes in proxy, the basic, digest and
ntlm modules were extracted from the plugins, these being left with the
request management. So now, an extra parameter, `:scheme`, can be
passed (it'll be "basic" for http and "socks5" for socks5 by default,
can also be "digest" or "ntlm", haven't tested those yet).
This commit is contained in:
HoneyryderChuck 2022-05-07 01:00:03 +01:00
parent e3191f0d6c
commit 817a10a537
19 changed files with 357 additions and 176 deletions

View File

@ -152,7 +152,7 @@ Lint/EmptyBlock:
Enabled: false # and neither does this. I don't control 3rd party methods.
Style/HashTransformValues:
Exclude:
- 'lib/httpx/plugins/digest_authentication.rb'
- 'lib/httpx/plugins/authentication/digest.rb'
Bundler/DuplicatedGem:
Enabled: false

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
require "base64"
module HTTPX
module Plugins
module Authentication
class Basic
def initialize(user, password, *)
@user = user
@password = password
end
def can_authenticate?(response)
!response.is_a?(ErrorResponse) &&
response.status == 401 && response.headers.key?("www-authenticate") &&
/Basic .*/.match?(response.headers["www-authenticate"])
end
def authenticate(*)
"Basic #{Base64.strict_encode64("#{@user}:#{@password}")}"
end
end
end
end
end

View File

@ -0,0 +1,111 @@
# frozen_string_literal: true
require "time"
require "securerandom"
require "digest"
module HTTPX
module Plugins
module Authentication
class Digest
using RegexpExtensions unless Regexp.method_defined?(:match?)
def initialize(user, password)
@user = user
@password = password
@nonce = 0
end
def can_authenticate?(response)
!response.is_a?(ErrorResponse) &&
response.status == 401 && response.headers.key?("www-authenticate") &&
/Digest .*/.match?(response.headers["www-authenticate"])
end
def authenticate(request, response)
"Digest #{generate_header(request, response)}"
end
private
def generate_header(request, response, iis = false)
meth = request.verb.to_s.upcase
www = response.headers["www-authenticate"]
# discard first token, it's Digest
auth_info = www[/^(\w+) (.*)/, 2]
uri = request.path
params = Hash[auth_info.split(/ *, */)
.map { |val| val.split("=") }
.map { |k, v| [k, v.delete("\"")] }]
nonce = params["nonce"]
nc = next_nonce
# verify qop
qop = params["qop"]
if params["algorithm"] =~ /(.*?)(-sess)?$/
alg = Regexp.last_match(1)
algorithm = ::Digest.const_get(alg)
raise DigestError, "unknown algorithm \"#{alg}\"" unless algorithm
sess = Regexp.last_match(2)
params.delete("algorithm")
else
algorithm = ::Digest::MD5
end
if qop || sess
cnonce = make_cnonce
nc = format("%<nonce>08x", nonce: nc)
end
a1 = if sess
[algorithm.hexdigest("#{@user}:#{params["realm"]}:#{@password}"),
nonce,
cnonce].join ":"
else
"#{@user}:#{params["realm"]}:#{@password}"
end
ha1 = algorithm.hexdigest(a1)
ha2 = algorithm.hexdigest("#{meth}:#{uri}")
request_digest = [ha1, nonce]
request_digest.push(nc, cnonce, qop) if qop
request_digest << ha2
request_digest = request_digest.join(":")
header = [
%(username="#{@user}"),
%(nonce="#{nonce}"),
%(uri="#{uri}"),
%(response="#{algorithm.hexdigest(request_digest)}"),
]
header << %(realm="#{params["realm"]}") if params.key?("realm")
header << %(algorithm=#{params["algorithm"]}") if params.key?("algorithm")
header << %(opaque="#{params["opaque"]}") if params.key?("opaque")
header << %(cnonce="#{cnonce}") if cnonce
header << %(nc=#{nc})
if qop
header << iis ? %(qop="#{qop}") : %(qop=#{qop})
end
header.join ", "
end
def make_cnonce
::Digest::MD5.hexdigest [
Time.now.to_i,
Process.pid,
SecureRandom.random_number(2**32),
].join ":"
end
def next_nonce
@nonce += 1
end
end
end
end
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
require "base64"
require "ntlm"
module HTTPX
module Plugins
module Authentication
class Ntlm
def initialize(user, password, domain: nil)
@user = user
@password = password
@domain = domain
end
def can_authenticate?(response)
!response.is_a?(ErrorResponse) && response.status == 401 &&
response.headers.key?("www-authenticate") &&
/NTLM .*/.match?(response.headers["www-authenticate"])
end
def negotiate
"NTLM #{NTLM.negotiate(domain: @domain).to_base64}"
end
def authenticate(_, response)
challenge = response.headers["www-authenticate"][/NTLM (.*)/, 1]
challenge = Base64.decode64(challenge)
ntlm_challenge = NTLM.authenticate(challenge, @user, @domain, @password).to_base64
"NTLM #{ntlm_challenge}"
end
end
end
end
end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
require "base64"
module HTTPX
module Plugins
module Authentication
class Socks5
def initialize(user, password)
@user = user
@password = password
end
def can_authenticate?(*)
@user && @password
end
def authenticate(*)
[0x01, @user.bytesize, @user, @password.bytesize, @password].pack("CCA*CA*")
end
end
end
end
end

View File

@ -10,7 +10,7 @@ module HTTPX
module BasicAuthentication
class << self
def load_dependencies(_klass)
require "base64"
require_relative "authentication/basic"
end
def configure(klass)
@ -20,7 +20,7 @@ module HTTPX
module InstanceMethods
def basic_authentication(user, password)
authentication("Basic #{Base64.strict_encode64("#{user}:#{password}")}")
authentication(Authentication::Basic.new(user, password).authenticate)
end
alias_method :basic_auth, :basic_authentication
end

View File

@ -1,7 +1,5 @@
# frozen_string_literal: true
require "digest"
module HTTPX
module Plugins
#
@ -10,8 +8,6 @@ module HTTPX
# https://gitlab.com/honeyryderchuck/httpx/wikis/Authentication#authentication
#
module DigestAuthentication
using RegexpExtensions unless Regexp.method_defined?(:match?)
DigestError = Class.new(Error)
class << self
@ -20,14 +16,13 @@ module HTTPX
end
def load_dependencies(*)
require "securerandom"
require "digest"
require_relative "authentication/digest"
end
end
module OptionsMethods
def option_digest(value)
raise TypeError, ":digest must be a Digest" unless value.is_a?(Digest)
raise TypeError, ":digest must be a Digest" unless value.is_a?(Authentication::Digest)
value
end
@ -35,7 +30,7 @@ module HTTPX
module InstanceMethods
def digest_authentication(user, password)
with(digest: Digest.new(user, password))
with(digest: Authentication::Digest.new(user, password))
end
alias_method :digest_auth, :digest_authentication
@ -44,114 +39,23 @@ module HTTPX
requests.flat_map do |request|
digest = request.options.digest
if digest
probe_response = wrap { super(request).first }
if digest && !probe_response.is_a?(ErrorResponse) &&
probe_response.status == 401 && probe_response.headers.key?("www-authenticate") &&
/Digest .*/.match?(probe_response.headers["www-authenticate"])
request.transition(:idle)
token = digest.generate_header(request, probe_response)
request.headers["authorization"] = "Digest #{token}"
super(request)
else
probe_response
end
else
unless digest
super(request)
next
end
probe_response = wrap { super(request).first }
if digest.can_authenticate?(probe_response)
request.transition(:idle)
request.headers["authorization"] = digest.authenticate(request, probe_response)
super(request)
else
probe_response
end
end
end
end
class Digest
def initialize(user, password)
@user = user
@password = password
@nonce = 0
end
def generate_header(request, response, _iis = false)
meth = request.verb.to_s.upcase
www = response.headers["www-authenticate"]
# discard first token, it's Digest
auth_info = www[/^(\w+) (.*)/, 2]
uri = request.path
params = Hash[auth_info.split(/ *, */)
.map { |val| val.split("=") }
.map { |k, v| [k, v.delete("\"")] }]
nonce = params["nonce"]
nc = next_nonce
# verify qop
qop = params["qop"]
if params["algorithm"] =~ /(.*?)(-sess)?$/
alg = Regexp.last_match(1)
algorithm = ::Digest.const_get(alg)
raise DigestError, "unknown algorithm \"#{alg}\"" unless algorithm
sess = Regexp.last_match(2)
params.delete("algorithm")
else
algorithm = ::Digest::MD5
end
if qop || sess
cnonce = make_cnonce
nc = format("%<nonce>08x", nonce: nc)
end
a1 = if sess
[algorithm.hexdigest("#{@user}:#{params["realm"]}:#{@password}"),
nonce,
cnonce].join ":"
else
"#{@user}:#{params["realm"]}:#{@password}"
end
ha1 = algorithm.hexdigest(a1)
ha2 = algorithm.hexdigest("#{meth}:#{uri}")
request_digest = [ha1, nonce]
request_digest.push(nc, cnonce, qop) if qop
request_digest << ha2
request_digest = request_digest.join(":")
header = [
%(username="#{@user}"),
%(nonce="#{nonce}"),
%(uri="#{uri}"),
%(response="#{algorithm.hexdigest(request_digest)}"),
]
header << %(realm="#{params["realm"]}") if params.key?("realm")
header << %(algorithm=#{params["algorithm"]}") if params.key?("algorithm")
header << %(opaque="#{params["opaque"]}") if params.key?("opaque")
header << %(cnonce="#{cnonce}") if cnonce
header << %(nc=#{nc})
header << %(qop=#{qop}) if qop
header.join ", "
end
private
def make_cnonce
::Digest::MD5.hexdigest [
Time.now.to_i,
Process.pid,
SecureRandom.random_number(2**32),
].join ":"
end
def next_nonce
@nonce += 1
end
end
end
register_plugin :digest_authentication, DigestAuthentication

View File

@ -6,12 +6,9 @@ module HTTPX
# https://gitlab.com/honeyryderchuck/httpx/wikis/Authentication#ntlm-authentication
#
module NTLMAuthentication
NTLMParams = Struct.new(:user, :domain, :password)
class << self
def load_dependencies(_klass)
require "base64"
require "ntlm"
require_relative "authentication/ntlm"
end
def extra_options(options)
@ -21,7 +18,7 @@ module HTTPX
module OptionsMethods
def option_ntlm(value)
raise TypeError, ":ntlm must be a #{NTLMParams}" unless value.is_a?(NTLMParams)
raise TypeError, ":ntlm must be a #{Authentication::Ntlm}" unless value.is_a?(Authentication::Ntlm)
value
end
@ -29,7 +26,7 @@ module HTTPX
module InstanceMethods
def ntlm_authentication(user, password, domain = nil)
with(ntlm: NTLMParams.new(user, domain, password))
with(ntlm: Authentication::Ntlm.new(user, password, domain: domain))
end
alias_method :ntlm_auth, :ntlm_authentication
@ -39,19 +36,12 @@ module HTTPX
ntlm = request.options.ntlm
if ntlm
request.headers["authorization"] = "NTLM #{NTLM.negotiate(domain: ntlm.domain).to_base64}"
request.headers["authorization"] = ntlm.negotiate
probe_response = wrap { super(request).first }
if !probe_response.is_a?(ErrorResponse) && probe_response.status == 401 &&
probe_response.headers.key?("www-authenticate") &&
(challenge = probe_response.headers["www-authenticate"][/NTLM (.*)/, 1])
challenge = Base64.decode64(challenge)
ntlm_challenge = NTLM.authenticate(challenge, ntlm.user, ntlm.domain, ntlm.password).to_base64
if ntlm.can_authenticate?(probe_response)
request.transition(:idle)
request.headers["authorization"] = "NTLM #{ntlm_challenge}"
request.headers["authorization"] = ntlm.authenticate(request, probe_response)
super(request)
else
probe_response

View File

@ -19,22 +19,43 @@ module HTTPX
PROXY_ERRORS = [TimeoutError, IOError, SystemCallError, Error].freeze
class Parameters
attr_reader :uri, :username, :password
attr_reader :uri, :username, :password, :scheme
def initialize(uri:, username: nil, password: nil)
def initialize(uri:, scheme: nil, username: nil, password: nil, **extra)
@uri = uri.is_a?(URI::Generic) ? uri : URI(uri)
@username = username || @uri.user
@password = password || @uri.password
return unless @username && @password
scheme ||= case @uri.scheme
when "socks5"
@uri.scheme
when "http", "https"
"basic"
else
return
end
@scheme = scheme
auth_scheme = scheme.to_s.capitalize
require_relative "authentication/#{scheme}" unless defined?(Authentication) && Authentication.const_defined?(auth_scheme)
@authenticator = Authentication.const_get(auth_scheme).new(@username, @password, **extra)
end
def authenticated?
@username && @password
def can_authenticate?(*args)
return false unless @authenticator
@authenticator.can_authenticate?(*args)
end
def token_authentication
return unless authenticated?
def authenticate(*args)
return unless @authenticator
Base64.strict_encode64("#{@username}:#{@password}")
@authenticator.authenticate(*args)
end
def ==(other)
@ -42,7 +63,8 @@ module HTTPX
when Parameters
@uri == other.uri &&
@username == other.username &&
@password == other.password
@password == other.password &&
@scheme == other.scheme
when URI::Generic, String
proxy_uri = @uri.dup
proxy_uri.user = @username

View File

@ -59,7 +59,7 @@ module HTTPX
end
end
def __http_on_connect(_, response)
def __http_on_connect(request, response)
@inflight -= 1
if response.status == 200
req = @pending.first
@ -67,6 +67,11 @@ module HTTPX
@io = ProxySSL.new(@io, request_uri, @options)
transition(:connected)
throw(:called)
elsif @options.proxy.can_authenticate?(response)
request.transition(:idle)
request.headers["proxy-authorization"] = @options.proxy.authenticate(request, response)
@inflight += 1
parser.send(connect_request)
else
pending = @pending + @parser.pending
while (req = pending.shift)
@ -88,7 +93,10 @@ module HTTPX
extra_headers = super
proxy_params = @options.proxy
extra_headers["proxy-authorization"] = "Basic #{proxy_params.token_authentication}" if proxy_params.authenticated?
if proxy_params.scheme == "basic"
# opt for basic auth
extra_headers["proxy-authorization"] = proxy_params.authenticate(extra_headers)
end
extra_headers["proxy-connection"] = extra_headers.delete("connection") if extra_headers.key?("connection")
extra_headers
end

View File

@ -18,6 +18,10 @@ module HTTPX
Error = Socks5Error
def self.load_dependencies(*)
require_relative "../authentication/socks5"
end
module ConnectionMethods
def call
super
@ -156,16 +160,14 @@ module HTTPX
def negotiate(parameters)
methods = [NOAUTH]
methods << PASSWD if parameters.authenticated?
methods << PASSWD if parameters.can_authenticate?
methods.unshift(methods.size)
methods.unshift(VERSION)
methods.pack("C*")
end
def authenticate(parameters)
user = parameters.username
password = parameters.password
[0x01, user.bytesize, user, password.bytesize, password].pack("CCA*CA*")
parameters.authenticate
end
def connect(uri)

View File

@ -0,0 +1,19 @@
module HTTPX
module Plugins
module Authentication
class Basic
@user: String
@password: String
def can_authenticate?: (response) -> boolish
def authenticate: (*untyped) -> String
private
def initialize: (string user, string password, *untyped) -> void
end
end
end
end

View File

@ -0,0 +1,24 @@
module HTTPX
module Plugins
module Authentication
class Digest
@user: String
@password: String
def can_authenticate?: (response) -> boolish
def authenticate: (Request request, Response response) -> String
private
def generate_header: (Request request, Response response, ?bool? iis) -> String
def initialize: (string user, string password) -> void
def make_cnonce: () -> String
def next_nonce: () -> Integer
end
end
end
end

View File

@ -0,0 +1,20 @@
module HTTPX
module Plugins
module Authentication
class Ntlm
@user: String
@password: String
@domain: String?
def can_authenticate?: (response) -> boolish
def authenticate: (*untyped) -> String
private
def initialize: (string user, string password, ?domain: String?) -> void
end
end
end
end

View File

@ -0,0 +1,18 @@
module HTTPX
module Plugins
module Authentication
class Socks5
@user: String
@password: String
def can_authenticate?: (*untyped) -> boolish
def authenticate: (*untyped) -> String
private
def initialize: (string user, string password) -> void
end
end
end
end

View File

@ -4,7 +4,7 @@ module HTTPX
DigestError: singleton(Error)
interface _DigestOptions
def digest: () -> Digest?
def digest: () -> Authentication::Digest?
end
def self.extra_options: (Options) -> (Options & _DigestOptions)
@ -14,16 +14,6 @@ module HTTPX
module InstanceMethods
def digest_authentication: (string user, string password) -> instance
end
class Digest
def generate_header: (Request, Response, ?bool?) -> String
private
def initialize: (string user, string password) -> untyped
def make_cnonce: () -> String
def next_nonce: () -> Integer
end
end
type sessionDigestAuthentication = sessionAuthentication & DigestAuthentication::InstanceMethods

View File

@ -3,7 +3,7 @@ module HTTPX
module NTLMAuthentication
interface _NTLMOptions
def ntlm: () -> NTLMParams?
def ntlm: () -> Authentication::Ntlm?
end
def self.extra_options: (Options) -> (Options & _NTLMOptions)
@ -14,11 +14,6 @@ module HTTPX
def ntlm_authentication: (string user, string password, ?string? domain) -> instance
end
class NTLMParams
attr_reader user: String
attr_reader password: String
attr_reader domain: String?
end
end
type sessionNTLMAuthentication = sessionAuthentication & NTLMAuthentication::InstanceMethods

View File

@ -11,15 +11,17 @@ module HTTPX
attr_reader uri: URI::Generic
attr_reader username: String?
attr_reader password: String?
attr_reader scheme: String?
def authenticated?: () -> boolish
def token_authentication: () -> String?
def can_authenticate?: (*untyped) -> boolish
def authenticate: (*untyped) -> String?
def ==: (untyped) -> bool
private
def initialize: (uri: generic_uri, ?username: String, ?password: String) -> untyped
def initialize: (uri: generic_uri, ?scheme: String, ?username: String, ?password: String, **extra) -> untyped
end
def self.configure: (singleton(Session)) -> void

View File

@ -6,7 +6,7 @@ require "httpx/plugins/proxy"
class ProxyTest < Minitest::Test
include HTTPX
def test_parameters
def test_parameters_equality
params = parameters(username: "user", password: "pass")
assert params == parameters(username: "user", password: "pass")
assert params != parameters(username: "user2", password: "pass")
@ -17,17 +17,6 @@ class ProxyTest < Minitest::Test
assert params != 1
end
def test_parameters_authenticated
assert parameters(username: "user", password: "pass").authenticated?
assert !parameters.authenticated?
end
def test_parameters_token_authentication
params = parameters(username: "user", password: "pass")
assert params.token_authentication == Base64.strict_encode64("user:pass"),
"it should have base64-rencoded the credentials"
end
private
def parameters(uri: "http://proxy", **args)