diff --git a/duo_security.gemspec b/duo_security.gemspec index 47ec19e..dd5b0ea 100644 --- a/duo_security.gemspec +++ b/duo_security.gemspec @@ -16,7 +16,9 @@ Gem::Specification.new do |gem| gem.version = DuoSecurity::VERSION gem.add_dependency "httparty", "~> 0.10" - - gem.add_development_dependency "vcr", "~> 2.2.4" + + gem.add_development_dependency "minitest", "~> 5.9.0" + gem.add_development_dependency "rake", "~> 11.2.0" + gem.add_development_dependency "vcr", "~> 2.4.0" gem.add_development_dependency "webmock", "~> 1.8.9" end diff --git a/lib/duo_security.rb b/lib/duo_security.rb index 7822fd7..77da813 100644 --- a/lib/duo_security.rb +++ b/lib/duo_security.rb @@ -1,6 +1,9 @@ -require "duo_security/version" require "duo_security/api" +require "duo_security/api_base" +require "duo_security/api_v1" +require "duo_security/api_v2" require "duo_security/attempt" +require "duo_security/version" module DuoSecurity end diff --git a/lib/duo_security/api.rb b/lib/duo_security/api.rb index 7f0cd6c..7858252 100644 --- a/lib/duo_security/api.rb +++ b/lib/duo_security/api.rb @@ -1,82 +1,17 @@ -require 'cgi' -require 'httparty' +#!/usr/bin/env ruby module DuoSecurity class API - class UnknownUser < StandardError; end - - FACTORS = ["auto", "passcode", "phone", "sms", "push"] - - include HTTParty - ssl_ca_file File.expand_path(File.join(File.dirname(__FILE__), "..", "..", "data", "ca-bundle.crt")) - - def initialize(host, secret_key, integration_key) - @host = host - @skey = secret_key - @ikey = integration_key - - self.class.base_uri "https://#{@host}/rest/v1" - end - - def ping - response = self.class.get("/ping") - response.parsed_response.fetch("response") == "pong" - end - - def check - auth = sign("get", @host, "/rest/v1/check", {}, @skey, @ikey) - response = self.class.get("/check", headers: {"Authorization" => auth}) - - # TODO use parsed_response.fetch(...) when content-type is set correctly - response["response"] == "valid" - end - - def preauth(user) - response = post("/preauth", {"user" => user})["response"] - - raise UnknownUser, response.fetch("status") if response.fetch("result") == "enroll" || response.fetch("result") == "deny" - - return response - end - - def auth(user, factor, factor_params) - raise ArgumentError.new("Factor should be one of #{FACTORS.join(", ")}") unless FACTORS.include?(factor) - - params = {"user" => user, "factor" => factor}.merge(factor_params) - response = post("/auth",params) - - response["response"]["result"] == "allow" - end - - protected - - def post(path, params = {}) - auth = sign("post", @host, "/rest/v1#{path}", params, @skey, @ikey) - self.class.post(path, headers: {"Authorization" => auth}, body: params) - end - - def hmac_sha1(key, data) - OpenSSL::HMAC.hexdigest(OpenSSL::Digest::Digest.new('sha1'), key, data.to_s) - end - - def sign(method, host, path, params, skey, ikey) - canon = [method.upcase, host.downcase, path] - - args = [] - for key in params.keys.sort - val = params[key] - args << "#{CGI.escape(key)}=#{CGI.escape(val)}" + def self.new(host, secret_key, integration_key, api_version = 1) + case api_version + when 1 + ApiV1.new(host, secret_key, integration_key) + when 2 + ApiV2.new(host, secret_key, integration_key) + else + raise "API version #{api_version} not supported" end - - canon << args.join("&") - canon = canon.join("\n") - - sig = hmac_sha1(skey, canon) - auth = "#{ikey}:#{sig}" - - encoded = Base64.encode64(auth).split("\n").join("") - - return "Basic #{encoded}" end - end -end + end # API +end # DuoSecurity + diff --git a/lib/duo_security/api_base.rb b/lib/duo_security/api_base.rb new file mode 100644 index 0000000..d2636e6 --- /dev/null +++ b/lib/duo_security/api_base.rb @@ -0,0 +1,17 @@ +#!/usr/bin/env ruby + +module DuoSecurity + class API_Base + class UnknownUser < StandardError; end + require 'cgi' + require 'httparty' + FACTORS = ["auto", "passcode", "phone", "sms", "push"] + include HTTParty + ssl_ca_file File.expand_path(File.join(File.dirname(__FILE__), "..", "..", "data", "ca-bundle.crt")) + + def ping + response = self.class.get("/ping") + response.parsed_response.fetch("response") == "pong" + end + end +end diff --git a/lib/duo_security/api_v1.rb b/lib/duo_security/api_v1.rb new file mode 100644 index 0000000..0ade988 --- /dev/null +++ b/lib/duo_security/api_v1.rb @@ -0,0 +1,73 @@ +module DuoSecurity + class ApiV1 < API_Base + def initialize(host, secret_key, integration_key) + @host = host + @skey = secret_key + @ikey = integration_key + + self.class.base_uri "https://#{@host}/rest/v1" + end + + def check + auth = sign("get", @host, "/rest/v1/check", {}, @skey, @ikey) + response = self.class.get("/check", + headers: {"Authorization" => auth, + "Content-Type" => "application/x-www-form-urlencoded"}) + + + # TODO use parsed_response.fetch(...) when content-type is set correctly + response["response"] == "valid" + end + + def preauth(user) + response = post("/preauth", {"user" => user})["response"] + + raise API_Base::UnknownUser, response.fetch("status") if response.fetch("result") == "enroll" || response.fetch("result") == "deny" + + return response + end + + def auth(user, factor, factor_params) + raise ArgumentError.new("Factor should be one of #{FACTORS.join(", ")}") unless FACTORS.include?(factor) + + params = {"user" => user, "factor" => factor}.merge(factor_params) + response = post("/auth",params) + + response["response"]["result"] == "allow" + end + + protected + + def post(path, params = {}) + auth = sign("post", @host, "/rest/v1#{path}", params, @skey, @ikey) + self.class.post(path, + headers: {"Authorization" => auth, + "Content-Type" => "application/x-www-form-urlencoded"}, + body: params) + end + + def hmac_sha1(key, data) + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), key, data.to_s) + end + + def sign(method, host, path, params, skey, ikey) + canon = [method.upcase, host.downcase, path] + + args = [] + for key in params.keys.sort + val = params[key] + args << "#{CGI.escape(key)}=#{CGI.escape(val)}" + end + + canon << args.join("&") + canon = canon.join("\n") + + sig = hmac_sha1(skey, canon) + auth = "#{ikey}:#{sig}" + + encoded = Base64.encode64(auth).split("\n").join("") + + return "Basic #{encoded}" + end + end +end diff --git a/lib/duo_security/api_v2.rb b/lib/duo_security/api_v2.rb new file mode 100644 index 0000000..c4a19bc --- /dev/null +++ b/lib/duo_security/api_v2.rb @@ -0,0 +1,72 @@ +module DuoSecurity + class ApiV2 < API_Base + def initialize(host, secret_key, integration_key) + @host = host + @skey = secret_key + @ikey = integration_key + @now = Time.now.strftime("%a, %d %b %Y %T %z") + + self.class.base_uri "https://#{@host}/auth/v2" + end + + def check + auth = sign("get", @host, "/auth/v2/check", {}, @skey, @ikey) + response = self.class.get("/check", headers: {"Authorization" => auth}) + + # TODO use parsed_response.fetch(...) when content-type is set correctly + response["response"] == "valid" + end + + def preauth(user) + response = post("/preauth", {"username" => user})["response"] + + raise API_Base::UnknownUser, response.fetch("status") if response.fetch("result") == "enroll" || response.fetch("result") == "deny" + + return response + end + + def auth(user, factor, factor_params) + raise ArgumentError.new("Factor should be one of #{FACTORS.join(", ")}") unless FACTORS.include?(factor) + + params = {"username" => user, "factor" => factor}.merge(factor_params) + response = post("/auth",params) + + response["response"]["result"] == "allow" + end + + protected + + def post(path, params = {}) + auth = sign("post", @host, "/auth/v2#{path}", params, @skey, @ikey) + self.class.post(path, + headers: {"Authorization" => auth, + "Content-Type" => "application/x-www-form-urlencoded", + "Date" => @now}, + body: params) + end + + def hmac_sha1(key, data) + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), key, data.to_s) + end + + def sign(method, host, path, params, skey, ikey) + canon = [@now, method.upcase, host.downcase, path] + + args = [] + for key in params.keys.sort + val = params[key] + args << "#{CGI.escape(key)}=#{CGI.escape(val)}" + end + + canon << args.join("&") + canon = canon.join("\n") + + sig = hmac_sha1(skey, canon) + auth = "#{ikey}:#{sig}" + + encoded = Base64.encode64(auth).split("\n").join("") + + return "Basic #{encoded}" + end + end +end diff --git a/lib/duo_security/attempt.rb b/lib/duo_security/attempt.rb index 907db0a..0d4d7b7 100644 --- a/lib/duo_security/attempt.rb +++ b/lib/duo_security/attempt.rb @@ -8,9 +8,9 @@ def initialize(api, username) def login! preauth = @api.preauth(@username) factor = preauth["factors"].fetch("default") - + @api.auth(@username, "auto", {"auto" => factor}) - rescue API::UnknownUser => e + rescue API_Base::UnknownUser false end end diff --git a/lib/duo_security/version.rb b/lib/duo_security/version.rb index b5ef993..7ff197e 100644 --- a/lib/duo_security/version.rb +++ b/lib/duo_security/version.rb @@ -1,3 +1,3 @@ module DuoSecurity - VERSION = "0.0.2" + VERSION = "0.0.3" end diff --git a/spec/duo_security/api_spec.rb b/spec/duo_security/api_spec.rb index bff920c..b080a6d 100644 --- a/spec/duo_security/api_spec.rb +++ b/spec/duo_security/api_spec.rb @@ -1,6 +1,9 @@ -require 'minitest/autorun' -require 'vcr' +require "minitest/autorun" +require "vcr" require_relative "../../lib/duo_security/api" +require_relative "../../lib/duo_security/api_base" +require_relative "../../lib/duo_security/api_v1" +require_relative "../../lib/duo_security/api_v2" VCR.configure do |c| c.cassette_library_dir = "fixtures/vcr" @@ -12,88 +15,94 @@ end module DuoSecurity - describe API do - let(:host) { ENV["DUO_HOST"] } - let(:skey) { ENV["DUO_SKEY"] } - let(:ikey) { ENV["DUO_IKEY"] } + versions = [1, 2] + versions.each do |v| + describe API do + let(:host) { ENV["DUO_HOST"] } + let(:skey) { ENV["DUO_SKEY"] } + let(:ikey) { ENV["DUO_IKEY"] } + let(:user) { ENV["DUO_USER"] } - describe '#ping' do - it 'succeeds' do - VCR.use_cassette("api_ping_success") do - duo = API.new(host, skey, ikey) - duo.ping.must_equal true + describe "#ping (v#{v})" do + it "succeeds (v#{v})" do + VCR.use_cassette("api_ping_success_v#{v}") do + duo = API.new(host, skey, ikey) + duo.ping.must_equal true + end end end - end - describe '#check' do - it 'succeeds with correct credentials' do - VCR.use_cassette("api_check_success") do - duo = API.new(host, skey, ikey) - duo.check.must_equal true + describe "#check (v#{v})" do + it "succeeds with correct credentials (v#{v})" do + VCR.use_cassette("api_check_success_v#{v}") do + duo = API.new(host, skey, ikey) + duo.check.must_equal true + end end - end - it 'fails with incorrect skey' do - VCR.use_cassette("api_check_wrong_skey") do - duo = API.new(host, "wrong", ikey) - duo.check.must_equal false + it "fails with incorrect skey (v#{v})" do + VCR.use_cassette("api_check_wrong_skey_v#{v}") do + duo = API.new(host, "wrong", ikey) + duo.check.must_equal false + end end - end - it 'fails with incorrect ikey' do - VCR.use_cassette("api_check_wrong_ikey") do - duo = API.new(host, skey, "wrong") - duo.check.must_equal false + it "fails with incorrect ikey (v#{v})" do + VCR.use_cassette("api_check_wrong_ikey_v#{v}") do + duo = API.new(host, skey, "wrong") + duo.check.must_equal false + end end end - end - describe '#preauth' do - it 'returns a list of possible factors' do - VCR.use_cassette("api_preauth") do - duo = API.new(host, skey, ikey) - result = duo.preauth("marten") - result["factors"].must_equal({"1"=>"push1", "2"=>"sms1", "default"=>"push1"}) - result["result"].must_equal("auth") + describe "#preauth (v#{v})" do + it "returns a list of possible factors (#{v})" do + VCR.use_cassette("api_preauth_v#{v}") do + duo = API.new(host, skey, ikey) + result = duo.preauth(user) + result["factors"].must_equal({"1" => "push1", + "2" => "sms1", + "default" => "push1"}) + result["result"].must_equal("auth") + end end - end - it 'raises when user does not exist' do - VCR.use_cassette("api_preauth_unknown_user") do - duo = API.new(host, skey, ikey) - -> { duo.preauth("unknown") }.must_raise(API::UnknownUser) + it "raises when user does not exist (v#{v})" do + VCR.use_cassette("api_preauth_unknown_user_v#{v}") do + duo = API.new(host, skey, ikey) + -> { duo.preauth("unknown") }.must_raise(API_Base::UnknownUser) + end end end - end - describe '#auth' do - let(:duo) { API.new(host, skey, ikey) } + describe "#auth (v#{v})" do + let(:duo) { API.new(host, skey, ikey) } - it 'returns true if user OKs the request' do - VCR.use_cassette("api_auth_user_accepts") do - result = duo.auth("marten", "push", "phone" => "phone1") - result.must_equal(true) + it "returns true if user OKs the request (#{v})" do + VCR.use_cassette("api_auth_user_accepts_v#{v}") do + result = duo.auth(user, "push", "phone" => "phone1") + result.must_equal(true) + end end - end - it 'returns false if the user denies the request as a mistake' do - VCR.use_cassette("api_auth_user_denies_mistake") do - result = duo.auth("marten", "push", "phone" => "phone1") - result.must_equal(false) + it "returns false if the user denies the requestas a mistake (#{v})" do + VCR.use_cassette("api_auth_user_denies_mistake_v#{v}") do + result = duo.auth(user, "push", "phone" => "phone1") + result.must_equal(false) + end end - end - it 'returns false if the user denies the request as a fraudulent attack' do - VCR.use_cassette("api_auth_user_denies_fraud") do - result = duo.auth("marten", "push", "phone" => "phone1") - result.must_equal(false) + it "returns false if the user denies the request as a fraudulent attack (#{v})" do + VCR.use_cassette("api_auth_user_denies_fraud_v#{v}") do + result = duo.auth(user, "push", "phone" => "phone1") + result.must_equal(false) + end end - end - it 'raises an exception when factor is unknown' do - -> { duo.auth("marten", "something") }.must_raise(ArgumentError) + it "raises an exception when factor is unknown (v#{v})" do + -> { duo.auth(user, "something") }.must_raise(ArgumentError) + end end end end -end \ No newline at end of file +end diff --git a/spec/duo_security/attempt_spec.rb b/spec/duo_security/attempt_spec.rb index 4cfea6b..4584aed 100644 --- a/spec/duo_security/attempt_spec.rb +++ b/spec/duo_security/attempt_spec.rb @@ -4,17 +4,18 @@ module DuoSecurity describe Attempt do let(:api) { API.new(ENV["DUO_HOST"], ENV["DUO_SKEY"], ENV["DUO_IKEY"]) } + let(:user) { ENV["DUO_USER"] } describe 'when using push notifications' do it 'returns true if the user accepts the login' do VCR.use_cassette("attempt_allowed") do - Attempt.new(api, "marten").login!.must_equal true + Attempt.new(api, user).login!.must_equal true end end it 'returns false if the user denies the login' do VCR.use_cassette("attempt_disallowed") do - Attempt.new(api, "marten").login!.must_equal false + Attempt.new(api, user).login!.must_equal false end end @@ -25,4 +26,4 @@ module DuoSecurity end end end -end \ No newline at end of file +end