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
data/Rakefile
CHANGED
data/lib/rails/auth.rb
CHANGED
@@ -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,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,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
|
data/lib/rails/auth/version.rb
CHANGED
@@ -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
|