rails-auth 0.0.0 → 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rspec +2 -0
- data/.rubocop.yml +18 -0
- data/.travis.yml +9 -1
- data/CONDUCT.md +5 -0
- data/CONTRIBUTING.md +14 -0
- data/Gemfile +11 -2
- data/Guardfile +5 -0
- data/LICENSE +202 -0
- data/README.md +267 -7
- data/Rakefile +3 -1
- data/lib/rails/auth.rb +3 -6
- data/lib/rails/auth/acl.rb +88 -0
- data/lib/rails/auth/acl/matchers/allow_all.rb +23 -0
- data/lib/rails/auth/acl/middleware.rb +31 -0
- data/lib/rails/auth/acl/resource.rb +74 -0
- data/lib/rails/auth/exceptions.rb +9 -0
- data/lib/rails/auth/principals.rb +36 -0
- data/lib/rails/auth/rack.rb +19 -0
- data/lib/rails/auth/rspec.rb +6 -0
- data/lib/rails/auth/rspec/helper_methods.rb +51 -0
- data/lib/rails/auth/rspec/matchers/acl_matchers.rb +13 -0
- data/lib/rails/auth/version.rb +4 -1
- data/lib/rails/auth/x509/filter/java.rb +25 -0
- data/lib/rails/auth/x509/filter/pem.rb +14 -0
- data/lib/rails/auth/x509/matcher.rb +22 -0
- data/lib/rails/auth/x509/middleware.rb +78 -0
- data/lib/rails/auth/x509/principal.rb +41 -0
- data/rails-auth.gemspec +20 -9
- data/spec/fixtures/example_acl.yml +27 -0
- data/spec/rails/auth/acl/matchers/allow_all_spec.rb +32 -0
- data/spec/rails/auth/acl/middleware_spec.rb +24 -0
- data/spec/rails/auth/acl/resource_spec.rb +105 -0
- data/spec/rails/auth/acl_spec.rb +28 -0
- data/spec/rails/auth/principals_spec.rb +36 -0
- data/spec/rails/auth/rspec/helper_methods_spec.rb +42 -0
- data/spec/rails/auth/rspec/matchers/acl_matchers_spec.rb +20 -0
- data/spec/rails/auth/x509/matcher_spec.rb +21 -0
- data/spec/rails/auth/x509/middleware_spec.rb +74 -0
- data/spec/rails/auth/x509/principal_spec.rb +27 -0
- data/spec/rails/auth_spec.rb +5 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/support/claims_predicate.rb +11 -0
- data/spec/support/create_certs.rb +59 -0
- metadata +60 -24
- data/bin/console +0 -14
- data/bin/setup +0 -8
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rails
|
4
|
+
module Auth
|
5
|
+
module X509
|
6
|
+
# Raised when certificate verification is mandatory
|
7
|
+
CertificateVerifyFailed = Class.new(NotAuthorizedError)
|
8
|
+
|
9
|
+
# Validates X.509 client certificates and adds principal objects for valid
|
10
|
+
# clients to the rack environment as env["rails-auth.principals"]["x509"]
|
11
|
+
class Middleware
|
12
|
+
# Create a new X.509 Middleware object
|
13
|
+
#
|
14
|
+
# @param [Object] app next app in the Rack middleware chain
|
15
|
+
# @param [Hash] cert_filters maps Rack environment names to cert extractors
|
16
|
+
# @param [String] ca_file path to the CA bundle to verify client certs with
|
17
|
+
# @param [OpenSSL::X509::Store] truststore (optional) provide your own truststore (for e.g. CRLs)
|
18
|
+
# @param [Boolean] require_cert causes middleware to raise if certs are unverified
|
19
|
+
#
|
20
|
+
# @return [Rails::Auth::X509::Middleware] new X509 middleware instance
|
21
|
+
def initialize(app, cert_filters: {}, ca_file: nil, truststore: nil, require_cert: false, logger: nil)
|
22
|
+
fail ArgumentError, "no ca_file given" unless ca_file
|
23
|
+
|
24
|
+
@app = app
|
25
|
+
@logger = logger
|
26
|
+
@truststore = truststore || OpenSSL::X509::Store.new.add_file(ca_file)
|
27
|
+
@require_cert = require_cert
|
28
|
+
@cert_filters = cert_filters
|
29
|
+
|
30
|
+
@cert_filters.each do |key, filter|
|
31
|
+
next unless filter.is_a?(Symbol)
|
32
|
+
|
33
|
+
# Shortcut syntax for symbols
|
34
|
+
@cert_filters[key] = Rails::Auth::X509::Filter.const_get(filter.to_s.capitalize).new
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def call(env)
|
39
|
+
principal = extract_principal(env)
|
40
|
+
Rails::Auth.add_principal(env, "x509".freeze, principal.freeze) if principal
|
41
|
+
|
42
|
+
@app.call(env)
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def extract_principal(env)
|
48
|
+
@cert_filters.each do |key, filter|
|
49
|
+
raw_cert = env[key]
|
50
|
+
next unless raw_cert
|
51
|
+
|
52
|
+
cert = filter.call(raw_cert)
|
53
|
+
next unless cert
|
54
|
+
|
55
|
+
if @truststore.verify(cert)
|
56
|
+
log("Verified", cert)
|
57
|
+
return Rails::Auth::X509::Principal.new(cert)
|
58
|
+
else
|
59
|
+
log("Verify FAILED", cert)
|
60
|
+
fail CertificateVerifyFailed, "verify failed: #{subject(cert)}" if @require_cert
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
fail CertificateVerifyFailed, "no client certificate in request" if @require_cert
|
65
|
+
nil
|
66
|
+
end
|
67
|
+
|
68
|
+
def log(message, cert)
|
69
|
+
@logger.debug("rails-auth: #{message} (#{subject(cert)})") if @logger
|
70
|
+
end
|
71
|
+
|
72
|
+
def subject(cert)
|
73
|
+
cert.subject.to_a.map { |attr, data| "#{attr}=#{data}" }.join(",")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rails
|
4
|
+
module Auth
|
5
|
+
module X509
|
6
|
+
# HTTPS principal identified by an X.509 client certificate
|
7
|
+
class Principal
|
8
|
+
attr_reader :certificate
|
9
|
+
|
10
|
+
def initialize(certificate)
|
11
|
+
unless certificate.is_a?(OpenSSL::X509::Certificate)
|
12
|
+
fail TypeError, "expecting OpenSSL::X509::Certificate, got #{certificate.class}"
|
13
|
+
end
|
14
|
+
|
15
|
+
@certificate = certificate.freeze
|
16
|
+
@subject = {}
|
17
|
+
|
18
|
+
@certificate.subject.to_a.each do |name, data, _type|
|
19
|
+
@subject[name.freeze] = data.freeze
|
20
|
+
end
|
21
|
+
|
22
|
+
@subject.freeze
|
23
|
+
end
|
24
|
+
|
25
|
+
def [](component)
|
26
|
+
@subject[component.to_s.upcase]
|
27
|
+
end
|
28
|
+
|
29
|
+
def cn
|
30
|
+
@subject["CN".freeze]
|
31
|
+
end
|
32
|
+
alias common_name cn
|
33
|
+
|
34
|
+
def ou
|
35
|
+
@subject["OU".freeze]
|
36
|
+
end
|
37
|
+
alias organizational_unit ou
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
data/rails-auth.gemspec
CHANGED
@@ -1,23 +1,34 @@
|
|
1
1
|
# coding: utf-8
|
2
|
-
lib = File.expand_path(
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
3
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
-
require
|
4
|
+
require "rails/auth/version"
|
5
5
|
|
6
6
|
Gem::Specification.new do |spec|
|
7
7
|
spec.name = "rails-auth"
|
8
8
|
spec.version = Rails::Auth::VERSION
|
9
9
|
spec.authors = ["Tony Arcieri"]
|
10
|
-
spec.email = ["
|
10
|
+
spec.email = ["tonyarcieri@squareup.com"]
|
11
|
+
spec.homepage = "https://github.com/square/rails-auth/"
|
12
|
+
spec.licenses = ["Apache-2.0"]
|
11
13
|
|
12
|
-
spec.summary = "
|
13
|
-
spec.description = "
|
14
|
+
spec.summary = "Modular resource-oriented authentication and authorization for Rails/Rack"
|
15
|
+
spec.description = <<-DESCRIPTION.strip.gsub(/\s+/, " ")
|
16
|
+
A plugin-based framework for supporting multiple authentication and
|
17
|
+
authorization systems in Rails/Rack apps. Supports resource-oriented
|
18
|
+
route-by-route access control lists with TLS authentication.
|
19
|
+
DESCRIPTION
|
14
20
|
|
15
|
-
|
21
|
+
# Only allow gem to be pushed to https://rubygems.org
|
22
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
23
|
+
|
24
|
+
spec.files = `git ls-files`.split("\n")
|
16
25
|
spec.bindir = "exe"
|
17
|
-
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
18
26
|
spec.require_paths = ["lib"]
|
19
27
|
|
20
|
-
spec.
|
28
|
+
spec.required_ruby_version = ">= 2.0.0"
|
29
|
+
|
30
|
+
spec.add_runtime_dependency "rack"
|
31
|
+
|
32
|
+
spec.add_development_dependency "bundler", "~> 1.10"
|
21
33
|
spec.add_development_dependency "rake", "~> 10.0"
|
22
|
-
spec.add_development_dependency "rspec", "~> 3.0"
|
23
34
|
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
---
|
2
|
+
- resources:
|
3
|
+
- method: ALL
|
4
|
+
path: /foo/bar/.*
|
5
|
+
allow_x509_subject:
|
6
|
+
ou: ponycopter
|
7
|
+
allow_claims:
|
8
|
+
group: example
|
9
|
+
- resources:
|
10
|
+
- method: GET
|
11
|
+
path: /baz/.*
|
12
|
+
allow_x509_subject:
|
13
|
+
ou: ponycopter
|
14
|
+
- resources:
|
15
|
+
- method: ALL
|
16
|
+
path: /_admin/?.*
|
17
|
+
allow_claims:
|
18
|
+
group: admins
|
19
|
+
- resources:
|
20
|
+
- method: GET
|
21
|
+
path: /internal/frobnobs/.*
|
22
|
+
allow_x509_subject:
|
23
|
+
ou: frobnobber
|
24
|
+
- resources:
|
25
|
+
- method: GET
|
26
|
+
path: /
|
27
|
+
allow_all: true
|
@@ -0,0 +1,32 @@
|
|
1
|
+
RSpec.describe Rails::Auth::ACL::Matchers::AllowAll do
|
2
|
+
let(:predicate) { described_class.new(enabled) }
|
3
|
+
let(:example_env) { env_for(:get, "/") }
|
4
|
+
|
5
|
+
describe "#initialize" do
|
6
|
+
it "raises if given nil" do
|
7
|
+
expect { described_class.new(nil) }.to raise_error(ArgumentError)
|
8
|
+
end
|
9
|
+
|
10
|
+
it "raises if given a non-boolean" do
|
11
|
+
expect { described_class.new(42) }.to raise_error(ArgumentError)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
describe "#match" do
|
16
|
+
context "enabled" do
|
17
|
+
let(:enabled) { true }
|
18
|
+
|
19
|
+
it "allows all requests" do
|
20
|
+
expect(predicate.match(example_env)).to eq true
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
context "disabled" do
|
25
|
+
let(:enabled) { false }
|
26
|
+
|
27
|
+
it "rejects all requests" do
|
28
|
+
expect(predicate.match(example_env)).to eq false
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require "logger"
|
2
|
+
|
3
|
+
RSpec.describe Rails::Auth::ACL::Middleware do
|
4
|
+
let(:request) { Rack::MockRequest.env_for("https://www.example.com") }
|
5
|
+
let(:app) { ->(env) { [200, env, "Hello, world!"] } }
|
6
|
+
let(:acl) { instance_double(Rails::Auth::ACL, match: authorized) }
|
7
|
+
let(:middleware) { described_class.new(app, acl: acl) }
|
8
|
+
|
9
|
+
context "authorized" do
|
10
|
+
let(:authorized) { true }
|
11
|
+
|
12
|
+
it "allows authorized requests" do
|
13
|
+
expect(middleware.call(request)[0]).to eq 200
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
context "unauthorized" do
|
18
|
+
let(:authorized) { false }
|
19
|
+
|
20
|
+
it "raises Rails::Auth::NotAuthorizedError for unauthorized requests" do
|
21
|
+
expect { expect(middleware.call(request)) }.to raise_error(Rails::Auth::NotAuthorizedError)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
RSpec.describe Rails::Auth::ACL::Resource do
|
2
|
+
let(:example_method) { "GET" }
|
3
|
+
let(:another_method) { "POST" }
|
4
|
+
let(:example_path) { "/foobar" }
|
5
|
+
let(:another_path) { "/baz" }
|
6
|
+
|
7
|
+
let(:example_options) do
|
8
|
+
{
|
9
|
+
"method" => example_method,
|
10
|
+
"path" => example_path
|
11
|
+
}
|
12
|
+
end
|
13
|
+
|
14
|
+
let(:example_predicates) { { "example" => double(:predicate, match: predicate_matches) } }
|
15
|
+
let(:example_resource) { described_class.new(example_options, example_predicates) }
|
16
|
+
let(:example_env) { env_for(example_method, example_path) }
|
17
|
+
|
18
|
+
describe "#initialize" do
|
19
|
+
it "initializes with a method and a path" do
|
20
|
+
resource = described_class.new(
|
21
|
+
{
|
22
|
+
"method" => example_method,
|
23
|
+
"path" => example_path
|
24
|
+
},
|
25
|
+
{}
|
26
|
+
)
|
27
|
+
|
28
|
+
expect(resource.http_methods).to eq [example_method]
|
29
|
+
end
|
30
|
+
|
31
|
+
it "accepts ALL as a specifier for all HTTP methods" do
|
32
|
+
resource = described_class.new(
|
33
|
+
{
|
34
|
+
"method" => "ALL",
|
35
|
+
"path" => example_path
|
36
|
+
},
|
37
|
+
{}
|
38
|
+
)
|
39
|
+
|
40
|
+
expect(resource.http_methods).to eq nil
|
41
|
+
end
|
42
|
+
|
43
|
+
context "errors" do
|
44
|
+
let(:invalid_method) { "DERP" }
|
45
|
+
|
46
|
+
it "raises ParseError for invalid HTTP methods" do
|
47
|
+
expect do
|
48
|
+
described_class.new(
|
49
|
+
{
|
50
|
+
"method" => invalid_method,
|
51
|
+
"path" => example_path
|
52
|
+
},
|
53
|
+
{}
|
54
|
+
)
|
55
|
+
end.to raise_error(Rails::Auth::ParseError)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
describe "#match" do
|
61
|
+
context "with matching predicates and method/path" do
|
62
|
+
let(:predicate_matches) { true }
|
63
|
+
|
64
|
+
it "matches against a valid resource" do
|
65
|
+
expect(example_resource.match(example_env)).to eq true
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
context "without matching predicates" do
|
70
|
+
let(:predicate_matches) { false }
|
71
|
+
|
72
|
+
it "doesn't match against a valid resource" do
|
73
|
+
expect(example_resource.match(example_env)).to eq false
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
context "without a method/path match" do
|
78
|
+
let(:predicate_matches) { true }
|
79
|
+
|
80
|
+
it "doesn't match" do
|
81
|
+
env = env_for(another_method, example_path)
|
82
|
+
expect(example_resource.match(env)).to eq false
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
describe "#match_method_and_path" do
|
88
|
+
let(:predicate_matches) { false }
|
89
|
+
|
90
|
+
it "matches against all methods if specified" do
|
91
|
+
resource = described_class.new(example_options.merge("method" => "ALL"), example_predicates)
|
92
|
+
expect(resource.match_method_and_path(example_env)).to eq true
|
93
|
+
end
|
94
|
+
|
95
|
+
it "doesn't match if the method mismatches" do
|
96
|
+
env = env_for(another_method, example_path)
|
97
|
+
expect(example_resource.match_method_and_path(env)).to eq false
|
98
|
+
end
|
99
|
+
|
100
|
+
it "doesn't match if the path mismatches" do
|
101
|
+
env = env_for(example_method, another_path)
|
102
|
+
expect(example_resource.match_method_and_path(env)).to eq false
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
RSpec.describe Rails::Auth::ACL do
|
2
|
+
let(:example_config) { fixture_path("example_acl.yml").read }
|
3
|
+
|
4
|
+
let(:example_acl) do
|
5
|
+
described_class.from_yaml(
|
6
|
+
example_config,
|
7
|
+
matchers: {
|
8
|
+
allow_x509_subject: Rails::Auth::X509::Matcher,
|
9
|
+
allow_claims: ClaimsPredicate
|
10
|
+
}
|
11
|
+
)
|
12
|
+
end
|
13
|
+
|
14
|
+
describe "#match" do
|
15
|
+
it "matches routes against the ACL" do
|
16
|
+
expect(example_acl.match(env_for(:get, "/"))).to eq true
|
17
|
+
expect(example_acl.match(env_for(:get, "/foo/bar/baz"))).to eq true
|
18
|
+
expect(example_acl.match(env_for(:get, "/_admin"))).to eq false
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
describe "#matching_resources" do
|
23
|
+
it "finds Rails::Auth::ACL::Resource objects that match the request" do
|
24
|
+
resources = example_acl.matching_resources(env_for(:get, "/foo/bar/baz"))
|
25
|
+
expect(resources.first.path).to eq %r{\A/foo/bar/.*\z}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
RSpec.describe Rails::Auth::Principals do
|
2
|
+
describe "#principals" do
|
3
|
+
let(:example_type) { "example" }
|
4
|
+
let(:example_principals) { { example_type => double(:principal) } }
|
5
|
+
|
6
|
+
let(:example_env) do
|
7
|
+
env_for(:get, "/").tap do |env|
|
8
|
+
env[Rails::Auth::PRINCIPALS_ENV_KEY] = example_principals
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
it "extracts principals from Rack environments" do
|
13
|
+
expect(Rails::Auth.principals(example_env)).to eq example_principals
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
describe "#add_principal" do
|
18
|
+
let(:example_type) { "example" }
|
19
|
+
let(:example_principal) { double(:principal) }
|
20
|
+
let(:example_env) { env_for(:get, "/") }
|
21
|
+
|
22
|
+
it "adds principals to a Rack environment" do
|
23
|
+
expect(Rails::Auth.principals(example_env)[example_type]).to be_nil
|
24
|
+
Rails::Auth.add_principal(example_env, example_type, example_principal)
|
25
|
+
expect(Rails::Auth.principals(example_env)[example_type]).to eq example_principal
|
26
|
+
end
|
27
|
+
|
28
|
+
it "raises ArgumentError if the same type of principal is added twice" do
|
29
|
+
Rails::Auth.add_principal(example_env, example_type, example_principal)
|
30
|
+
|
31
|
+
expect do
|
32
|
+
Rails::Auth.add_principal(example_env, example_type, example_principal)
|
33
|
+
end.to raise_error(ArgumentError)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
RSpec.describe Rails::Auth::RSpec::HelperMethods, acl_spec: true do
|
2
|
+
let(:example_cn) { "127.0.0.1" }
|
3
|
+
let(:example_ou) { "ponycopter" }
|
4
|
+
|
5
|
+
describe "#x509_principal" do
|
6
|
+
subject { x509_principal(cn: example_cn, ou: example_ou) }
|
7
|
+
|
8
|
+
it "creates instance doubles for Rails::Auth::X509::Principals" do
|
9
|
+
# Method syntax
|
10
|
+
expect(subject.cn).to eq example_cn
|
11
|
+
expect(subject.ou).to eq example_ou
|
12
|
+
|
13
|
+
# Hash-like syntax
|
14
|
+
expect(subject[:cn]).to eq example_cn
|
15
|
+
expect(subject[:ou]).to eq example_ou
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
describe "#x509_principal_hash" do
|
20
|
+
subject { x509_principal_hash(cn: example_cn, ou: example_ou) }
|
21
|
+
|
22
|
+
it "creates a principal hash with an Rails::Auth::X509::Principal double" do
|
23
|
+
expect(subject["x509"].cn).to eq example_cn
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
Rails::Auth::ACL::Resource::HTTP_METHODS.each do |method|
|
28
|
+
describe "##{method.downcase}_request" do
|
29
|
+
it "returns a Rack environment" do
|
30
|
+
# These methods introspect self.class.description to find the path
|
31
|
+
allow(self.class).to receive(:description).and_return("/")
|
32
|
+
env = method("#{method.downcase}_request").call
|
33
|
+
|
34
|
+
expect(env["REQUEST_METHOD"]).to eq method
|
35
|
+
end
|
36
|
+
|
37
|
+
it "raises ArgumentError if the description doesn't start with /" do
|
38
|
+
expect { method("#{method.downcase}_request").call }.to raise_error(ArgumentError)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|