Skip to content

Commit 340b46e

Browse files
committed
Enable users to add WebAuthn credentials to their accounts
So far, this commit won't use them yet, but rather enables the management of WebAuthn credentials.
1 parent 12c9540 commit 340b46e

24 files changed

+702
-261
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ gem 'telegraf'
5454
gem 'terser', require: false
5555
gem 'tubesock', github: 'openhpi/tubesock'
5656
gem 'turbolinks'
57+
gem 'webauthn'
5758
gem 'whenever', require: false
5859
gem 'zxcvbn-ruby', require: 'zxcvbn'
5960

Gemfile.lock

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ GEM
8787
addressable (2.8.7)
8888
public_suffix (>= 2.0.2, < 7.0)
8989
amq-protocol (2.3.2)
90+
android_key_attestation (0.3.0)
9091
ast (2.4.2)
9192
base64 (0.2.0)
9293
bcrypt (3.1.20)
@@ -96,6 +97,7 @@ GEM
9697
rack (>= 0.9.0)
9798
rouge (>= 1.0.0)
9899
bigdecimal (3.1.8)
100+
bindata (2.5.0)
99101
bindex (0.8.1)
100102
binding_of_caller (1.0.1)
101103
debug_inspector (>= 1.2.0)
@@ -126,13 +128,17 @@ GEM
126128
image_processing (~> 1.1)
127129
marcel (~> 1.0.0)
128130
ssrf_filter (~> 1.0)
131+
cbor (0.5.9.8)
129132
charlock_holmes (0.7.9)
130133
childprocess (5.1.0)
131134
logger (~> 1.5)
132135
chronic (0.10.2)
133136
coderay (1.1.3)
134137
concurrent-ruby (1.3.4)
135138
connection_pool (2.4.1)
139+
cose (1.3.1)
140+
cbor (~> 0.5.9)
141+
openssl-signature_algorithm (~> 1.0)
136142
crack (1.0.0)
137143
bigdecimal
138144
rexml
@@ -307,6 +313,9 @@ GEM
307313
rack (>= 1.2, < 4)
308314
snaky_hash (~> 2.0)
309315
version_gem (~> 1.1)
316+
openssl (3.2.0)
317+
openssl-signature_algorithm (1.3.0)
318+
openssl (> 2.0)
310319
package_json (0.1.0)
311320
parallel (1.26.3)
312321
parser (3.3.6.0)
@@ -470,6 +479,8 @@ GEM
470479
rubytree (2.1.0)
471480
json (~> 2.0, > 2.3.1)
472481
rubyzip (2.3.2)
482+
safety_net_attestation (0.4.0)
483+
jwt (~> 2.0)
473484
sassc (2.4.0)
474485
ffi (~> 1.9)
475486
sassc-rails (2.1.2)
@@ -561,6 +572,10 @@ GEM
561572
thor (1.3.2)
562573
tilt (2.4.0)
563574
timeout (0.4.2)
575+
tpm-key_attestation (0.12.1)
576+
bindata (~> 2.4)
577+
openssl (> 2.0)
578+
openssl-signature_algorithm (~> 1.0)
564579
turbo-rails (2.0.11)
565580
actionpack (>= 6.0.0)
566581
railties (>= 6.0.0)
@@ -578,6 +593,14 @@ GEM
578593
activemodel (>= 6.0.0)
579594
bindex (>= 0.4.0)
580595
railties (>= 6.0.0)
596+
webauthn (3.2.2)
597+
android_key_attestation (~> 0.3.0)
598+
bindata (~> 2.4)
599+
cbor (~> 0.5.9)
600+
cose (~> 1.1)
601+
openssl (>= 2.2)
602+
safety_net_attestation (~> 0.4.0)
603+
tpm-key_attestation (~> 0.12.0)
581604
webmock (3.24.0)
582605
addressable (>= 2.8.0)
583606
crack (>= 0.3.2)
@@ -681,6 +704,7 @@ DEPENDENCIES
681704
tubesock!
682705
turbolinks
683706
web-console
707+
webauthn
684708
webmock
685709
whenever
686710
zxcvbn-ruby
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# frozen_string_literal: true
2+
3+
class WebauthnCredentialsController < ApplicationController
4+
include CommonBehavior
5+
6+
before_action :set_user_and_authorize
7+
before_action :set_webauthn_credential, only: MEMBER_ACTIONS
8+
9+
def show; end
10+
11+
def new
12+
@webauthn_credential = WebauthnCredential.new(user: @user)
13+
authorize @webauthn_credential
14+
15+
@webauthn_create_options = personalized_options
16+
session[:current_challenge] = @webauthn_create_options.challenge
17+
end
18+
19+
def edit; end
20+
21+
def create
22+
@webauthn_credential = @user.webauthn_credentials.build(
23+
label: webauthn_credential_params[:label]
24+
)
25+
26+
authorize!
27+
28+
raise WebAuthn::Error.new(t('.missing_challenge')) unless session[:current_challenge]
29+
raise WebAuthn::Error.new(t('.invalid_param')) unless credential_param.is_a?(Hash) && credential_param.key?('rawId')
30+
31+
credential = WebAuthn::Credential.from_create(credential_param)
32+
credential.verify(session[:current_challenge], user_presence: true, user_verification: true)
33+
34+
@webauthn_credential.assign_attributes(
35+
external_id: credential.id,
36+
public_key: credential.public_key,
37+
sign_count: credential.sign_count,
38+
transports: credential.response.transports
39+
)
40+
41+
# In case something goes wrong, we want to show the user the same options again.
42+
@webauthn_create_options = personalized_options
43+
session[:current_challenge] = @webauthn_create_options.challenge
44+
45+
create_and_respond(object: @webauthn_credential, path: -> { @webauthn_credential.user }) do
46+
session.delete(:current_challenge)
47+
# Don't return a specific value from this block, so that the default is used.
48+
nil
49+
end
50+
rescue JSON::ParserError, WebAuthn::Error => e
51+
flash.now[:danger] = ERB::Util.html_escape e.message
52+
respond_to do |format|
53+
@webauthn_create_options = personalized_options
54+
session[:current_challenge] = @webauthn_create_options.challenge
55+
56+
respond_with_invalid_object(format, template: :new)
57+
end
58+
end
59+
60+
def update
61+
update_and_respond(object: @webauthn_credential, params: {label: webauthn_credential_params[:label]}, path: [@webauthn_credential.user, @webauthn_credential])
62+
end
63+
64+
def destroy
65+
destroy_and_respond(object: @webauthn_credential, path: @webauthn_credential.user)
66+
end
67+
68+
private
69+
70+
def personalized_options
71+
@user.with_lock do
72+
if @user.webauthn_user_id.blank?
73+
@user.validate_password = false if @user.respond_to?(:validate_password=)
74+
@user.update!(webauthn_user_id: WebAuthn.generate_user_id)
75+
end
76+
end
77+
78+
WebAuthn::Credential.options_for_create(
79+
user: {
80+
id: @user.webauthn_user_id,
81+
display_name: @user.displayname,
82+
name: @user.webauthn_name,
83+
},
84+
exclude_credentials:,
85+
authenticator_selection: {
86+
user_verification: :required,
87+
}
88+
)
89+
end
90+
91+
def exclude_credentials
92+
@user.webauthn_credentials.map do |cred|
93+
{id: cred.external_id, type: WebAuthn::TYPE_PUBLIC_KEY, transports: cred.transports}
94+
end
95+
end
96+
97+
def authorize!
98+
raise Pundit::NotAuthorizedError if @webauthn_credential.present? && @user.present? && @webauthn_credential.user != @user
99+
100+
authorize(@webauthn_credential)
101+
end
102+
103+
def set_user_and_authorize
104+
if params[:external_user_id]
105+
@user = ExternalUser.find(params[:external_user_id])
106+
else
107+
@user = InternalUser.find(params[:internal_user_id])
108+
end
109+
params[:user_id] = @user.id_with_type # for the breadcrumbs
110+
authorize(@user, :update?)
111+
end
112+
113+
def set_webauthn_credential
114+
@webauthn_credential = WebauthnCredential.find(params[:id])
115+
authorize!
116+
end
117+
118+
def webauthn_credential_params
119+
params.require(:webauthn_credential).permit(:credential, :label)
120+
end
121+
122+
def credential_param
123+
return @credential_param if defined? @credential_param
124+
125+
credential_param = webauthn_credential_params[:credential]
126+
@credential_param = JSON.parse(credential_param.to_s)
127+
rescue JSON::ParserError
128+
@credential_param = {}
129+
end
130+
end

app/javascript/webauthn.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {
2+
supported,
3+
create,
4+
parseCreationOptionsFromJSON,
5+
} from "@github/webauthn-json/browser-ponyfill";
6+
7+
let form;
8+
let credentialMethod;
9+
10+
function getPublicKey() {
11+
return { publicKey: form.data('publicKey') }
12+
}
13+
14+
async function createCredential(publicKey) {
15+
const options = parseCreationOptionsFromJSON(publicKey);
16+
return await create(options);
17+
}
18+
19+
$(document).on('turbolinks:load', function() {
20+
if ($.isController('webauthn_credentials')) {
21+
form = $('form#new_webauthn_credential');
22+
credentialMethod = createCredential;
23+
}
24+
25+
if (!supported()) {
26+
setTimeout(() => {
27+
// In order to use `showPermanent`, we need to wait for the flash helper to finish initializing
28+
$.flash.danger({
29+
text: I18n.t('webauthn_credentials.browser_not_supported'),
30+
showPermanent: true,
31+
icon: ['fa-solid', 'fa-exclamation-triangle']
32+
});
33+
}, 100);
34+
form.find('input[type="submit"]').prop('disabled', true);
35+
return;
36+
}
37+
38+
form.on('submit', function(event) {
39+
event.preventDefault();
40+
const publicKey = getPublicKey();
41+
credentialMethod(publicKey).then((credential) => {
42+
form.find('input[name="webauthn_credential[credential]"]').val(JSON.stringify(credential));
43+
this.submit();
44+
}
45+
).catch((error) => {
46+
form.find('input[type="submit"]').prop('disabled', false);
47+
$.flash.danger({text: error});
48+
})
49+
});
50+
});

app/models/external_user.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ class ExternalUser < User
77
def displayname
88
name.presence || "#{model_name.human} #{id}"
99
end
10+
11+
def webauthn_name
12+
"#{consumer.name}: #{displayname}"
13+
end
1014
end

app/models/internal_user.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,8 @@ def password_strength
3737
def displayname
3838
name
3939
end
40+
41+
def webauthn_name
42+
email
43+
end
4044
end

app/models/user.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class User < Contributor
2424
has_many :events_synchronized_editor, class_name: 'Event::SynchronizedEditor'
2525
has_many :pair_programming_exercise_feedbacks
2626
has_many :pair_programming_waiting_users
27+
has_many :webauthn_credentials, as: :user, dependent: :destroy
2728
has_one :codeharbor_link, dependent: :destroy
2829
accepts_nested_attributes_for :user_proxy_exercise_exercises
2930

app/models/webauthn_credential.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
class WebauthnCredential < ApplicationRecord
4+
belongs_to :user, polymorphic: true
5+
6+
validates :external_id, :public_key, :label, :sign_count, presence: true
7+
validates :external_id, uniqueness: true
8+
validates :label, uniqueness: {scope: %i[user_id user_type]}
9+
validates :sign_count, numericality: {only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: (2**32) - 1}
10+
11+
delegate :to_s, to: :label
12+
13+
def self.parent_resource
14+
User
15+
end
16+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
class WebauthnCredentialPolicy < ApplicationPolicy
4+
%i[create? new?].each do |action|
5+
define_method(action) { admin? || teacher? }
6+
end
7+
8+
%i[destroy? edit? show? update?].each do |action|
9+
define_method(action) { admin? || author? }
10+
end
11+
12+
def index?
13+
no_one
14+
end
15+
16+
private
17+
18+
def author?
19+
@record.user == @user
20+
end
21+
end

app/views/external_users/show.html.slim

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ h1 = @user.displayname
3333

3434
- if @user == current_user || current_user.admin?
3535
= row(label: 'codeharbor_link.profile_label', value: @user.codeharbor_link.nil? ? link_to(t('codeharbor_link.new'), polymorphic_path([@user, CodeharborLink], action: :new), class: 'btn btn-secondary') : link_to(t('codeharbor_link.edit'), polymorphic_path([@user, @user.codeharbor_link], action: :edit), class: 'btn btn-secondary'))
36+
= render 'webauthn_credentials/list'
3637

3738
h4.mt-4 = link_to(t('.exercise_statistics'), statistics_external_user_path(@user)) if policy(@user).statistics?
3839

0 commit comments

Comments
 (0)