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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +2 -0
  3. data/.rubocop.yml +18 -0
  4. data/.travis.yml +9 -1
  5. data/CONDUCT.md +5 -0
  6. data/CONTRIBUTING.md +14 -0
  7. data/Gemfile +11 -2
  8. data/Guardfile +5 -0
  9. data/LICENSE +202 -0
  10. data/README.md +267 -7
  11. data/Rakefile +3 -1
  12. data/lib/rails/auth.rb +3 -6
  13. data/lib/rails/auth/acl.rb +88 -0
  14. data/lib/rails/auth/acl/matchers/allow_all.rb +23 -0
  15. data/lib/rails/auth/acl/middleware.rb +31 -0
  16. data/lib/rails/auth/acl/resource.rb +74 -0
  17. data/lib/rails/auth/exceptions.rb +9 -0
  18. data/lib/rails/auth/principals.rb +36 -0
  19. data/lib/rails/auth/rack.rb +19 -0
  20. data/lib/rails/auth/rspec.rb +6 -0
  21. data/lib/rails/auth/rspec/helper_methods.rb +51 -0
  22. data/lib/rails/auth/rspec/matchers/acl_matchers.rb +13 -0
  23. data/lib/rails/auth/version.rb +4 -1
  24. data/lib/rails/auth/x509/filter/java.rb +25 -0
  25. data/lib/rails/auth/x509/filter/pem.rb +14 -0
  26. data/lib/rails/auth/x509/matcher.rb +22 -0
  27. data/lib/rails/auth/x509/middleware.rb +78 -0
  28. data/lib/rails/auth/x509/principal.rb +41 -0
  29. data/rails-auth.gemspec +20 -9
  30. data/spec/fixtures/example_acl.yml +27 -0
  31. data/spec/rails/auth/acl/matchers/allow_all_spec.rb +32 -0
  32. data/spec/rails/auth/acl/middleware_spec.rb +24 -0
  33. data/spec/rails/auth/acl/resource_spec.rb +105 -0
  34. data/spec/rails/auth/acl_spec.rb +28 -0
  35. data/spec/rails/auth/principals_spec.rb +36 -0
  36. data/spec/rails/auth/rspec/helper_methods_spec.rb +42 -0
  37. data/spec/rails/auth/rspec/matchers/acl_matchers_spec.rb +20 -0
  38. data/spec/rails/auth/x509/matcher_spec.rb +21 -0
  39. data/spec/rails/auth/x509/middleware_spec.rb +74 -0
  40. data/spec/rails/auth/x509/principal_spec.rb +27 -0
  41. data/spec/rails/auth_spec.rb +5 -0
  42. data/spec/spec_helper.rb +23 -0
  43. data/spec/support/claims_predicate.rb +11 -0
  44. data/spec/support/create_certs.rb +59 -0
  45. metadata +60 -24
  46. data/bin/console +0 -14
  47. 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
@@ -1,23 +1,34 @@
1
1
  # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
2
+ lib = File.expand_path("../lib", __FILE__)
3
3
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require 'rails/auth/version'
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 = ["bascule@gmail.com"]
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 = "Placeholder for an upcoming gem. Stay tuned!"
13
- spec.description = "An exciting gem awaits this name really soon now!"
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
- spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
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.add_development_dependency "bundler", "~> 1.11"
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