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
data/Rakefile CHANGED
@@ -1,6 +1,8 @@
1
1
  require "bundler/gem_tasks"
2
2
  require "rspec/core/rake_task"
3
+ require "rubocop/rake_task"
3
4
 
4
5
  RSpec::Core::RakeTask.new(:spec)
6
+ RuboCop::RakeTask.new
5
7
 
6
- task :default => :spec
8
+ task default: %w(spec rubocop)
@@ -1,7 +1,4 @@
1
- require "rails/auth/version"
1
+ # frozen_string_literal: true
2
2
 
3
- module Rails
4
- module Auth
5
- # Your code goes here...
6
- end
7
- end
3
+ # Pull in core library components that work with any Rack application
4
+ require "rails/auth/rack"
@@ -0,0 +1,88 @@
1
+ module Rails
2
+ module Auth
3
+ # Route-based access control lists
4
+ class ACL
5
+ # Predicate matchers available by default in ACLs
6
+ # These are added by the individual files in lib/rails/auth/acl/matchers
7
+ # at the time they're loaded.
8
+ DEFAULT_MATCHERS = {} # rubocop:disable Style/MutableConstant
9
+
10
+ # Pull in default predicate matchers
11
+ require "rails/auth/acl/matchers/allow_all"
12
+
13
+ DEFAULT_MATCHERS.freeze
14
+
15
+ # Create a Rails::Auth::ACL from a YAML representation of an ACL
16
+ #
17
+ # @param [String] :yaml serialized YAML to load an ACL from
18
+ def self.from_yaml(yaml, **args)
19
+ require "yaml"
20
+ new(YAML.load(yaml), **args)
21
+ end
22
+
23
+ # @param [Array<Hash>] :acl Access Control List configuration
24
+ # @param [Hash] :matchers predicate matchers for use with this ACL
25
+ #
26
+ def initialize(acl, matchers: {})
27
+ @resources = []
28
+
29
+ acl.each_with_index do |entry|
30
+ resources = entry["resources"]
31
+ fail ParseError, "no 'resources' key present in entry: #{entry.inspect}" unless resources
32
+
33
+ predicates = parse_predicates(entry, matchers.merge(DEFAULT_MATCHERS))
34
+
35
+ resources.each do |resource|
36
+ @resources << Resource.new(resource, predicates).freeze
37
+ end
38
+ end
39
+
40
+ @resources.freeze
41
+ end
42
+
43
+ # Match the Rack environment against the ACL, checking all predicates
44
+ #
45
+ # @param [Hash] :env Rack environment
46
+ #
47
+ # @return [Boolean] is the request authorized?
48
+ #
49
+ def match(env)
50
+ @resources.any? { |resource| resource.match(env) }
51
+ end
52
+
53
+ # Find all resources that match the ACL. Predicates are *NOT* checked,
54
+ # instead only the initial checks for the "resources" section of the ACL
55
+ # are performed. Use the `#match` method to validate predicates.
56
+ #
57
+ # This method is intended for debugging AuthZ failures. It can find all
58
+ # resources that match the given request so the corresponding predicates
59
+ # can be introspected.
60
+ #
61
+ # @param [Hash] :env Rack environment
62
+ #
63
+ # @return [Array<Rails::Auth::ACL::Resource>] matching resources
64
+ #
65
+ def matching_resources(env)
66
+ @resources.find_all { |resource| resource.match_method_and_path(env) }
67
+ end
68
+
69
+ private
70
+
71
+ def parse_predicates(entry, matchers)
72
+ predicates = {}
73
+
74
+ entry.each do |name, options|
75
+ next if name == "resources"
76
+
77
+ matcher_class = matchers[name.to_sym]
78
+ fail ArgumentError, "no matcher for #{name}" unless matcher_class
79
+ fail TypeError, "expected Class for #{name}" unless matcher_class.is_a?(Class)
80
+
81
+ predicates[name.freeze] = matcher_class.new(options.freeze).freeze
82
+ end
83
+
84
+ predicates.freeze
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,23 @@
1
+ module Rails
2
+ module Auth
3
+ class ACL
4
+ # Built-in predicate matchers
5
+ module Matchers
6
+ # Allows all principals access to a given resource
7
+ class AllowAll
8
+ def initialize(enabled)
9
+ fail ArgumentError, "enabled must be true/false" unless [true, false].include?(enabled)
10
+ @enabled = enabled
11
+ end
12
+
13
+ def match(_env)
14
+ @enabled
15
+ end
16
+ end
17
+
18
+ # Make `allow_all` available by default as an ACL matcher
19
+ ACL::DEFAULT_MATCHERS[:allow_all] = AllowAll
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,31 @@
1
+ module Rails
2
+ module Auth
3
+ class ACL
4
+ # Authorizes requests by matching them against the given ACL
5
+ class Middleware
6
+ # Create Rails::Auth::ACL::Middleware from the args you'd pass to Rails::Auth::ACL's constructor
7
+ def self.from_acl_config(app, **args)
8
+ new(app, acl: Rails::Auth::ACL.new(**args))
9
+ end
10
+
11
+ # Create a new ACL Middleware object
12
+ #
13
+ # @param [Object] app next app in the Rack middleware chain
14
+ # @param [Hash] acl Rails::Auth::ACL object to authorize the request with
15
+ #
16
+ # @return [Rails::Auth::ACL::Middleware] new ACL middleware instance
17
+ def initialize(app, acl: nil)
18
+ fail ArgumentError, "no acl given" unless acl
19
+
20
+ @app = app
21
+ @acl = acl
22
+ end
23
+
24
+ def call(env)
25
+ fail NotAuthorizedError, "unauthorized request" unless @acl.match(env)
26
+ @app.call(env)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Auth
5
+ class ACL
6
+ # Rules for a particular route
7
+ class Resource
8
+ attr_reader :http_methods, :path, :predicates
9
+
10
+ # Valid HTTP methods
11
+ HTTP_METHODS = %w(GET HEAD PUT POST DELETE OPTIONS PATCH LINK UNLINK).freeze
12
+
13
+ # Options allowed for resource matchers
14
+ VALID_OPTIONS = %w(method path).freeze
15
+
16
+ # @option :options [String] :method HTTP method allowed ("ALL" for all methods)
17
+ # @option :options [String] :path path to the resource (regex syntax allowed)
18
+ # @param [Hash] :predicates matchers for this resource
19
+ #
20
+ def initialize(options, predicates)
21
+ fail TypeError, "expected Hash for options" unless options.is_a?(Hash)
22
+ fail TypeError, "expected Hash for predicates" unless predicates.is_a?(Hash)
23
+
24
+ unless (extra_keys = options.keys - VALID_OPTIONS).empty?
25
+ fail ParseError, "unrecognized key in ACL resource: #{extra_keys.first}"
26
+ end
27
+
28
+ @http_methods = extract_methods(options["method"])
29
+ @path = /\A#{options.fetch("path")}\z/
30
+ @predicates = predicates.freeze
31
+ end
32
+
33
+ # Match this resource against the given Rack environment, checking all
34
+ # predicates to ensure at least one of them matches
35
+ #
36
+ # @param [Hash] :env Rack environment
37
+ #
38
+ # @return [Boolean] resource and predicates match the given request
39
+ #
40
+ def match(env)
41
+ return false unless match_method_and_path(env)
42
+ @predicates.any? { |_name, predicate| predicate.match(env) }
43
+ end
44
+
45
+ # Match *only* the request method/path against the given Rack environment.
46
+ # Predicates are NOT checked.
47
+ #
48
+ # @param [Hash] :env Rack environment
49
+ #
50
+ # @return [Boolean] method and path *only* match the given environment
51
+ #
52
+ def match_method_and_path(env)
53
+ return false unless @http_methods.nil? || @http_methods.include?(env["REQUEST_METHOD".freeze])
54
+ return false unless @path =~ env["REQUEST_PATH".freeze]
55
+ true
56
+ end
57
+
58
+ private
59
+
60
+ def extract_methods(methods)
61
+ methods = Array(methods)
62
+
63
+ return nil if methods.include?("ALL")
64
+
65
+ methods.each do |method|
66
+ fail ParseError, "invalid HTTP method: #{method}" unless HTTP_METHODS.include?(method)
67
+ end
68
+
69
+ methods.freeze
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,9 @@
1
+ module Rails
2
+ module Auth
3
+ # Unauthorized!
4
+ NotAuthorizedError = Class.new(StandardError)
5
+
6
+ # Error parsing e.g. an ACL
7
+ ParseError = Class.new(StandardError)
8
+ end
9
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ # Modular resource-based authentication and authorization for Rails/Rack
5
+ module Auth
6
+ # Rack environment key for all rails-auth principals
7
+ PRINCIPALS_ENV_KEY = "rails-auth.principals".freeze
8
+
9
+ # Functionality for storing principals in the Rack environment
10
+ module Principals
11
+ # Obtain principals from a Rack environment
12
+ #
13
+ # @param [Hash] :env Rack environment
14
+ #
15
+ def principals(env)
16
+ env.fetch(PRINCIPALS_ENV_KEY, {})
17
+ end
18
+
19
+ # Add a principal to the Rack environment
20
+ #
21
+ # @param [Hash] :env Rack environment
22
+ # @param [String] :type principal type to add to the environment
23
+ # @param [Object] :principal principal object to add to the environment
24
+ #
25
+ def add_principal(env, type, principal)
26
+ principals = env[PRINCIPALS_ENV_KEY] ||= {}
27
+
28
+ fail ArgumentError, "principal #{type} already added to request" if principals.key?(type)
29
+ principals[type] = principal
30
+ end
31
+ end
32
+
33
+ # Include these functions in Rails::Auth for convenience
34
+ extend Principals
35
+ end
36
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Core library components that work with any Rack application
4
+ require "rack"
5
+ require "openssl"
6
+
7
+ require "rails/auth/version"
8
+ require "rails/auth/exceptions"
9
+ require "rails/auth/principals"
10
+
11
+ require "rails/auth/acl"
12
+ require "rails/auth/acl/middleware"
13
+ require "rails/auth/acl/resource"
14
+
15
+ require "rails/auth/x509/filter/pem"
16
+ require "rails/auth/x509/filter/java" if defined?(JRUBY_VERSION)
17
+ require "rails/auth/x509/matcher"
18
+ require "rails/auth/x509/middleware"
19
+ require "rails/auth/x509/principal"
@@ -0,0 +1,6 @@
1
+ require "rails/auth/rspec/helper_methods"
2
+ require "rails/auth/rspec/matchers/acl_matchers"
3
+
4
+ RSpec.configure do |config|
5
+ config.include Rails::Auth::RSpec::HelperMethods, acl_spec: true
6
+ end
@@ -0,0 +1,51 @@
1
+ module Rails
2
+ module Auth
3
+ module RSpec
4
+ # RSpec helper methods
5
+ module HelperMethods
6
+ # Creates an Rails::Auth::X509::Principal instance double
7
+ def x509_principal(cn: nil, ou: nil)
8
+ subject = ""
9
+ subject << "CN=#{cn}" if cn
10
+ subject << "OU=#{ou}" if ou
11
+
12
+ instance_double(X509::Principal, subject, cn: cn, ou: ou).tap do |principal|
13
+ allow(principal).to receive(:[]) do |key|
14
+ {
15
+ "CN" => cn,
16
+ "OU" => ou
17
+ }[key.to_s.upcase]
18
+ end
19
+ end
20
+ end
21
+
22
+ # Creates a principals hash containing a single X.509 principal instance double
23
+ def x509_principal_hash(**args)
24
+ { "x509" => x509_principal(**args) }
25
+ end
26
+
27
+ Rails::Auth::ACL::Resource::HTTP_METHODS.each do |method|
28
+ define_method("#{method.downcase}_request") do |principals: {}|
29
+ path = self.class.description
30
+
31
+ # Warn if methods are improperly used
32
+ unless path.chars[0] == "/"
33
+ fail ArgumentError, "expected #{path} to start with '/'"
34
+ end
35
+
36
+ env = {
37
+ "REQUEST_METHOD" => method,
38
+ "REQUEST_PATH" => self.class.description
39
+ }
40
+
41
+ principals.each do |type, value|
42
+ Rails::Auth.add_principal(env, type.to_s, value)
43
+ end
44
+
45
+ env
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,13 @@
1
+ RSpec::Matchers.define(:permit) do |env|
2
+ description do
3
+ method = env["REQUEST_METHOD"]
4
+ principals = Rails::Auth.principals(env)
5
+ message = "allow #{method}s by "
6
+
7
+ return message << "unauthenticated clients" if principals.count.zero?
8
+
9
+ message << principals.values.map(&:inspect).join(", ")
10
+ end
11
+
12
+ match { |acl| acl.match(env) }
13
+ end
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rails
4
+ # Pluggable authentication and authorization for Rack/Rails
2
5
  module Auth
3
- VERSION = "0.0.0"
6
+ VERSION = "0.0.1".freeze
4
7
  end
5
8
  end
@@ -0,0 +1,25 @@
1
+ require "java"
2
+ require "stringio"
3
+
4
+ module Rails
5
+ module Auth
6
+ module X509
7
+ module Filter
8
+ # Support for extracting X509::Principals from Java's sun.security.x509.X509CertImpl
9
+ class Java
10
+ def call(cert)
11
+ OpenSSL::X509::Certificate.new(extract_der(cert)).freeze
12
+ end
13
+
14
+ private
15
+
16
+ def extract_der(cert)
17
+ stringio = StringIO.new
18
+ cert.derEncode(stringio.to_outputstream)
19
+ stringio.string
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,14 @@
1
+ module Rails
2
+ module Auth
3
+ module X509
4
+ module Filter
5
+ # Support for extracting X509::Principals from Privacy Enhanced Mail (PEM) certificates
6
+ class Pem
7
+ def call(pem)
8
+ OpenSSL::X509::Certificate.new(pem).freeze
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,22 @@
1
+ module Rails
2
+ module Auth
3
+ module X509
4
+ # Predicate matcher for making assertions about X.509 principals
5
+ class Matcher
6
+ # @option options [String] cn Common Name of the subject
7
+ # @option options [String] ou Organizational Unit of the subject
8
+ def initialize(options)
9
+ @options = options
10
+ end
11
+
12
+ # @param [Hash] env Rack environment
13
+ def match(env)
14
+ principal = Rails::Auth.principals(env)["x509"]
15
+ return false unless principal
16
+
17
+ @options.all? { |name, value| principal[name] == value }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end