Implement Passkey authentication
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m12.65 10c-.83-2.33-3.05-4-5.65-4-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4h4.35v4h4v-4h2v-4zm-5.65 4c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>
|
||||||
|
After Width: | Height: | Size: 234 B |
@@ -62,6 +62,7 @@
|
|||||||
.icon--history { --svg: url("history.svg "); }
|
.icon--history { --svg: url("history.svg "); }
|
||||||
.icon--home { --svg: url("home.svg "); }
|
.icon--home { --svg: url("home.svg "); }
|
||||||
.icon--install-edge { --svg: url("install-edge.svg "); }
|
.icon--install-edge { --svg: url("install-edge.svg "); }
|
||||||
|
.icon--key { --svg: url("key.svg "); }
|
||||||
.icon--lifebuoy { --svg: url("lifebuoy.svg "); }
|
.icon--lifebuoy { --svg: url("lifebuoy.svg "); }
|
||||||
.icon--lock { --svg: url("lock.svg "); }
|
.icon--lock { --svg: url("lock.svg "); }
|
||||||
.icon--logout { --svg: url("logout.svg "); }
|
.icon--logout { --svg: url("logout.svg "); }
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ module CurrentRequest
|
|||||||
Current.user_agent = request.user_agent
|
Current.user_agent = request.user_agent
|
||||||
Current.ip_address = request.ip
|
Current.ip_address = request.ip
|
||||||
Current.referrer = request.referrer
|
Current.referrer = request.referrer
|
||||||
|
|
||||||
|
ActionPack::WebAuthn::Current.host = request.host
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
class Sessions::PasskeysController < ApplicationController
|
||||||
|
disallow_account_scope
|
||||||
|
require_unauthenticated_access
|
||||||
|
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
|
||||||
|
|
||||||
|
def create
|
||||||
|
credential = Identity::Credential.authenticate(
|
||||||
|
id: params.expect(:credential_id),
|
||||||
|
client_data_json: response_params[:client_data_json],
|
||||||
|
authenticator_data: response_params[:authenticator_data],
|
||||||
|
signature: response_params[:signature],
|
||||||
|
challenge: session.delete(:webauthn_challenge),
|
||||||
|
origin: request.base_url
|
||||||
|
)
|
||||||
|
|
||||||
|
if credential
|
||||||
|
authentication_succeeded(credential.identity)
|
||||||
|
else
|
||||||
|
authentication_failed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def response_params
|
||||||
|
params.expect(response: [ :client_data_json, :authenticator_data, :signature ])
|
||||||
|
end
|
||||||
|
|
||||||
|
def authentication_succeeded(identity)
|
||||||
|
start_new_session_for identity
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_to after_authentication_url }
|
||||||
|
format.json { render json: { session_token: session_token } }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def authentication_failed
|
||||||
|
alert_message = "That passkey didn't work. Try again."
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_to new_session_path, alert: alert_message }
|
||||||
|
format.json { render json: { message: alert_message }, status: :unauthorized }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def rate_limit_exceeded
|
||||||
|
rate_limit_exceeded_message = "Try again later."
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message }
|
||||||
|
format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,6 +6,8 @@ class SessionsController < ApplicationController
|
|||||||
layout "public"
|
layout "public"
|
||||||
|
|
||||||
def new
|
def new
|
||||||
|
@request_options = Identity::Credential.request_options
|
||||||
|
session[:webauthn_challenge] = @request_options.challenge
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -67,4 +69,5 @@ class SessionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,71 +1,40 @@
|
|||||||
class Users::CredentialsController < ApplicationController
|
class Users::CredentialsController < ApplicationController
|
||||||
before_action :set_user
|
before_action :set_credential, only: :destroy
|
||||||
before_action :set_webauthn_host
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@credentials = identity.credentials.order(created_at: :desc)
|
@credentials = Current.identity.credentials.order(name: :asc, created_at: :desc)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@creation_options = creation_options
|
@creation_options = Identity::Credential.creation_options(identity: Current.identity, display_name: Current.user.name)
|
||||||
session[:webauthn_challenge] = @creation_options.challenge
|
session[:webauthn_challenge] = @creation_options.challenge
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create(
|
Identity::Credential.register(
|
||||||
client_data_json: decode64(credential_response[:client_data_json]),
|
identity: Current.identity,
|
||||||
attestation_object: decode64(credential_response[:attestation_object]),
|
name: credential_params[:name],
|
||||||
|
client_data_json: credential_params[:client_data_json],
|
||||||
|
attestation_object: credential_params[:attestation_object],
|
||||||
challenge: session.delete(:webauthn_challenge),
|
challenge: session.delete(:webauthn_challenge),
|
||||||
origin: request.base_url,
|
origin: request.base_url,
|
||||||
transports: Array(credential_response[:transports])
|
transports: Array(credential_params[:transports])
|
||||||
)
|
)
|
||||||
|
|
||||||
identity.credentials.create!(
|
redirect_to user_credentials_path(Current.user)
|
||||||
name: params.dig(:credential, :name),
|
|
||||||
credential_id: public_key_credential.id,
|
|
||||||
public_key: public_key_credential.public_key.to_der,
|
|
||||||
sign_count: public_key_credential.sign_count,
|
|
||||||
transports: public_key_credential.transports
|
|
||||||
)
|
|
||||||
|
|
||||||
redirect_to user_credentials_path(@user)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
identity.credentials.find(params[:id]).destroy!
|
@credential.destroy!
|
||||||
redirect_to user_credentials_path(@user)
|
redirect_to user_credentials_path(Current.user)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def set_user
|
def set_credential
|
||||||
@user = Current.identity.users.find(params[:user_id])
|
@credential = Current.identity.credentials.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_webauthn_host
|
def credential_params
|
||||||
ActionPack::WebAuthn::Current.host = request.host
|
params.expect(credential: [ :name, :client_data_json, :attestation_object, transports: [] ])
|
||||||
end
|
end
|
||||||
|
|
||||||
def identity
|
|
||||||
@user.identity
|
|
||||||
end
|
|
||||||
|
|
||||||
def creation_options
|
|
||||||
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
|
||||||
id: identity.id,
|
|
||||||
name: identity.email_address,
|
|
||||||
display_name: @user.name,
|
|
||||||
resident_key: :required,
|
|
||||||
exclude_credentials: identity.credentials.map { |c| ExcludeCredential.new(c.credential_id, c.transports) }
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def credential_response
|
|
||||||
params.expect(credential: { response: [ :client_data_json, :attestation_object, transports: [] ] })[:response]
|
|
||||||
end
|
|
||||||
|
|
||||||
def decode64(value)
|
|
||||||
Base64.urlsafe_decode64(value)
|
|
||||||
end
|
|
||||||
|
|
||||||
ExcludeCredential = Struct.new(:id, :transports)
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default class extends Controller {
|
|||||||
for (const transport of credential.response.getTransports?.() || []) {
|
for (const transport of credential.response.getTransports?.() || []) {
|
||||||
const input = document.createElement("input")
|
const input = document.createElement("input")
|
||||||
input.type = "hidden"
|
input.type = "hidden"
|
||||||
input.name = "credential[response][transports][]"
|
input.name = "credential[transports][]"
|
||||||
input.value = transport
|
input.value = transport
|
||||||
this.element.appendChild(input)
|
this.element.appendChild(input)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,92 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
|
static values = { publicKey: Object, url: String, csrfToken: String }
|
||||||
|
|
||||||
|
#abortController
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
if (window.PublicKeyCredential?.isConditionalMediationAvailable) {
|
this.#attemptConditionalMediation()
|
||||||
this.attemptConditionalMediation()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
this.abortController?.abort()
|
this.#abortController?.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
async attemptConditionalMediation() {
|
async #attemptConditionalMediation() {
|
||||||
if (!await PublicKeyCredential.isConditionalMediationAvailable()) return
|
if (!await PublicKeyCredential?.isConditionalMediationAvailable?.()) return
|
||||||
|
|
||||||
this.abortController = new AbortController()
|
this.#abortController = new AbortController()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const credential = await navigator.credentials.get({
|
const credential = await navigator.credentials.get({
|
||||||
publicKey: {
|
publicKey: this.#prepareOptions(this.publicKeyValue),
|
||||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
||||||
rpId: window.location.hostname
|
|
||||||
},
|
|
||||||
mediation: "conditional",
|
mediation: "conditional",
|
||||||
signal: this.abortController.signal
|
signal: this.#abortController.signal
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("Passkey selected:", credential)
|
this.#submitAssertion(credential)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name !== "AbortError") {
|
if (error.name !== "AbortError") {
|
||||||
console.error("Passkey error:", error)
|
console.error("Passkey error:", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#submitAssertion(credential) {
|
||||||
|
const form = document.createElement("form")
|
||||||
|
form.method = "POST"
|
||||||
|
form.action = this.urlValue
|
||||||
|
form.style.display = "none"
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
authenticity_token: this.csrfTokenValue,
|
||||||
|
credential_id: credential.id,
|
||||||
|
"response[client_data_json]": new TextDecoder().decode(credential.response.clientDataJSON),
|
||||||
|
"response[authenticator_data]": this.#bufferToBase64url(credential.response.authenticatorData),
|
||||||
|
"response[signature]": this.#bufferToBase64url(credential.response.signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(fields)) {
|
||||||
|
const input = document.createElement("input")
|
||||||
|
input.type = "hidden"
|
||||||
|
input.name = name
|
||||||
|
input.value = value
|
||||||
|
form.appendChild(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(form)
|
||||||
|
form.submit()
|
||||||
|
}
|
||||||
|
|
||||||
|
#prepareOptions(options) {
|
||||||
|
const prepared = {
|
||||||
|
...options,
|
||||||
|
challenge: this.#base64urlToBuffer(options.challenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.allowCredentials?.length) {
|
||||||
|
prepared.allowCredentials = options.allowCredentials.map(cred => ({
|
||||||
|
...cred,
|
||||||
|
id: this.#base64urlToBuffer(cred.id)
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
delete prepared.allowCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
return prepared
|
||||||
|
}
|
||||||
|
|
||||||
|
#base64urlToBuffer(base64url) {
|
||||||
|
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
|
||||||
|
const padding = "=".repeat((4 - base64.length % 4) % 4)
|
||||||
|
const binary = atob(base64 + padding)
|
||||||
|
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
#bufferToBase64url(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
const binary = String.fromCharCode(...bytes)
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,64 @@ class Identity::Credential < ApplicationRecord
|
|||||||
|
|
||||||
serialize :transports, coder: JSON, type: Array, default: []
|
serialize :transports, coder: JSON, type: Array, default: []
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def creation_options(identity:, display_name:)
|
||||||
|
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||||
|
id: identity.id,
|
||||||
|
name: identity.email_address,
|
||||||
|
display_name: display_name,
|
||||||
|
resident_key: :required,
|
||||||
|
exclude_credentials: identity.credentials.map(&:to_public_key_credential)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def request_options(credentials: [])
|
||||||
|
ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(credentials: credentials.map(&:to_public_key_credential))
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate(id:, **params)
|
||||||
|
find_by(credential_id: id)&.authenticate(**params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def register(identity:, name:, client_data_json:, attestation_object:, challenge:, origin:, transports: [])
|
||||||
|
public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create(
|
||||||
|
client_data_json: Base64.urlsafe_decode64(client_data_json),
|
||||||
|
attestation_object: Base64.urlsafe_decode64(attestation_object),
|
||||||
|
challenge: challenge,
|
||||||
|
origin: origin,
|
||||||
|
transports: transports
|
||||||
|
)
|
||||||
|
|
||||||
|
identity.credentials.create!(
|
||||||
|
name: name,
|
||||||
|
credential_id: public_key_credential.id,
|
||||||
|
public_key: public_key_credential.public_key.to_der,
|
||||||
|
sign_count: public_key_credential.sign_count,
|
||||||
|
transports: public_key_credential.transports
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def authenticate(client_data_json:, authenticator_data:, signature:, challenge:, origin:)
|
||||||
|
pkc = to_public_key_credential
|
||||||
|
pkc.authenticate(
|
||||||
|
client_data_json: client_data_json,
|
||||||
|
authenticator_data: Base64.urlsafe_decode64(authenticator_data),
|
||||||
|
signature: Base64.urlsafe_decode64(signature),
|
||||||
|
challenge: challenge,
|
||||||
|
origin: origin
|
||||||
|
)
|
||||||
|
update!(sign_count: pkc.sign_count)
|
||||||
|
self
|
||||||
|
rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
def to_public_key_credential
|
def to_public_key_credential
|
||||||
ActionPack::WebAuthn::PublicKeyCredential.new(
|
ActionPack::WebAuthn::PublicKeyCredential.new(
|
||||||
id: credential_id,
|
id: credential_id,
|
||||||
public_key: public_key,
|
public_key: OpenSSL::PKey.read(public_key),
|
||||||
|
sign_count: sign_count,
|
||||||
transports: transports
|
transports: transports
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
<% @page_title = "Enter your email" %>
|
<% @page_title = "Enter your email" %>
|
||||||
|
|
||||||
<div class="panel panel--centered flex flex-column gap-half" data-controller="passkey">
|
<div class="panel panel--centered flex flex-column gap-half" data-controller="passkey"
|
||||||
|
data-passkey-public-key-value="<%= @request_options.as_json.to_json %>"
|
||||||
|
data-passkey-url-value="<%= session_passkey_path %>"
|
||||||
|
data-passkey-csrf-token-value="<%= form_authenticity_token %>">
|
||||||
<h1 class="txt-x-large font-weight-black margin-block-end">Get into Fizzy</h1>
|
<h1 class="txt-x-large font-weight-black margin-block-end">Get into Fizzy</h1>
|
||||||
|
|
||||||
<%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %>
|
<%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %>
|
||||||
<div class="flex align-center gap">
|
<div class="flex align-center gap">
|
||||||
<label class="flex align-center gap input input--actor">
|
<label class="flex align-center gap input input--actor">
|
||||||
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "username webauthn", placeholder: "Enter your email address…" %>
|
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "email webauthn", placeholder: "Enter your email address…" %>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if Account.accepting_signups? %>
|
<% if Account.accepting_signups? %>
|
||||||
<p><strong>New here?</strong> <%= link_to "Sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and we’ll get you signed in.</p>
|
<p><strong>New here?</strong> <%= link_to "Sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and we'll get you signed in.</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p>Enter your email and we’ll get you signed in.</p>
|
<p>Enter your email and we'll get you signed in.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<button type="submit" id="log_in" class="btn btn--link center txt-medium">
|
<button type="submit" id="log_in" class="btn btn--link center txt-medium">
|
||||||
<span>Let’s go</span>
|
<span>Let's go</span>
|
||||||
<%= icon_tag "arrow-right" %>
|
<%= icon_tag "arrow-right" %>
|
||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% content_for :footer do %>
|
<% content_for :footer do %>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<tr style="view-transition-name: <%= dom_id(credential) %>">
|
<tr style="view-transition-name: <%= dom_id(credential) %>">
|
||||||
<td><strong><%= credential.name.presence || "Passkey" %></strong></td>
|
<td><strong><%= credential.name.presence || "Passkey" %></strong></td>
|
||||||
<td class="txt-nowrap"><%= local_datetime_tag credential.created_at, style: :datetime %></td>
|
|
||||||
<td>
|
<td>
|
||||||
<%= button_to user_credential_path(@user, credential), method: :delete,
|
<%= button_to user_credential_path(Current.user, credential), method: :delete,
|
||||||
class: "btn btn--circle txt-negative txt-xx-small borderless",
|
class: "btn btn--circle txt-negative txt-xx-small borderless",
|
||||||
data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %>
|
data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %>
|
||||||
<%= icon_tag "trash" %>
|
<%= icon_tag "trash" %>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<% content_for :header do %>
|
<% content_for :header do %>
|
||||||
<div class="header__actions header__actions--start">
|
<div class="header__actions header__actions--start">
|
||||||
<%= back_link_to "My profile", user_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
<%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Passkey</th>
|
<th>Passkey</th>
|
||||||
<th>Created</th>
|
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -26,7 +25,7 @@
|
|||||||
</table>
|
</table>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= link_to new_user_credential_path(@user), class: "btn btn--link" do %>
|
<%= link_to new_user_credential_path(Current.user), class: "btn btn--link" do %>
|
||||||
<%= icon_tag "add" %>
|
<%= icon_tag "add" %>
|
||||||
<span>Add a passkey</span>
|
<span>Add a passkey</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
<% content_for :header do %>
|
<% content_for :header do %>
|
||||||
<div class="header__actions header__actions--start">
|
<div class="header__actions header__actions--start">
|
||||||
<%= back_link_to "Passkeys", user_credentials_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
<%= back_link_to "Passkeys", user_credentials_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<section class="panel panel--wide shadow center txt-align-start">
|
<section class="panel panel--wide shadow center txt-align-start">
|
||||||
<%= form_with url: user_credentials_path(@user),
|
<%= form_with url: user_credentials_path(Current.user),
|
||||||
data: { controller: "credential", credential_public_key_value: @creation_options.as_json },
|
data: { controller: "credential", credential_public_key_value: @creation_options.as_json },
|
||||||
html: { class: "flex flex-column gap" } do |form| %>
|
html: { class: "flex flex-column gap" } do |form| %>
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
<input type="text" id="credential_name" name="credential[name]" required autofocus class="input" placeholder="e.g. MacBook Pro, iPhone">
|
<input type="text" id="credential_name" name="credential[name]" required autofocus class="input" placeholder="e.g. MacBook Pro, iPhone">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" name="credential[response][client_data_json]" data-credential-target="clientDataJSON">
|
<input type="hidden" name="credential[client_data_json]" data-credential-target="clientDataJSON">
|
||||||
<input type="hidden" name="credential[response][attestation_object]" data-credential-target="attestationObject">
|
<input type="hidden" name="credential[attestation_object]" data-credential-target="attestationObject">
|
||||||
|
|
||||||
<button type="button" data-action="credential#create" class="btn btn--link center txt-medium">
|
<button type="button" data-action="credential#create" class="btn btn--link center txt-medium">
|
||||||
<%= icon_tag "add" %>
|
<%= icon_tag "add" %>
|
||||||
@@ -27,5 +27,5 @@
|
|||||||
</button>
|
</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= link_to "Cancel and go back", user_credentials_path(@user), hidden: true %>
|
<%= link_to "Cancel and go back", user_credentials_path(Current.user), hidden: true %>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="flex flex-column gap position-relative">
|
<div class="flex flex-column gap position-relative">
|
||||||
<% if Current.user == @user %>
|
<% if Current.user == @user %>
|
||||||
<%= link_to user_credentials_path(@user), class: "user-edit-link btn", style: "inset: 0 auto auto 0", data: { controller: "tooltip" } do %>
|
<%= link_to user_credentials_path(@user), class: "user-edit-link btn", style: "inset: 0 auto auto 0", data: { controller: "tooltip" } do %>
|
||||||
🔑
|
<%= icon_tag "key" %>
|
||||||
<span class="for-screen-reader">Manage passkeys</span>
|
<span class="for-screen-reader">Manage passkeys</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ Rails.application.routes.draw do
|
|||||||
resources :transfers
|
resources :transfers
|
||||||
resource :magic_link
|
resource :magic_link
|
||||||
resource :menu
|
resource :menu
|
||||||
|
resource :passkey, only: :create
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
|
|||||||
],
|
],
|
||||||
authenticatorSelection: {
|
authenticatorSelection: {
|
||||||
residentKey: @resident_key.to_s,
|
residentKey: @resident_key.to_s,
|
||||||
|
requireResidentKey: @resident_key == :required,
|
||||||
userVerification: user_verification.to_s
|
userVerification: user_verification.to_s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@identity = identities(:kevin)
|
||||||
|
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
|
||||||
|
|
||||||
|
@credential = @identity.credentials.create!(
|
||||||
|
name: "Test Passkey",
|
||||||
|
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
|
||||||
|
public_key: @private_key.public_to_der,
|
||||||
|
sign_count: 0,
|
||||||
|
transports: [ "internal" ]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "successful authentication" do
|
||||||
|
untenanted do
|
||||||
|
get new_session_url
|
||||||
|
challenge = session[:webauthn_challenge]
|
||||||
|
|
||||||
|
post session_passkey_url, params: assertion_params(challenge: challenge)
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
assert cookies[:session_token].present?
|
||||||
|
assert_redirected_to landing_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates sign count" do
|
||||||
|
untenanted do
|
||||||
|
get new_session_url
|
||||||
|
challenge = session[:webauthn_challenge]
|
||||||
|
|
||||||
|
post session_passkey_url, params: assertion_params(challenge: challenge, sign_count: 1)
|
||||||
|
|
||||||
|
assert_equal 1, @credential.reload.sign_count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid signature" do
|
||||||
|
untenanted do
|
||||||
|
get new_session_url
|
||||||
|
challenge = session[:webauthn_challenge]
|
||||||
|
|
||||||
|
params = assertion_params(challenge: challenge)
|
||||||
|
params[:response][:signature] = Base64.urlsafe_encode64("invalid", padding: false)
|
||||||
|
|
||||||
|
post session_passkey_url, params: params
|
||||||
|
|
||||||
|
assert_redirected_to new_session_path
|
||||||
|
assert_not cookies[:session_token].present?
|
||||||
|
assert_equal "That passkey didn't work. Try again.", flash[:alert]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects unknown credential" do
|
||||||
|
untenanted do
|
||||||
|
get new_session_url
|
||||||
|
|
||||||
|
post session_passkey_url, params: {
|
||||||
|
credential_id: "nonexistent",
|
||||||
|
response: {
|
||||||
|
client_data_json: Base64.urlsafe_encode64("{}", padding: false),
|
||||||
|
authenticator_data: Base64.urlsafe_encode64("x", padding: false),
|
||||||
|
signature: Base64.urlsafe_encode64("x", padding: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to new_session_path
|
||||||
|
assert_not cookies[:session_token].present?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "successful authentication via JSON" do
|
||||||
|
untenanted do
|
||||||
|
get new_session_url
|
||||||
|
challenge = session[:webauthn_challenge]
|
||||||
|
|
||||||
|
post session_passkey_url(format: :json), params: assertion_params(challenge: challenge)
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert @response.parsed_body["session_token"].present?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "failed authentication via JSON" do
|
||||||
|
untenanted do
|
||||||
|
get new_session_url
|
||||||
|
|
||||||
|
post session_passkey_url(format: :json), params: {
|
||||||
|
credential_id: "nonexistent",
|
||||||
|
response: {
|
||||||
|
client_data_json: Base64.urlsafe_encode64("{}", padding: false),
|
||||||
|
authenticator_data: Base64.urlsafe_encode64("x", padding: false),
|
||||||
|
signature: Base64.urlsafe_encode64("x", padding: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
assert_equal "That passkey didn't work. Try again.", @response.parsed_body["message"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def assertion_params(challenge:, sign_count: 1)
|
||||||
|
origin = "http://www.example.com"
|
||||||
|
|
||||||
|
client_data_json = {
|
||||||
|
challenge: challenge,
|
||||||
|
origin: origin,
|
||||||
|
type: "webauthn.get"
|
||||||
|
}.to_json
|
||||||
|
|
||||||
|
authenticator_data = build_authenticator_data(sign_count: sign_count)
|
||||||
|
signature = sign(authenticator_data, client_data_json)
|
||||||
|
|
||||||
|
{
|
||||||
|
credential_id: @credential.credential_id,
|
||||||
|
response: {
|
||||||
|
client_data_json: client_data_json,
|
||||||
|
authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),
|
||||||
|
signature: Base64.urlsafe_encode64(signature, padding: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_authenticator_data(sign_count:)
|
||||||
|
rp_id_hash = Digest::SHA256.digest("www.example.com")
|
||||||
|
flags = 0x01 | 0x04 # user present + user verified
|
||||||
|
|
||||||
|
bytes = []
|
||||||
|
bytes.concat(rp_id_hash.bytes)
|
||||||
|
bytes << flags
|
||||||
|
bytes.concat([ sign_count ].pack("N").bytes)
|
||||||
|
bytes.pack("C*")
|
||||||
|
end
|
||||||
|
|
||||||
|
def sign(authenticator_data, client_data_json)
|
||||||
|
client_data_hash = Digest::SHA256.digest(client_data_json)
|
||||||
|
signed_data = authenticator_data + client_data_hash
|
||||||
|
@private_key.sign("SHA256", signed_data)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -43,6 +43,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
|
|||||||
], @options.as_json[:pubKeyCredParams]
|
], @options.as_json[:pubKeyCredParams]
|
||||||
|
|
||||||
assert_equal "preferred", @options.as_json[:authenticatorSelection][:residentKey]
|
assert_equal "preferred", @options.as_json[:authenticatorSelection][:residentKey]
|
||||||
|
assert_equal false, @options.as_json[:authenticatorSelection][:requireResidentKey]
|
||||||
assert_equal "preferred", @options.as_json[:authenticatorSelection][:userVerification]
|
assert_equal "preferred", @options.as_json[:authenticatorSelection][:userVerification]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert_equal "required", options.as_json[:authenticatorSelection][:residentKey]
|
assert_equal "required", options.as_json[:authenticatorSelection][:residentKey]
|
||||||
|
assert_equal true, options.as_json[:authenticatorSelection][:requireResidentKey]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "as_json excludes excludeCredentials when empty" do
|
test "as_json excludes excludeCredentials when empty" do
|
||||||
|
|||||||
Reference in New Issue
Block a user