keycard 0.3.2 → 0.3.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95ea37a28c7738e482b571188cfbd96e376363903e7a8888ef643484e6da854e
4
- data.tar.gz: 5279c715a7b837aafd75d8cd224f21318544862be2f42805bf19e1d951b04ec5
3
+ metadata.gz: 5a73c4a3d99e36a4126bde8248bc3050037dc333a9a264b3169dff4a715d9a67
4
+ data.tar.gz: 320cec317aa2b0fdf3d85bf02506db09267954484898e1b351d4bd69de2d7253
5
5
  SHA512:
6
- metadata.gz: '095c01d740d69c8cf58cb708ae65ed5fce8533e22c26be9cd96fb030966e766800aa3fdd81923b31ec6662d09296f292c2fd3f20d0aff4f793651616f4eeaf12'
7
- data.tar.gz: f91616faf6c27cbee76a455c998c171794fa7ce3fb00d59a44d58811af42edd9946c9aa07c764822e7d9ac14e40c7d557e020859b7111e3133e2078b0f6ce4b9
6
+ metadata.gz: fa1c61ee6513b56dd213229ee7df793076b095a54b52865f31b07d293a4bc235e72a563900f1fee5aff693de0136ad30b8be7b8345e47606d760ca5474d8ad5a
7
+ data.tar.gz: 86f727073283be6c122c3d4a0aaf7c5e738223306b08331779528087aecbe6bec1759b9fbaaa4819f033d1a3dc1fd0a4fd370d1107c4d5e068c9ec678fea9334
data/keycard.gemspec CHANGED
@@ -33,6 +33,6 @@ Gem::Specification.new do |spec|
33
33
  spec.add_development_dependency "rspec"
34
34
  spec.add_development_dependency "rubocop"
35
35
  spec.add_development_dependency "rubocop-rspec"
36
- spec.add_development_dependency "sqlite3"
36
+ spec.add_development_dependency "sqlite3", "~> 1.3.13"
37
37
  spec.add_development_dependency "yard"
38
38
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycard
4
+ module Authentication
5
+ # Identity verification based on an authorization token.
6
+ #
7
+ # The bound finder method is expected to take one parameter, the token as
8
+ # presented by the user. This will typically need to be digested for
9
+ # comparison with a stored version.
10
+ class AuthToken < Method
11
+ def apply
12
+ if token.nil?
13
+ skipped("No auth_token found in request attributes")
14
+ elsif (account = finder.call(token))
15
+ succeeded(account, "Account found for supplied Authorization Token", csrf_safe: true)
16
+ else
17
+ failed("Account not found for supplied Authorization Token")
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def token
24
+ attributes.auth_token
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycard
4
+ module Authentication
5
+ # An abstract identity authentication method. Subclasses will inspect the
6
+ # attributes and session for a request, attempting to match an account, and
7
+ # recording the results on a {Result}.
8
+ #
9
+ # The general operation is that each authentication method will have its
10
+ # {#apply} method called. It should examine the attributes, session, or
11
+ # credentials, and decide whether the required information is present. Then:
12
+ #
13
+ # 1. If the method is not applicable, call {#skipped} with a message naming
14
+ # the authentication method and why it was not applicable.
15
+ # 2. If the method is applicable, call the finder to attempt to locate the
16
+ # user/account and verify the method-specific information. For example,
17
+ # some methods will trust a username attribute that arrived by way of
18
+ # a reverse proxy, and the finder will only need to verify that a user
19
+ # exists with the given username. Other methods will need to verify that
20
+ # a token or password supplied hashes to the correct value.
21
+ # 3. Depending on whether a user/account is identified and authenticated,
22
+ # call {#succeeded} with the account and a message, or {#failed} with
23
+ # a message.
24
+ #
25
+ # Each of the status methods appends to a result for diagnostic or audit
26
+ # purposes and affects whether the chain of authentication should continue or
27
+ # be terminated. If a authentication method is skipped, the next one will be
28
+ # attempted. If it succeeds, or fails, the chain will be terminated. If it
29
+ # succeeds, the identity attributes will be assigned to the account, and it
30
+ # will be set as the account on the result.
31
+ #
32
+ # For integration with larger-scale configuration (like how request
33
+ # attributes should be extracted and which authentication methods should be
34
+ # used, in what order), see {Keycard::Notary}.
35
+ #
36
+ # For stateful integration with controllers (like the notions of a "current
37
+ # user" and logging in and out), see {Keycard::ControllerMethods}.
38
+ class Method
39
+ def initialize(attributes:, session:, result:, finder:, **credentials)
40
+ @attributes = attributes
41
+ @session = session
42
+ @result = result
43
+ @finder = finder
44
+ @credentials = credentials
45
+ end
46
+
47
+ # Bind a finder callable and yield a factory lambda to create a
48
+ # Verification with all of the other parameters. This allows for
49
+ # configuring a prototype at the system level and applying items that vary
50
+ # per request more conveniently.
51
+ def self.bind(finder)
52
+ lambda do |attributes, session, result, **credentials|
53
+ new(
54
+ attributes: attributes,
55
+ session: session,
56
+ result: result,
57
+ finder: finder,
58
+ credentials: credentials
59
+ )
60
+ end
61
+ end
62
+
63
+ # Bind a class method as a finder. This is more convenient form than
64
+ # {::bind} because it uses a {Keycard::ReloadableProxy}, making it easier
65
+ # to work with finder methods on ActiveRecord models, which are reloaded in
66
+ # development on each change, without restarting the server.
67
+ def self.bind_class_method(finder_class, method)
68
+ bind(ReloadableProxy.new(finder_class, method))
69
+ end
70
+
71
+ # Attempt to apply this authentication method and record the status on the
72
+ # result.
73
+ def apply
74
+ skipped("Base Verification is always skipped; it should not be used directly.")
75
+ end
76
+
77
+ private
78
+
79
+ def skipped(message)
80
+ result.skipped(message)
81
+ end
82
+
83
+ def succeeded(account, message, csrf_safe: false)
84
+ account.identity = attributes.identity
85
+ result.succeeded(account, message, csrf_safe: csrf_safe)
86
+ end
87
+
88
+ def failed(message)
89
+ result.failed(message)
90
+ end
91
+
92
+ attr_reader :attributes
93
+ attr_reader :session
94
+ attr_reader :result
95
+ attr_reader :finder
96
+ attr_reader :credentials
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycard
4
+ module Authentication
5
+ # A Result is the central point of information about an authentication
6
+ # attempt. It logs the authentication methods attempted with their statuses
7
+ # and reports the overall status. When authentication is successful, it holds
8
+ # the user/account that was verified.
9
+ class Result
10
+ attr_reader :account
11
+ attr_reader :log
12
+
13
+ def initialize
14
+ @account = nil
15
+ @log = []
16
+ @failed = false
17
+ @csrf_safe = false
18
+ end
19
+
20
+ # Has this authentication completed successfully?
21
+ def authenticated?
22
+ !account.nil?
23
+ end
24
+
25
+ # Was there a failure for an attempted authentication method?
26
+ def failed?
27
+ @failed
28
+ end
29
+
30
+ # Does a completed verification protect from Cross-Site Request Forgery?
31
+ #
32
+ # This should be true in cases where the client presents authentication
33
+ # that is not automatic, like an authentication token, rather than
34
+ # automatic credentials like cookies or proxy-applied headers.
35
+ def csrf_safe?
36
+ @csrf_safe
37
+ end
38
+
39
+ # Log that the authentication method was not applicable; continue the chain.
40
+ #
41
+ # @param message [String] a message about why the authentication method was skipped
42
+ # @return [Boolean] false, indicating that the authentication method was inconclusive
43
+ def skipped(message)
44
+ log << "[SKIPPED] #{message}"
45
+ false
46
+ end
47
+
48
+ # Log that the authentication method failed; terminate the chain.
49
+ #
50
+ # @param message [String] a message about how the authentication method failed
51
+ # @return [Boolean] true, indicating that further authentication should not occur
52
+ def failed(message)
53
+ log << "[FAILURE] #{message}"
54
+ @failed = true
55
+ end
56
+
57
+ # Log that the authentication method succeeded; terminate the chain.
58
+ #
59
+ # @param account [User|Account] Object/model representing the authenticated account
60
+ # @param message [String] a message about how the authentication method succeeded
61
+ # @param csrf_safe [Boolean] set to true if this authentication method precludes
62
+ # Cross-Site Request Forgery, as with a non-cookie token sent with the request
63
+ # @return [Boolean] true, indicating that further authentication should not occur
64
+ def succeeded(account, message, csrf_safe: false)
65
+ @account = account
66
+ @csrf_safe ||= csrf_safe
67
+ log << "[SUCCESS] #{message}"
68
+ true
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycard
4
+ module Authentication
5
+ # Identity verification based on a user_id present in the session.
6
+ #
7
+ # A user_id in the session would typically be placed there after some other
8
+ # login process, after which it is sufficient to authenticate the session.
9
+ # The finder, then, takes only one parameter, the ID as on the account's #id
10
+ # property.
11
+ class SessionUserId < Method
12
+ def apply
13
+ if user_id.nil?
14
+ skipped("No user_id found in session")
15
+ elsif (account = finder.call(user_id))
16
+ succeeded(account, "Account found for user_id '#{user_id}' in session")
17
+ else
18
+ failed("Account not found for user_id '#{user_id}' in session")
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def user_id
25
+ session[:user_id]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycard
4
+ module Authentication
5
+ # Identity verification based on the user EID request attribute.
6
+ #
7
+ # The EID will typically be present in single sign-on scenarios, where
8
+ # there is a proxy in place to set secure headers. The finder is expected
9
+ # to take one paramter, the user_eid itself.
10
+ class UserEid < Method
11
+ def apply
12
+ if user_eid.nil?
13
+ skipped("No user_eid found in request attributes")
14
+ elsif (account = finder.call(user_eid))
15
+ succeeded(account, "Account found for user_eid '#{user_eid}'")
16
+ else
17
+ failed("Account not found for user_eid '#{user_eid}'")
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def user_eid
24
+ attributes.user_eid
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycard
4
+ # Mixin for conveniences in controllers.
5
+ #
6
+ # These methods depend on a `notary` method in your controller that returns a
7
+ # configured {Keycard::Notary} instance.
8
+ module ControllerMethods
9
+ # The default session timeout is 24 hours, in seconds.
10
+ DEFAULT_SESSION_TIMEOUT = 60 * 60 * 24
11
+
12
+ # Check whether the current request is authenticated as coming from a known
13
+ # person or account.
14
+ #
15
+ # @return [Boolean] true if any of the {Notary}'s configured authentication
16
+ # methods succeeds
17
+ def logged_in?
18
+ authentication.authenticated?
19
+ end
20
+
21
+ # Retrieve the user/account to which the current request is attributed.
22
+ #
23
+ # @return [User/Account] the user/account that has been authenticated; nil
24
+ # if no one is logged in
25
+ def current_user
26
+ authentication.account
27
+ end
28
+
29
+ # Validate the session, resetting it if expired.
30
+ #
31
+ # This should be called as a before_action before {#authenticate!} when
32
+ # working with session-based logins. It preserves a CSRF token, if present,
33
+ # so login forms and the like will pass forgery protection.
34
+ def validate_session
35
+ csrf_token = session[:_csrf_token]
36
+ elapsed = begin
37
+ Time.now - (session[:timestamp] || Time.at(0))
38
+ rescue StandardError
39
+ session_timeout
40
+ end
41
+ reset_session if elapsed >= session_timeout
42
+ session[:_csrf_token] = csrf_token
43
+ session[:timestamp] = Time.now if session.key?(:timestamp)
44
+ end
45
+
46
+ # Require that some authentication method successfully identifies a user/account,
47
+ # raising an exception if there is a failure for active credentials or no
48
+ # applicable credentials are presented.
49
+ #
50
+ # @raise [AuthenticationFailed] if credentials for an attempted
51
+ # authentication method are incorrect
52
+ # @raise [AuthenticationRequired] if all authentication methods are skipped
53
+ # and authentication could not be attempted
54
+ # @return nil
55
+ def authenticate!
56
+ raise AuthenticationFailed if authentication.failed?
57
+ raise AuthenticationRequired unless authentication.authenticated?
58
+ end
59
+
60
+ # Attempt to authenticate, optionally with user-supplied credentials, and
61
+ # establish a session.
62
+ #
63
+ # @param credentials [Hash|kwargs] user-supplied credentials that will be
64
+ # passed to each authentication method
65
+ # @return [Boolean] whether the login attempt was successful
66
+ def login(**credentials)
67
+ authentication(credentials).authenticated?.tap do |success|
68
+ setup_session if success
69
+ end
70
+ end
71
+
72
+ # Log an account in without checking any credentials, starting a session.
73
+ #
74
+ # @param account [User|Account] the user/account object to consider current;
75
+ # must have an #id property.
76
+ def auto_login(account)
77
+ request.env["keycard.authentication"] = notary.waive(account)
78
+ setup_session
79
+ end
80
+
81
+ # Clear authentication status and terminate any open session.
82
+ def logout
83
+ request.env["keycard.authentication"] = notary.reject
84
+ reset_session
85
+ end
86
+
87
+ private
88
+
89
+ def authentication(**credentials)
90
+ request.env["keycard.authentication"] ||=
91
+ notary.authenticate(request, session, credentials)
92
+ end
93
+
94
+ # The session timeout, in seconds. Sessions will be cleared before any
95
+ # further authentication unless there is a timestamp younger than this many
96
+ # seconds old. The default is 24 hours.
97
+ #
98
+ # @return [Integer] session timeout, in seconds
99
+ def session_timeout
100
+ DEFAULT_SESSION_TIMEOUT
101
+ end
102
+
103
+ def setup_session
104
+ return_url = session[:return_to]
105
+ reset_session
106
+ session[:return_to] = return_url
107
+ session[:user_id] = current_user.id
108
+ session[:timestamp] = Time.now
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycard
4
+ # A Notary is the primary entry point for authentication needs. It will
5
+ # examine the request, session, and user-supplied credentials and provide
6
+ # a {Result} with the results of identity verification.
7
+ #
8
+ # It relies on configuration to extract the correct attributes from each
9
+ # request and to use the appropriate identity {AuthenticationMethod}. Each
10
+ # authentication method will attempt to locate a matching account and, for
11
+ # those methods that involve user-supplied credentials, verify that are
12
+ # correct for that account.
13
+ class Notary
14
+ # Create a Notary, which authenticates requests, verifying the identity of
15
+ # the requester and issuing an {Authentication::Result}.
16
+ #
17
+ # @param attributes_factory [Request::AttributesFactory] the factory to create
18
+ # {Request::Attributes} from the current request.
19
+ # @param methods [Array<callable>] the list of {AuthenticationMethod}s to
20
+ # use, in order, each wrapped as a callable initializer. Each factory
21
+ # should take (attributes, session, result, **credentials) and
22
+ # instantiate an AuthenticationMethod with a bound account/user finder.
23
+ def initialize(attributes_factory:, methods:)
24
+ @attributes_factory = attributes_factory
25
+ @methods = methods
26
+ end
27
+
28
+ # Create a default Notary instance, using common authenticaiton methods and
29
+ # the default AttributesFactory that creates request attributes based on
30
+ # the Keycard.config.access value.
31
+ #
32
+ # This instance assumes that there is a `User` model with class methods
33
+ # called `authenticate_by_auth_token`, `authenticate_by_id`, and
34
+ # `authenticate_by_user_eid`. These should find the user with the given
35
+ # id, authorization token, and EID/username. This is the order of
36
+ # precedence, as well, corresponding to the following {AuthenticationMethod}s:
37
+ #
38
+ # 1. {Keycard::Authentication::AuthToken}
39
+ # 2. {Keycard::Authentication::SessionUserId}
40
+ # 3. {Keycard::Authentication::UserEid}
41
+ #
42
+ # @return [Keycard::Notary] a default Notary instance, bound to conventional
43
+ # authentication methods on a User class.
44
+ def self.default
45
+ new(
46
+ attributes_factory: Keycard::Request::AttributesFactory.new,
47
+ methods: [
48
+ Keycard::Authentication::AuthToken.bind_class_method(:User, :authenticate_by_auth_token),
49
+ Keycard::Authentication::SessionUserId.bind_class_method(:User, :authenticate_by_id),
50
+ Keycard::Authentication::UserEid.bind_class_method(:User, :authenticate_by_user_eid)
51
+ ]
52
+ )
53
+ end
54
+
55
+ # Authenticate a request, giving a Result of the result.
56
+ #
57
+ # @param request [Rack::Request] the active request, used to extract attributes
58
+ # @param session [Session] the active session, to be inspected with #[]
59
+ # @return [Authentication::Result] the result of this authentication
60
+ def authenticate(request, session, **credentials)
61
+ attributes = attributes_factory.for(request)
62
+ Authentication::Result.new.tap do |result|
63
+ methods.find do |factory|
64
+ factory.call(attributes, session, result, credentials).apply
65
+ end
66
+ end
67
+ end
68
+
69
+ # Bypass normal authentication and create a Result for the given
70
+ # user/account. This would typically only be used in development or other
71
+ # administrative scenarios where it is appropriate to allow impersonation.
72
+ def waive(account)
73
+ Authentication::Result.new.tap do |result|
74
+ result.succeeded(account, "Administrative waiver for #{account}")
75
+ end
76
+ end
77
+
78
+ # Issue an unconditional rejection Result. This is useful for a logout
79
+ # workflow, where authenticating again yield a passing result. The
80
+ # notion here is that the rejection would be cached just like any other
81
+ # result, rather than simply clearing it for the request.
82
+ #
83
+ # A logout would typically be followed by an immediate redirect, but this
84
+ # is a provision to ensure that the current request stays unauthenticated.
85
+ #
86
+ # @see {Keycard::ControllerMethods#logout} for how this is used in the
87
+ # context of the state of the current request.
88
+ def reject
89
+ Authentication::Result.new.tap do |result|
90
+ result.failed("Authentication rejected; session terminated")
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ attr_reader :attributes_factory
97
+ attr_reader :methods
98
+ end
99
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycard
4
+ # A proxy for class methods, as for authentication methods on User/Account
5
+ # models. This is useful primarily during development mode, where binding
6
+ # a finder into a Verfication factory can break because of code reloading.
7
+ #
8
+ # For example, something like this would fail across requests where the User
9
+ # model is saved (in development):
10
+ # `AuthToken.bind(User.public_method(:authenticate_by_auth_token))`.
11
+ #
12
+ # Instead, you can bind to a class method with a proxy that will catch the
13
+ # error from Rails thrown when using a stale reference, replace the target
14
+ # method, and retry the call transparently.
15
+ #
16
+ # @example
17
+ # callable = ReloadableProxy.new(:User, :authenticate_by_auth_token)
18
+ class ReloadableProxy
19
+ attr_reader :classname, :methodname, :target
20
+
21
+ def initialize(classname, methodname)
22
+ @classname = classname
23
+ @methodname = methodname
24
+ lookup
25
+ end
26
+
27
+ # Call the proxied class method, looking it up again if the class has been
28
+ # reloaded (as signified byt he ArgumentError Rails raises).
29
+ def call(*args)
30
+ target.call(*args)
31
+ rescue ArgumentError
32
+ lookup
33
+ target.call(*args)
34
+ end
35
+
36
+ def lookup
37
+ @target = Object.const_get(classname).method(methodname)
38
+ end
39
+ end
40
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Keycard
4
- VERSION = "0.3.2"
4
+ VERSION = "0.3.3"
5
5
  end
data/lib/keycard.rb CHANGED
@@ -6,6 +6,9 @@ require "ostruct"
6
6
 
7
7
  # All of the Keycard components are contained within this top-level module.
8
8
  module Keycard
9
+ class AuthenticationRequired < StandardError; end
10
+ class AuthenticationFailed < StandardError; end
11
+
9
12
  def self.config
10
13
  @config ||= OpenStruct.new(
11
14
  access: :direct
@@ -19,3 +22,15 @@ require "keycard/railtie" if defined?(Rails)
19
22
  require "keycard/institution_finder"
20
23
  require "keycard/request"
21
24
  require "keycard/token"
25
+
26
+ require "keycard/notary"
27
+
28
+ require "keycard/authentication/method"
29
+ require "keycard/authentication/result"
30
+
31
+ require "keycard/authentication/auth_token"
32
+ require "keycard/authentication/session_user_id"
33
+ require "keycard/authentication/user_eid"
34
+
35
+ require "keycard/reloadable_proxy"
36
+ require "keycard/controller_methods"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keycard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Noah Botimer
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2019-07-01 00:00:00.000000000 Z
12
+ date: 2019-07-12 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: sequel
@@ -127,16 +127,16 @@ dependencies:
127
127
  name: sqlite3
128
128
  requirement: !ruby/object:Gem::Requirement
129
129
  requirements:
130
- - - ">="
130
+ - - "~>"
131
131
  - !ruby/object:Gem::Version
132
- version: '0'
132
+ version: 1.3.13
133
133
  type: :development
134
134
  prerelease: false
135
135
  version_requirements: !ruby/object:Gem::Requirement
136
136
  requirements:
137
- - - ">="
137
+ - - "~>"
138
138
  - !ruby/object:Gem::Version
139
- version: '0'
139
+ version: 1.3.13
140
140
  - !ruby/object:Gem::Dependency
141
141
  name: yard
142
142
  requirement: !ruby/object:Gem::Requirement
@@ -182,10 +182,18 @@ files:
182
182
  - docs/runtime_context.rst
183
183
  - keycard.gemspec
184
184
  - lib/keycard.rb
185
+ - lib/keycard/authentication/auth_token.rb
186
+ - lib/keycard/authentication/method.rb
187
+ - lib/keycard/authentication/result.rb
188
+ - lib/keycard/authentication/session_user_id.rb
189
+ - lib/keycard/authentication/user_eid.rb
190
+ - lib/keycard/controller_methods.rb
185
191
  - lib/keycard/db.rb
186
192
  - lib/keycard/digest_key.rb
187
193
  - lib/keycard/institution_finder.rb
194
+ - lib/keycard/notary.rb
188
195
  - lib/keycard/railtie.rb
196
+ - lib/keycard/reloadable_proxy.rb
189
197
  - lib/keycard/request.rb
190
198
  - lib/keycard/request/attributes.rb
191
199
  - lib/keycard/request/attributes_factory.rb