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.
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