keycard 0.3.2 → 0.3.3

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