duo_security 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +12 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +36 -0
- data/Rakefile +9 -0
- data/bin/duo +20 -0
- data/data/ca-bundle.crt +3849 -0
- data/duo_security.gemspec +22 -0
- data/lib/duo_security/api.rb +82 -0
- data/lib/duo_security/attempt.rb +17 -0
- data/lib/duo_security/version.rb +3 -0
- data/lib/duo_security.rb +6 -0
- data/spec/duo_security/api_spec.rb +99 -0
- data/spec/duo_security/attempt_spec.rb +28 -0
- metadata +96 -0
@@ -0,0 +1,22 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/duo_security/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Marten Veldthuis"]
|
6
|
+
gem.email = ["marten@veldthuis.com"]
|
7
|
+
gem.description = %q{Perform 2-factor authentication using duosecurity.com}
|
8
|
+
gem.summary = %q{}
|
9
|
+
gem.homepage = "https://github.com/roqua/duo_security"
|
10
|
+
|
11
|
+
gem.files = `git ls-files`.split($\)
|
12
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
13
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
14
|
+
gem.name = "duo_security"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = DuoSecurity::VERSION
|
17
|
+
|
18
|
+
gem.add_dependency "httparty", "~> 0.8.3"
|
19
|
+
|
20
|
+
gem.add_development_dependency "vcr", "~> 2.2.4"
|
21
|
+
gem.add_development_dependency "webmock", "~> 1.8.9"
|
22
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require 'cgi'
|
2
|
+
require 'httparty'
|
3
|
+
|
4
|
+
module DuoSecurity
|
5
|
+
class API
|
6
|
+
class UnknownUser < StandardError; end
|
7
|
+
|
8
|
+
FACTORS = ["auto", "passcode", "phone", "sms", "push"]
|
9
|
+
|
10
|
+
include HTTParty
|
11
|
+
ssl_ca_file File.expand_path(File.join(File.dirname(__FILE__), "..", "..", "data", "ca-bundle.crt"))
|
12
|
+
|
13
|
+
def initialize(host, secret_key, integration_key)
|
14
|
+
@host = host
|
15
|
+
@skey = secret_key
|
16
|
+
@ikey = integration_key
|
17
|
+
|
18
|
+
self.class.base_uri "https://#{@host}/rest/v1"
|
19
|
+
end
|
20
|
+
|
21
|
+
def ping
|
22
|
+
response = self.class.get("/ping")
|
23
|
+
response.parsed_response.fetch("response") == "pong"
|
24
|
+
end
|
25
|
+
|
26
|
+
def check
|
27
|
+
auth = sign("get", @host, "/rest/v1/check", {}, @skey, @ikey)
|
28
|
+
response = self.class.get("/check", headers: {"Authorization" => auth})
|
29
|
+
|
30
|
+
# TODO use parsed_response.fetch(...) when content-type is set correctly
|
31
|
+
response["response"] == "valid"
|
32
|
+
end
|
33
|
+
|
34
|
+
def preauth(user)
|
35
|
+
response = post("/preauth", {"user" => user})["response"]
|
36
|
+
|
37
|
+
raise UnknownUser, response.fetch("status") if response.fetch("result") == "enroll"
|
38
|
+
|
39
|
+
return response
|
40
|
+
end
|
41
|
+
|
42
|
+
def auth(user, factor, factor_params)
|
43
|
+
raise ArgumentError.new("Factor should be one of #{FACTORS.join(", ")}") unless FACTORS.include?(factor)
|
44
|
+
|
45
|
+
params = {"user" => user, "factor" => factor}.merge(factor_params)
|
46
|
+
response = post("/auth",params)
|
47
|
+
|
48
|
+
response["response"]["result"] == "allow"
|
49
|
+
end
|
50
|
+
|
51
|
+
protected
|
52
|
+
|
53
|
+
def post(path, params = {})
|
54
|
+
auth = sign("post", @host, "/rest/v1#{path}", params, @skey, @ikey)
|
55
|
+
self.class.post(path, headers: {"Authorization" => auth}, body: params)
|
56
|
+
end
|
57
|
+
|
58
|
+
def hmac_sha1(key, data)
|
59
|
+
OpenSSL::HMAC.hexdigest(OpenSSL::Digest::Digest.new('sha1'), key, data.to_s)
|
60
|
+
end
|
61
|
+
|
62
|
+
def sign(method, host, path, params, skey, ikey)
|
63
|
+
canon = [method.upcase, host.downcase, path]
|
64
|
+
|
65
|
+
args = []
|
66
|
+
for key in params.keys.sort
|
67
|
+
val = params[key]
|
68
|
+
args << "#{CGI.escape(key)}=#{CGI.escape(val)}"
|
69
|
+
end
|
70
|
+
|
71
|
+
canon << args.join("&")
|
72
|
+
canon = canon.join("\n")
|
73
|
+
|
74
|
+
sig = hmac_sha1(skey, canon)
|
75
|
+
auth = "#{ikey}:#{sig}"
|
76
|
+
|
77
|
+
encoded = Base64.encode64(auth).split("\n").join("")
|
78
|
+
|
79
|
+
return "Basic #{encoded}"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module DuoSecurity
|
2
|
+
class Attempt
|
3
|
+
def initialize(api, username)
|
4
|
+
@api = api
|
5
|
+
@username = username
|
6
|
+
end
|
7
|
+
|
8
|
+
def login!
|
9
|
+
preauth = @api.preauth(@username)
|
10
|
+
factor = preauth["factors"].fetch("default")
|
11
|
+
|
12
|
+
@api.auth(@username, "auto", {"auto" => factor})
|
13
|
+
rescue API::UnknownUser => e
|
14
|
+
false
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/duo_security.rb
ADDED
@@ -0,0 +1,99 @@
|
|
1
|
+
require 'minitest/autorun'
|
2
|
+
require 'vcr'
|
3
|
+
require_relative "../../lib/duo_security/api"
|
4
|
+
|
5
|
+
VCR.configure do |c|
|
6
|
+
c.cassette_library_dir = "fixtures/vcr"
|
7
|
+
c.hook_into :webmock
|
8
|
+
c.allow_http_connections_when_no_cassette = true
|
9
|
+
c.before_http_request(:real?) do |request|
|
10
|
+
puts "Cassette #{VCR.current_cassette.name} being recorded. Take appropriate actions on your phone."
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
module DuoSecurity
|
15
|
+
describe API do
|
16
|
+
let(:host) { ENV["DUO_HOST"] }
|
17
|
+
let(:skey) { ENV["DUO_SKEY"] }
|
18
|
+
let(:ikey) { ENV["DUO_IKEY"] }
|
19
|
+
|
20
|
+
describe '#ping' do
|
21
|
+
it 'succeeds' do
|
22
|
+
VCR.use_cassette("api_ping_success") do
|
23
|
+
duo = API.new(host, skey, ikey)
|
24
|
+
duo.ping.must_equal true
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
describe '#check' do
|
30
|
+
it 'succeeds with correct credentials' do
|
31
|
+
VCR.use_cassette("api_check_success") do
|
32
|
+
duo = API.new(host, skey, ikey)
|
33
|
+
duo.check.must_equal true
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'fails with incorrect skey' do
|
38
|
+
VCR.use_cassette("api_check_wrong_skey") do
|
39
|
+
duo = API.new(host, "wrong", ikey)
|
40
|
+
duo.check.must_equal false
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'fails with incorrect ikey' do
|
45
|
+
VCR.use_cassette("api_check_wrong_ikey") do
|
46
|
+
duo = API.new(host, skey, "wrong")
|
47
|
+
duo.check.must_equal false
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe '#preauth' do
|
53
|
+
it 'returns a list of possible factors' do
|
54
|
+
VCR.use_cassette("api_preauth") do
|
55
|
+
duo = API.new(host, skey, ikey)
|
56
|
+
result = duo.preauth("marten")
|
57
|
+
result["factors"].must_equal({"1"=>"push1", "2"=>"sms1", "default"=>"push1"})
|
58
|
+
result["result"].must_equal("auth")
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
it 'raises when user does not exist' do
|
63
|
+
VCR.use_cassette("api_preauth_unknown_user") do
|
64
|
+
duo = API.new(host, skey, ikey)
|
65
|
+
-> { duo.preauth("unknown") }.must_raise(API::UnknownUser)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
describe '#auth' do
|
71
|
+
let(:duo) { API.new(host, skey, ikey) }
|
72
|
+
|
73
|
+
it 'returns true if user OKs the request' do
|
74
|
+
VCR.use_cassette("api_auth_user_accepts") do
|
75
|
+
result = duo.auth("marten", "push", "phone" => "phone1")
|
76
|
+
result.must_equal(true)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
it 'returns false if the user denies the request as a mistake' do
|
81
|
+
VCR.use_cassette("api_auth_user_denies_mistake") do
|
82
|
+
result = duo.auth("marten", "push", "phone" => "phone1")
|
83
|
+
result.must_equal(false)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
it 'returns false if the user denies the request as a fraudulent attack' do
|
88
|
+
VCR.use_cassette("api_auth_user_denies_fraud") do
|
89
|
+
result = duo.auth("marten", "push", "phone" => "phone1")
|
90
|
+
result.must_equal(false)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'raises an exception when factor is unknown' do
|
95
|
+
-> { duo.auth("marten", "something") }.must_raise(ArgumentError)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'minitest/autorun'
|
2
|
+
require_relative '../../lib/duo_security/attempt'
|
3
|
+
|
4
|
+
module DuoSecurity
|
5
|
+
describe Attempt do
|
6
|
+
let(:api) { API.new(ENV["DUO_HOST"], ENV["DUO_SKEY"], ENV["DUO_IKEY"]) }
|
7
|
+
|
8
|
+
describe 'when using push notifications' do
|
9
|
+
it 'returns true if the user accepts the login' do
|
10
|
+
VCR.use_cassette("attempt_allowed") do
|
11
|
+
Attempt.new(api, "marten").login!.must_equal true
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'returns false if the user denies the login' do
|
16
|
+
VCR.use_cassette("attempt_disallowed") do
|
17
|
+
Attempt.new(api, "marten").login!.must_equal false
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'returns false if the user is not known' do
|
22
|
+
VCR.use_cassette("attempt_user_unknown") do
|
23
|
+
Attempt.new(api, "unknown").login!.must_equal false
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
metadata
ADDED
@@ -0,0 +1,96 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: duo_security
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Marten Veldthuis
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-09-05 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: httparty
|
16
|
+
requirement: &70240733958400 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 0.8.3
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70240733958400
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: vcr
|
27
|
+
requirement: &70240733957900 !ruby/object:Gem::Requirement
|
28
|
+
none: false
|
29
|
+
requirements:
|
30
|
+
- - ~>
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 2.2.4
|
33
|
+
type: :development
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: *70240733957900
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: webmock
|
38
|
+
requirement: &70240733957380 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ~>
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: 1.8.9
|
44
|
+
type: :development
|
45
|
+
prerelease: false
|
46
|
+
version_requirements: *70240733957380
|
47
|
+
description: Perform 2-factor authentication using duosecurity.com
|
48
|
+
email:
|
49
|
+
- marten@veldthuis.com
|
50
|
+
executables:
|
51
|
+
- duo
|
52
|
+
extensions: []
|
53
|
+
extra_rdoc_files: []
|
54
|
+
files:
|
55
|
+
- .gitignore
|
56
|
+
- Gemfile
|
57
|
+
- LICENSE
|
58
|
+
- README.md
|
59
|
+
- Rakefile
|
60
|
+
- bin/duo
|
61
|
+
- data/ca-bundle.crt
|
62
|
+
- duo_security.gemspec
|
63
|
+
- lib/duo_security.rb
|
64
|
+
- lib/duo_security/api.rb
|
65
|
+
- lib/duo_security/attempt.rb
|
66
|
+
- lib/duo_security/version.rb
|
67
|
+
- spec/duo_security/api_spec.rb
|
68
|
+
- spec/duo_security/attempt_spec.rb
|
69
|
+
homepage: https://github.com/roqua/duo_security
|
70
|
+
licenses: []
|
71
|
+
post_install_message:
|
72
|
+
rdoc_options: []
|
73
|
+
require_paths:
|
74
|
+
- lib
|
75
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
76
|
+
none: false
|
77
|
+
requirements:
|
78
|
+
- - ! '>='
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
version: '0'
|
81
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
82
|
+
none: false
|
83
|
+
requirements:
|
84
|
+
- - ! '>='
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: '0'
|
87
|
+
requirements: []
|
88
|
+
rubyforge_project:
|
89
|
+
rubygems_version: 1.8.11
|
90
|
+
signing_key:
|
91
|
+
specification_version: 3
|
92
|
+
summary: ''
|
93
|
+
test_files:
|
94
|
+
- spec/duo_security/api_spec.rb
|
95
|
+
- spec/duo_security/attempt_spec.rb
|
96
|
+
has_rdoc:
|