rails-auth 0.0.0 → 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.
- 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
|