|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +module Webauthn |
| 4 | + module Authentication |
| 5 | + def self.included(base) |
| 6 | + base.send(:include, InstanceMethods) |
| 7 | + base.before_action :require_fully_authenticated_user! |
| 8 | + |
| 9 | + Sorcery::Controller::Config.after_login << :require_webauthn_credential_verification |
| 10 | + Sorcery::Controller::Config.after_remember_me << :require_webauthn_credential_verification |
| 11 | + Sorcery::Controller::Config.after_logout << :destroy_webauthn_cookie |
| 12 | + end |
| 13 | + |
| 14 | + module InstanceMethods |
| 15 | + # Verify user presence and ensure the sign-in process finished. |
| 16 | + # If configured previously, the user must have completed the WebAuthn authentication. |
| 17 | + # This method is intended to be used as a before_action. |
| 18 | + def require_fully_authenticated_user! |
| 19 | + require_partially_authenticated_user! |
| 20 | + if current_user.webauthn_configured? |
| 21 | + _require_webauthn_credential_authentication |
| 22 | + refresh_webauthn_cookie |
| 23 | + else |
| 24 | + current_user.store_authentication_result(true) |
| 25 | + end |
| 26 | + end |
| 27 | + |
| 28 | + # Verify that a user is signed in (and that we have a current_user). |
| 29 | + # This method does not check for the completion of the WebAuthn authentication. |
| 30 | + # This method is intended to be used as a before_action. |
| 31 | + def require_partially_authenticated_user! |
| 32 | + raise Pundit::NotAuthorizedError unless current_user |
| 33 | + end |
| 34 | + |
| 35 | + # This method is called after the first login step when *not using a password*. |
| 36 | + # For example, it could be called when signing in using LTI or an authentication token. |
| 37 | + # Password-based logins are handled by Sorcery, and will call `require_webauthn_credential_verification`. |
| 38 | + def authenticate(user) |
| 39 | + _sign_in_as(user) |
| 40 | + return _finalize_login(user) if user.fully_authenticated? |
| 41 | + |
| 42 | + redirect_to new_webauthn_credential_authentication_path |
| 43 | + user |
| 44 | + end |
| 45 | + |
| 46 | + # This method is called after the WebAuthn authentication is completed. |
| 47 | + # It is intended to be used in the WebAuthnCredentialAuthentication controller. |
| 48 | + def authenticate_webauthn_for(webauthn_credential) |
| 49 | + _store_in_webauthn_cookie(webauthn_credential) |
| 50 | + _finalize_login(webauthn_credential.user) |
| 51 | + end |
| 52 | + |
| 53 | + # This method can be used to redirect users who are already fully authenticated. |
| 54 | + # It is intended to be used as a before_action in the WebAuthnCredentialAuthentication controller. |
| 55 | + def redirect_fully_authenticated_users |
| 56 | + if session[:return_to_url].blank? && session[:return_to_url_notice].blank? && request.referer.blank? |
| 57 | + session[:return_to_url_alert] = t('application.not_authorized') |
| 58 | + end |
| 59 | + _finalize_login(current_user) if _webauthn_credential_authentication_completed?(current_user) |
| 60 | + end |
| 61 | + |
| 62 | + private |
| 63 | + |
| 64 | + ###################################################### |
| 65 | + # Sorcery Hooks |
| 66 | + ###################################################### |
| 67 | + |
| 68 | + # If the user has configured WebAuthn, require the WebAuthn authentication after login. |
| 69 | + def require_webauthn_credential_verification(user, _credential = nil) |
| 70 | + return unless user.webauthn_configured? |
| 71 | + |
| 72 | + _require_webauthn_credential_authentication user |
| 73 | + end |
| 74 | + |
| 75 | + # Refresh the WebAuthn cookie on each request if the user has completed the WebAuthn authentication. |
| 76 | + def refresh_webauthn_cookie |
| 77 | + return unless current_user |
| 78 | + return unless _webauthn_credential_authentication_completed?(current_user) |
| 79 | + |
| 80 | + Webauthn::Cookie.new(request).refresh |
| 81 | + end |
| 82 | + |
| 83 | + # Remove the WebAuthn cookie after the user logs out. |
| 84 | + def destroy_webauthn_cookie(_user = nil) |
| 85 | + webauthn_cookie = Webauthn::Cookie.new(request) |
| 86 | + webauthn_cookie.clear |
| 87 | + end |
| 88 | + |
| 89 | + ###################################################### |
| 90 | + # Internal Methods, use with caution! |
| 91 | + ###################################################### |
| 92 | + |
| 93 | + # Redirect to the WebAuthn authentication page if the user has not completed the WebAuthn authentication. |
| 94 | + def _require_webauthn_credential_authentication(user = current_user) |
| 95 | + redirect_to new_webauthn_credential_authentication_path unless _webauthn_credential_authentication_completed?(user) |
| 96 | + end |
| 97 | + |
| 98 | + # Finish the login process and redirect the user to the return_to_url. |
| 99 | + # This method is called after the second login step, i.e., after verifying the WebAuthn credential. |
| 100 | + # If no WebAuthn credential is required, the method might be called directly after the first login step. |
| 101 | + def _finalize_login(user) |
| 102 | + flash = {notice: session.delete(:return_to_url_notice), alert: session.delete(:return_to_url_alert)}.compact_blank |
| 103 | + sorcery_redirect_back_or_to(:root, flash) unless session[:return_to_url] == request.fullpath |
| 104 | + user.store_authentication_result(true) |
| 105 | + end |
| 106 | + |
| 107 | + # Sign in the user (by setting the session) and store the authentication result. |
| 108 | + def _sign_in_as(user) |
| 109 | + if user.is_a? InternalUser |
| 110 | + # Sorcery Login only works for InternalUsers |
| 111 | + auto_login(user) |
| 112 | + else |
| 113 | + # All external users are logged in "manually" |
| 114 | + session[:external_user_id] = user.id |
| 115 | + end |
| 116 | + |
| 117 | + if user.webauthn_configured? |
| 118 | + # The user is fully authenticated if the WebAuthn authentication completed |
| 119 | + user.store_authentication_result(_webauthn_credential_authentication_completed?(user)) |
| 120 | + else |
| 121 | + # No additional authentication required |
| 122 | + user.store_authentication_result(true) |
| 123 | + end |
| 124 | + end |
| 125 | + |
| 126 | + # Store the WebAuthn credential and user in a dedicated cookie to indicate full authentication. |
| 127 | + # A dedicated cookie is beneficial for LTI-based logins; otherwise, a user would need to reauthenticate for each LTI launch. |
| 128 | + def _store_in_webauthn_cookie(webauthn_credential) |
| 129 | + webauthn_cookie = Webauthn::Cookie.new(request) |
| 130 | + webauthn_cookie.store(:webauthn_user, webauthn_credential.user.id_with_type) |
| 131 | + webauthn_cookie.store(:webauthn_credential, webauthn_credential.id) |
| 132 | + end |
| 133 | + |
| 134 | + # Check if the user has successfully completed the WebAuthn authentication. |
| 135 | + # Furthermore, memorize the result for the given user. |
| 136 | + def _webauthn_credential_authentication_completed?(user) |
| 137 | + return false unless user.webauthn_configured? |
| 138 | + return true if user.fully_authenticated? # Simple memorization for the current request |
| 139 | + |
| 140 | + webauthn_cookie = Webauthn::Cookie.new(request) |
| 141 | + return false unless webauthn_cookie.key?(:webauthn_user) |
| 142 | + return false unless webauthn_cookie.key?(:webauthn_credential) |
| 143 | + |
| 144 | + webauthn_credential = WebauthnCredential.find_by(id: webauthn_cookie.content[:webauthn_credential]) |
| 145 | + return false unless webauthn_credential |
| 146 | + return false unless user == webauthn_credential.user |
| 147 | + return false unless user.id_with_type == webauthn_cookie.content[:webauthn_user] |
| 148 | + |
| 149 | + user.store_authentication_result(true) |
| 150 | + true |
| 151 | + end |
| 152 | + end |
| 153 | + end |
| 154 | +end |
0 commit comments