Skip to content

Commit 1c7a5bc

Browse files
committed
Use WebAuthn credentials as second factor for login
1 parent 340b46e commit 1c7a5bc

18 files changed

+434
-27
lines changed

app/controllers/application_controller.rb

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,18 @@ class ApplicationController < ActionController::Base
77
include ApplicationHelper
88
include I18nHelper
99
include Pundit::Authorization
10+
include Webauthn::Authentication
1011

1112
MEMBER_ACTIONS = %i[destroy edit show update].freeze
1213
RENDER_HOST = CodeOcean::Config.new(:code_ocean).read[:render_host]
1314
LEGAL_SETTINGS = CodeOcean::Config.new(:code_ocean).read[:legal] || {}
1415
MONITORING_USER_AGENT = /updown\.io/
1516

16-
before_action :require_fully_authenticated_user!
1717
before_action :deny_access_from_render_host, prepend: true
1818
after_action :verify_authorized, except: %i[welcome]
1919
around_action :mnemosyne_trace, prepend: true
2020
around_action :switch_locale, prepend: true
21+
before_action :check_current_user, prepend: true
2122
before_action :set_sentry_context, :load_embed_options, :set_document_policy
2223
skip_before_action :require_fully_authenticated_user!, only: %i[welcome]
2324
protect_from_forgery(with: :exception, prepend: true)
@@ -46,12 +47,18 @@ def current_contributor
4647
def welcome
4748
# Show root page
4849
redirect_to ping_index_path if MONITORING_USER_AGENT.match?(request.user_agent)
50+
_require_webauthn_credential_authentication if current_user&.webauthn_configured?
4951
end
5052

5153
private
5254

53-
def require_fully_authenticated_user!
54-
raise Pundit::NotAuthorizedError unless current_user
55+
def check_current_user
56+
# Simply accessing the current_user will trigger the authentication process:
57+
# If the user is not authenticated but a remember_me cookie is present,
58+
# the user might be redirected to the WebAuthn Credential Authentication page.
59+
# Therefore, we use `prepend: true` to ensure that this method is called before
60+
# the `require_fully_authenticated_user!` method.
61+
current_user
5562
end
5663

5764
def deny_access_from_render_host
@@ -97,19 +104,6 @@ def login_from_authentication_token
97104
end
98105
end
99106

100-
def authenticate(user)
101-
if user.is_a? InternalUser
102-
# Sorcery Login only works for InternalUsers
103-
auto_login(user)
104-
else
105-
# All external users are logged in "manually"
106-
session[:external_user_id] = user.id
107-
end
108-
109-
flash = {notice: session.delete(:return_to_url_notice), alert: session.delete(:return_to_url_alert)}.compact_blank
110-
sorcery_redirect_back_or_to(:root, flash) unless session[:return_to_url] == request.fullpath
111-
end
112-
113107
def set_sentry_context
114108
return if current_user.blank?
115109

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
module Webauthn
4+
class Cookie
5+
NAME = 'CodeOcean-WebAuthn'
6+
PREFIXED_NAME = AuthenticatedUrlHelper.cookie_name_for(NAME)
7+
8+
attr_reader :request
9+
10+
delegate :key?, to: :content
11+
12+
def initialize(request)
13+
@request = request
14+
end
15+
16+
def content
17+
@content ||= JSON.parse(cookies.encrypted[PREFIXED_NAME]).deep_symbolize_keys
18+
rescue JSON::ParserError, TypeError
19+
{}
20+
end
21+
22+
def content=(value)
23+
@content = value
24+
if value.present?
25+
cookies.encrypted[PREFIXED_NAME] = {
26+
value: JSON.generate(value),
27+
expires: 1.month.from_now,
28+
secure: Rails.env.production? || Rails.env.staging?,
29+
httponly: true,
30+
path: Rails.application.config.relative_url_root,
31+
same_site: :lax, # Similar to the session cookie, we cannot use :strict here (due to LTI support).
32+
}
33+
else
34+
clear
35+
end
36+
end
37+
38+
def store(key, value)
39+
self.content = content.merge({key => value})
40+
end
41+
42+
def clear
43+
cookies.delete PREFIXED_NAME
44+
end
45+
46+
def refresh
47+
return if content.blank?
48+
49+
self.content = content
50+
end
51+
52+
private
53+
54+
def cookies
55+
request.cookie_jar
56+
end
57+
end
58+
end

app/controllers/sessions_controller.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ class SessionsController < ApplicationController
1010
end
1111

1212
skip_before_action :verify_authenticity_token, only: :create_through_lti
13-
skip_before_action :require_fully_authenticated_user!, only: %i[new create_through_lti create]
13+
skip_before_action :require_fully_authenticated_user!, only: %i[new create_through_lti create destroy]
14+
before_action :require_partially_authenticated_user!, only: %i[destroy]
1415
skip_after_action :verify_authorized
1516
after_action :set_sentry_context, only: %i[create_through_lti create]
1617

@@ -46,6 +47,8 @@ def create
4647
# We set the user's default study group to the "internal" group (no external id) for the given consumer.
4748
session[:study_group_id] = current_user.study_groups.find_by(external_id: nil)&.id
4849
session[:return_to_url_notice] = t('.success')
50+
# Since _finalize_login requires the session information, we cannot integrate it with Sorcery directly.
51+
_finalize_login(current_user) unless current_user.webauthn_configured?
4952
else
5053
flash.now[:danger] = t('.failure')
5154
render(:new)
@@ -68,16 +71,19 @@ def destroy
6871
session.delete(:embed_options)
6972
session.delete(:pg_id)
7073
session.delete(:pair_programming)
74+
destroy_webauthn_cookie
7175

72-
# In case we have another session as an internal user, we set the study group for this one
76+
# In case we have another session as an internal user, we set the study group for this one.
77+
# A second factor authentication is still required and *might cause a redirect*.
7378
internal_user = find_or_login_current_user
7479
if internal_user.present?
7580
session[:study_group_id] = internal_user.study_groups.find_by(external_id: nil)&.id
7681
end
7782
else
7883
logout
7984
end
80-
redirect_to(:root, notice: t('.success'))
85+
flash[:notice] = t('.success')
86+
redirect_to(:root) unless performed?
8187
end
8288

8389
private
@@ -119,6 +125,7 @@ def redirect_to_survey
119125
uri = Addressable::URI.parse 'https://survey.openhpi.de/survey/index.php'
120126
uri.query_values = query_params
121127

128+
# This redirect skips the WebAuthn requirement
122129
redirect_to uri.to_s, allow_other_host: true
123130
end
124131
end
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# frozen_string_literal: true
2+
3+
class WebauthnCredentialAuthenticationController < ApplicationController
4+
skip_before_action :require_fully_authenticated_user!
5+
before_action :require_partially_authenticated_user!
6+
before_action :deny_access_for_users_without_webauthn_credentials
7+
before_action :redirect_fully_authenticated_users
8+
before_action :authorize!
9+
10+
def new
11+
@webauthn_get_options = personalized_options
12+
session[:current_challenge] = @webauthn_get_options.challenge
13+
end
14+
15+
def create
16+
raise WebAuthn::Error.new(t('.missing_challenge')) unless session[:current_challenge]
17+
raise WebAuthn::Error.new(t('.invalid_param')) unless credential_param.is_a?(Hash) && credential_param.key?('rawId')
18+
19+
webauthn_credential = WebAuthn::Credential.from_get(credential_param)
20+
credential = current_user.webauthn_credentials.find_by(external_id: webauthn_credential.id)
21+
raise WebAuthn::Error.new(t('.credential_not_found')) unless credential
22+
23+
webauthn_credential.verify(
24+
session.delete(:current_challenge),
25+
public_key: credential.public_key,
26+
sign_count: credential.sign_count,
27+
user_presence: true,
28+
user_verification: true
29+
)
30+
31+
credential.assign_attributes(
32+
sign_count: webauthn_credential.sign_count,
33+
last_used_at: Time.zone.now
34+
)
35+
credential.save(touch: false)
36+
37+
authenticate_webauthn_for(credential)
38+
rescue WebAuthn::Error => e
39+
redirect_to new_webauthn_credential_authentication_path, danger: t('.failed', error: e.message)
40+
end
41+
42+
private
43+
44+
def personalized_options
45+
WebAuthn::Credential.options_for_get(
46+
allow_credentials:,
47+
user_verification: :required
48+
)
49+
end
50+
51+
def allow_credentials
52+
current_user.webauthn_credentials.map do |cred|
53+
{id: cred.external_id, type: WebAuthn::TYPE_PUBLIC_KEY, transports: cred.transports}
54+
end
55+
end
56+
57+
def authorize!
58+
authorize current_user, policy_class: WebauthnCredentialAuthenticationPolicy
59+
end
60+
61+
def deny_access_for_users_without_webauthn_credentials
62+
raise Pundit::NotAuthorizedError unless current_user.webauthn_configured?
63+
end
64+
65+
def credential_param
66+
return @credential_param if defined? @credential_param
67+
68+
credential_param = params.require(:webauthn_credential).permit(:credential)[:credential]
69+
@credential_param = JSON.parse(credential_param.to_s)
70+
rescue JSON::ParserError
71+
@credential_param = {}
72+
end
73+
end

0 commit comments

Comments
 (0)