maquina-generators 0.7.0 → 0.8.1
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 +4 -4
- data/README.md +53 -0
- data/lib/generators/maquina/clave/templates/app/views/sessions/new.html.erb.tt +2 -0
- data/lib/generators/maquina/passkeys/USAGE +18 -0
- data/lib/generators/maquina/passkeys/passkeys_generator.rb +260 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_challenge.rb +42 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_reauthentication.rb +26 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/passkey_challenges_controller.rb +24 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/passkeys_controller.rb +68 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkey_challenges_controller.rb +18 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkeys_controller.rb +55 -0
- data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_registration_controller.js +109 -0
- data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_sign_in_controller.js +113 -0
- data/lib/generators/maquina/passkeys/templates/app/models/passkey.rb +36 -0
- data/lib/generators/maquina/passkeys/templates/app/views/passkeys/index.html.erb +77 -0
- data/lib/generators/maquina/passkeys/templates/app/views/sessions/_passkey.html.erb +23 -0
- data/lib/generators/maquina/passkeys/templates/config/initializers/webauthn.rb +23 -0
- data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.en.yml +46 -0
- data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.es.yml +46 -0
- data/lib/generators/maquina/passkeys/templates/migration_add_webauthn_id_to_users.rb.tt +6 -0
- data/lib/generators/maquina/passkeys/templates/migration_create_passkeys.rb.tt +20 -0
- data/lib/generators/maquina/passkeys/templates/test/integration/passkeys_test.rb +99 -0
- data/lib/generators/maquina/passkeys/templates/test/test_helpers/webauthn_test_helper.rb +38 -0
- data/lib/maquina_generators/version.rb +1 -1
- data/test/generators/maquina/clave_generator_test.rb +13 -2
- data/test/generators/maquina/passkeys_generator_test.rb +267 -0
- data/test/tmp/Gemfile +1 -13
- data/test/tmp/app/controllers/concerns/authentication.rb +2 -0
- data/test/tmp/app/controllers/concerns/passkey_challenge.rb +42 -0
- data/test/tmp/app/controllers/concerns/passkey_reauthentication.rb +26 -0
- data/test/tmp/app/controllers/passkey_challenges_controller.rb +24 -0
- data/test/tmp/app/controllers/passkeys_controller.rb +68 -0
- data/test/tmp/app/controllers/session/passkey_challenges_controller.rb +18 -0
- data/test/tmp/app/controllers/session/passkeys_controller.rb +55 -0
- data/test/tmp/app/javascript/controllers/passkey_registration_controller.js +109 -0
- data/test/tmp/app/javascript/controllers/passkey_sign_in_controller.js +113 -0
- data/test/tmp/app/models/email_verification.rb +2 -0
- data/test/tmp/app/models/passkey.rb +36 -0
- data/test/tmp/app/models/user.rb +47 -0
- data/test/tmp/app/views/passkeys/index.html.erb +77 -0
- data/test/tmp/app/views/sessions/_passkey.html.erb +23 -0
- data/test/tmp/app/views/sessions/new.html.erb +37 -0
- data/test/tmp/config/initializers/rack_attack.rb +9 -0
- data/test/tmp/config/initializers/webauthn.rb +23 -0
- data/test/tmp/config/locales/passkeys.en.yml +46 -0
- data/test/tmp/config/locales/passkeys.es.yml +46 -0
- data/test/tmp/config/routes.rb +7 -1
- data/test/tmp/db/migrate/20261014213621_add_webauthn_id_to_users.rb +6 -0
- data/test/tmp/db/migrate/20261014213622_create_passkeys.rb +20 -0
- data/test/tmp/test/integration/passkeys_test.rb +99 -0
- data/test/tmp/test/test_helper.rb +4 -0
- data/test/tmp/test/test_helpers/webauthn_test_helper.rb +38 -0
- metadata +49 -19
- data/test/tmp/.gitignore +0 -5
- data/test/tmp/.rubocop.yml +0 -11
- data/test/tmp/.standard.yml +0 -1
- data/test/tmp/Procfile.dev +0 -3
- data/test/tmp/README.md +0 -89
- data/test/tmp/app/controllers/home_controller.rb +0 -6
- data/test/tmp/app/javascript/application.js +0 -1
- data/test/tmp/app/views/home/index.html.erb +0 -204
- data/test/tmp/app/views/layouts/application.html.erb +0 -10
- data/test/tmp/config/application.rb +0 -15
- data/test/tmp/config/database.yml +0 -90
- data/test/tmp/config/database.yml.example +0 -90
- data/test/tmp/config/environments/development.rb +0 -8
- data/test/tmp/config/environments/production.rb +0 -6
- data/test/tmp/config/initializers/generators.rb +0 -4
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Passkey registration and upkeep (maquina:passkeys).
|
|
4
|
+
//
|
|
5
|
+
// The button creates a passkey with the browser's own JSON helpers. On
|
|
6
|
+
// connect the page tells the passkey provider which passkeys this account
|
|
7
|
+
// still has and how it is named, so one removed here disappears from the
|
|
8
|
+
// device's list too. Both signals are skipped where unsupported.
|
|
9
|
+
//
|
|
10
|
+
// The button is rendered visible and only hidden where passkeys are not
|
|
11
|
+
// supported. A Turbo morph puts the server's attributes back without
|
|
12
|
+
// reconnecting this controller, so the view calls refresh on turbo:morph.
|
|
13
|
+
export default class extends Controller {
|
|
14
|
+
static targets = ["button", "error", "unsupported"]
|
|
15
|
+
static values = {
|
|
16
|
+
challengeUrl: String,
|
|
17
|
+
url: String,
|
|
18
|
+
errorMessage: String,
|
|
19
|
+
duplicateMessage: String,
|
|
20
|
+
rpId: String,
|
|
21
|
+
userId: String,
|
|
22
|
+
userName: String,
|
|
23
|
+
displayName: String,
|
|
24
|
+
credentialIds: Array
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
connect() {
|
|
28
|
+
this.refresh()
|
|
29
|
+
if (supported()) this.#signalAccountState()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
refresh() {
|
|
33
|
+
this.buttonTarget.hidden = !supported()
|
|
34
|
+
this.unsupportedTarget.hidden = supported()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async register() {
|
|
38
|
+
this.buttonTarget.disabled = true
|
|
39
|
+
this.#showError(null)
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const options = await post(this.challengeUrlValue)
|
|
43
|
+
// Sign in again first: a full load, so the new session gets a fresh page.
|
|
44
|
+
if (options.body.redirect_url) return window.location.assign(options.body.redirect_url)
|
|
45
|
+
if (!options.ok) return this.#showError(options.body.error)
|
|
46
|
+
|
|
47
|
+
let credential
|
|
48
|
+
try {
|
|
49
|
+
credential = await navigator.credentials.create({
|
|
50
|
+
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options.body)
|
|
51
|
+
})
|
|
52
|
+
} catch (error) {
|
|
53
|
+
// Cancelled, or this device already holds a passkey for the account.
|
|
54
|
+
if (error.name === "NotAllowedError") return
|
|
55
|
+
if (error.name === "InvalidStateError") return this.#showError(this.duplicateMessageValue)
|
|
56
|
+
return this.#showError()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const result = await post(this.urlValue, { credential: credential.toJSON() })
|
|
60
|
+
if (result.body.redirect_url) return visit(result.body.redirect_url)
|
|
61
|
+
this.#showError(result.body.error)
|
|
62
|
+
} finally {
|
|
63
|
+
this.buttonTarget.disabled = false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#signalAccountState() {
|
|
68
|
+
const { rpIdValue: rpId, userIdValue: userId } = this
|
|
69
|
+
|
|
70
|
+
PublicKeyCredential.signalAllAcceptedCredentials?.({
|
|
71
|
+
rpId, userId, allAcceptedCredentialIds: this.credentialIdsValue
|
|
72
|
+
}).catch(() => {})
|
|
73
|
+
PublicKeyCredential.signalCurrentUserDetails?.({
|
|
74
|
+
rpId, userId, name: this.userNameValue, displayName: this.displayNameValue
|
|
75
|
+
}).catch(() => {})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#showError(message = this.errorMessageValue) {
|
|
79
|
+
this.errorTarget.textContent = message || ""
|
|
80
|
+
this.errorTarget.hidden = !message
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// JSON POST with the CSRF token; a network failure reads as an empty error.
|
|
85
|
+
async function post(url, body) {
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(url, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: {
|
|
90
|
+
"Content-Type": "application/json",
|
|
91
|
+
Accept: "application/json",
|
|
92
|
+
"X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.content
|
|
93
|
+
},
|
|
94
|
+
body: body && JSON.stringify(body)
|
|
95
|
+
})
|
|
96
|
+
return { ok: response.ok, status: response.status, body: await response.json().catch(() => ({})) }
|
|
97
|
+
} catch {
|
|
98
|
+
return { ok: false, status: 0, body: {} }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function visit(url) {
|
|
103
|
+
window.Turbo ? window.Turbo.visit(url) : window.location.assign(url)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function supported() {
|
|
107
|
+
return typeof PublicKeyCredential !== "undefined" &&
|
|
108
|
+
typeof PublicKeyCredential.parseCreationOptionsFromJSON === "function"
|
|
109
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Passkey sign-in (maquina:passkeys).
|
|
4
|
+
//
|
|
5
|
+
// On connect it starts a conditional request: the browser offers saved
|
|
6
|
+
// passkeys in the email field's autofill (autocomplete="username webauthn").
|
|
7
|
+
// The button starts a modal request instead. Both use the browser's own JSON
|
|
8
|
+
// helpers, so no WebAuthn library is needed. Cancelling the prompt is a
|
|
9
|
+
// normal outcome, not an error.
|
|
10
|
+
//
|
|
11
|
+
// The block is rendered visible and only hidden where passkeys are not
|
|
12
|
+
// supported; the view calls refresh on turbo:morph, which puts the server's
|
|
13
|
+
// attributes back without reconnecting this controller. A successful sign-in
|
|
14
|
+
// loads the next page in full, so it starts with the new session's CSRF token
|
|
15
|
+
// and no Turbo snapshot of the signed-out page.
|
|
16
|
+
export default class extends Controller {
|
|
17
|
+
static targets = ["button", "error"]
|
|
18
|
+
static values = { challengeUrl: String, url: String, errorMessage: String }
|
|
19
|
+
|
|
20
|
+
connect() {
|
|
21
|
+
this.refresh()
|
|
22
|
+
if (supported()) this.#autofill()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
refresh() {
|
|
26
|
+
this.element.hidden = !supported()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
disconnect() {
|
|
30
|
+
this.#abort()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async signIn() {
|
|
34
|
+
this.buttonTarget.disabled = true
|
|
35
|
+
try {
|
|
36
|
+
await this.#authenticate()
|
|
37
|
+
} finally {
|
|
38
|
+
this.buttonTarget.disabled = false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async #autofill() {
|
|
43
|
+
if (!(await PublicKeyCredential.isConditionalMediationAvailable?.())) return
|
|
44
|
+
|
|
45
|
+
await this.#authenticate("conditional")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async #authenticate(mediation) {
|
|
49
|
+
this.#abort()
|
|
50
|
+
const abortController = this.abortController = new AbortController()
|
|
51
|
+
if (!mediation) this.#showError(null)
|
|
52
|
+
|
|
53
|
+
let credential
|
|
54
|
+
try {
|
|
55
|
+
const options = await post(this.challengeUrlValue)
|
|
56
|
+
if (!options.ok) return this.#showError(options.body.error)
|
|
57
|
+
|
|
58
|
+
credential = await navigator.credentials.get({
|
|
59
|
+
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.body),
|
|
60
|
+
signal: abortController.signal,
|
|
61
|
+
...(mediation && { mediation })
|
|
62
|
+
})
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (abortController.signal.aborted) return
|
|
65
|
+
if (error.name !== "NotAllowedError") return this.#showError()
|
|
66
|
+
// The modal prompt was cancelled: offer passkeys in autofill again.
|
|
67
|
+
if (!mediation) this.#autofill()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const result = await post(this.urlValue, { credential: credential.toJSON() })
|
|
72
|
+
if (result.body.redirect_url) return window.location.assign(result.body.redirect_url)
|
|
73
|
+
|
|
74
|
+
if (result.status === 404 && result.body.credential_id) {
|
|
75
|
+
// Deleted on the server: ask the passkey provider to drop it too.
|
|
76
|
+
PublicKeyCredential.signalUnknownCredential?.({ rpId: result.body.rp_id, credentialId: result.body.credential_id })
|
|
77
|
+
}
|
|
78
|
+
this.#showError(result.body.error)
|
|
79
|
+
this.#autofill()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#abort() {
|
|
83
|
+
this.abortController?.abort()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#showError(message = this.errorMessageValue) {
|
|
87
|
+
this.errorTarget.textContent = message || ""
|
|
88
|
+
this.errorTarget.hidden = !message
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// JSON POST with the CSRF token; a network failure reads as an empty error.
|
|
93
|
+
async function post(url, body) {
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch(url, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: {
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
Accept: "application/json",
|
|
100
|
+
"X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.content
|
|
101
|
+
},
|
|
102
|
+
body: body && JSON.stringify(body)
|
|
103
|
+
})
|
|
104
|
+
return { ok: response.ok, status: response.status, body: await response.json().catch(() => ({})) }
|
|
105
|
+
} catch {
|
|
106
|
+
return { ok: false, status: 0, body: {} }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function supported() {
|
|
111
|
+
return typeof PublicKeyCredential !== "undefined" &&
|
|
112
|
+
typeof PublicKeyCredential.parseRequestOptionsFromJSON === "function"
|
|
113
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# A WebAuthn credential that signs its owner in (generated by maquina:passkeys).
|
|
2
|
+
#
|
|
3
|
+
# Only the public key is stored; the private key never leaves the device or
|
|
4
|
+
# the password manager, so a leaked table cannot be used to sign in.
|
|
5
|
+
class Passkey < ApplicationRecord
|
|
6
|
+
# AAGUIDs of common passkey providers, to give a new passkey a useful
|
|
7
|
+
# default name. Providers that send no AAGUID fall back to "Passkey".
|
|
8
|
+
PROVIDERS = {
|
|
9
|
+
"fbfc3007-154e-4ecc-8c0b-6e020557d7bd" => "iCloud Keychain",
|
|
10
|
+
"ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4" => "Google Password Manager",
|
|
11
|
+
"adce0002-35bc-c60a-648b-0b25f1f05503" => "Chrome on Mac",
|
|
12
|
+
"08987058-cadc-4b81-b6e1-30de50dcbe96" => "Windows Hello",
|
|
13
|
+
"bada5566-a7aa-401f-bd96-45619a55120d" => "1Password",
|
|
14
|
+
"d548826e-79b4-db40-a3d8-11116f7e8349" => "Bitwarden"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
belongs_to :user
|
|
18
|
+
|
|
19
|
+
validates :credential_id, presence: true, uniqueness: true
|
|
20
|
+
validates :public_key, presence: true
|
|
21
|
+
validates :name, presence: true, length: {maximum: 100}
|
|
22
|
+
|
|
23
|
+
def self.default_name(aaguid)
|
|
24
|
+
PROVIDERS.fetch(aaguid, "Passkey")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def record_use!(sign_count)
|
|
28
|
+
update!(sign_count: sign_count, last_used_at: Time.current)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Synced through a provider (iCloud Keychain, Google Password Manager, a
|
|
32
|
+
# password manager) rather than bound to one device.
|
|
33
|
+
def synced?
|
|
34
|
+
backed_up?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<div class="flex flex-1 justify-center px-4 py-8">
|
|
2
|
+
<div class="w-full max-w-2xl space-y-6"
|
|
3
|
+
data-controller="passkey-registration"
|
|
4
|
+
data-action="turbo:morph@document->passkey-registration#refresh"
|
|
5
|
+
data-passkey-registration-challenge-url-value="<%= passkey_challenge_path %>"
|
|
6
|
+
data-passkey-registration-url-value="<%= passkeys_path %>"
|
|
7
|
+
data-passkey-registration-error-message-value="<%= t(".error") %>"
|
|
8
|
+
data-passkey-registration-duplicate-message-value="<%= t(".duplicate") %>"
|
|
9
|
+
data-passkey-registration-rp-id-value="<%= WebAuthn.configuration.rp_id %>"
|
|
10
|
+
data-passkey-registration-user-id-value="<%= Current.user.webauthn_id %>"
|
|
11
|
+
data-passkey-registration-user-name-value="<%= Current.user.email_address %>"
|
|
12
|
+
data-passkey-registration-display-name-value="<%= Current.user.name.presence || Current.user.email_address %>"
|
|
13
|
+
data-passkey-registration-credential-ids-value="<%= @passkeys.map(&:credential_id).to_json %>">
|
|
14
|
+
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
15
|
+
<div>
|
|
16
|
+
<h1 class="text-2xl font-bold text-foreground"><%= t(".title") %></h1>
|
|
17
|
+
<p class="mt-1 text-sm text-muted-foreground"><%= t(".description") %></p>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<button type="button"
|
|
21
|
+
data-action="passkey-registration#register"
|
|
22
|
+
data-passkey-registration-target="button"
|
|
23
|
+
data-component="button"
|
|
24
|
+
data-variant="primary"
|
|
25
|
+
class="px-4 py-2 text-sm font-medium text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors cursor-pointer disabled:opacity-50">
|
|
26
|
+
<%= t(".add") %>
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<p data-passkey-registration-target="error" class="text-sm text-destructive" role="alert" hidden></p>
|
|
31
|
+
<p data-passkey-registration-target="unsupported" class="text-sm text-muted-foreground" hidden><%= t(".unsupported") %></p>
|
|
32
|
+
|
|
33
|
+
<% if @passkeys.empty? %>
|
|
34
|
+
<div class="bg-card rounded-xl border border-border p-8 text-center">
|
|
35
|
+
<p class="text-sm text-muted-foreground"><%= t(".empty") %></p>
|
|
36
|
+
</div>
|
|
37
|
+
<% else %>
|
|
38
|
+
<ul class="bg-card rounded-xl border border-border divide-y divide-border">
|
|
39
|
+
<% @passkeys.each do |passkey| %>
|
|
40
|
+
<li id="<%= dom_id(passkey) %>" class="flex flex-wrap items-center gap-4 p-4">
|
|
41
|
+
<%= form_with model: passkey, class: "flex flex-1 items-center gap-2 min-w-0" do |form| %>
|
|
42
|
+
<%= form.text_field :name, required: true, maxlength: 100,
|
|
43
|
+
aria: { label: Passkey.human_attribute_name(:name) },
|
|
44
|
+
data: { component: "input" },
|
|
45
|
+
class: "min-w-0 flex-1 px-3 py-2 text-sm border border-input rounded-lg focus:ring-2 focus:ring-ring focus:border-ring" %>
|
|
46
|
+
<%= form.submit t(".rename"), data: { component: "button", variant: "ghost" },
|
|
47
|
+
class: "px-3 py-2 text-sm font-medium text-foreground hover:bg-accent rounded-lg cursor-pointer" %>
|
|
48
|
+
<% end %>
|
|
49
|
+
|
|
50
|
+
<div class="text-xs text-muted-foreground space-y-1">
|
|
51
|
+
<p>
|
|
52
|
+
<%= t(".created", date: l(passkey.created_at.to_date, format: :long)) %>
|
|
53
|
+
<% if passkey.synced? %>
|
|
54
|
+
· <span data-component="badge" class="font-medium"><%= t(".synced") %></span>
|
|
55
|
+
<% end %>
|
|
56
|
+
</p>
|
|
57
|
+
<p>
|
|
58
|
+
<%= passkey.last_used_at ? t(".last_used", time: time_ago_in_words(passkey.last_used_at)) : t(".never_used") %>
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<%= button_to t(".remove"), passkey_path(passkey), method: :delete,
|
|
63
|
+
form: { data: { turbo_confirm: t(".remove_confirm") } },
|
|
64
|
+
data: { component: "button", variant: "ghost" },
|
|
65
|
+
class: "px-3 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 rounded-lg cursor-pointer" %>
|
|
66
|
+
</li>
|
|
67
|
+
<% end %>
|
|
68
|
+
</ul>
|
|
69
|
+
|
|
70
|
+
<% if @passkeys.one? && !@passkeys.first.synced? %>
|
|
71
|
+
<p class="text-sm text-muted-foreground"><%= t(".add_another") %></p>
|
|
72
|
+
<% end %>
|
|
73
|
+
<% end %>
|
|
74
|
+
|
|
75
|
+
<p class="text-sm text-muted-foreground"><%= t(".fallback") %></p>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<div data-controller="passkey-sign-in"
|
|
2
|
+
data-action="turbo:morph@document->passkey-sign-in#refresh"
|
|
3
|
+
data-passkey-sign-in-challenge-url-value="<%= session_passkey_challenge_path %>"
|
|
4
|
+
data-passkey-sign-in-url-value="<%= session_passkey_path %>"
|
|
5
|
+
data-passkey-sign-in-error-message-value="<%= t("sessions.passkey.error") %>"
|
|
6
|
+
class="mt-6 space-y-4">
|
|
7
|
+
<div class="flex items-center gap-3 text-sm text-muted-foreground">
|
|
8
|
+
<span class="h-px flex-1 bg-border"></span>
|
|
9
|
+
<%= t("sessions.passkey.or") %>
|
|
10
|
+
<span class="h-px flex-1 bg-border"></span>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<button type="button"
|
|
14
|
+
data-action="passkey-sign-in#signIn"
|
|
15
|
+
data-passkey-sign-in-target="button"
|
|
16
|
+
data-component="button"
|
|
17
|
+
data-variant="outline"
|
|
18
|
+
class="w-full px-4 py-3 text-base font-medium text-foreground bg-background border border-input hover:bg-accent rounded-lg transition-colors cursor-pointer disabled:opacity-50">
|
|
19
|
+
<%= t("sessions.passkey.sign_in") %>
|
|
20
|
+
</button>
|
|
21
|
+
|
|
22
|
+
<p data-passkey-sign-in-target="error" class="text-sm text-destructive text-center" role="alert" hidden></p>
|
|
23
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Passkeys (maquina:passkeys)
|
|
2
|
+
#
|
|
3
|
+
# A passkey is bound to its relying party: rp_id is the domain it belongs to,
|
|
4
|
+
# and the browser only offers it on pages from allowed_origins. rp_id can be
|
|
5
|
+
# a parent of the origins' host (example.com for https://app.example.com),
|
|
6
|
+
# never another domain. Changing rp_id later orphans every saved passkey.
|
|
7
|
+
#
|
|
8
|
+
# Development and test use localhost; production reads WEBAUTHN_RP_ID and
|
|
9
|
+
# WEBAUTHN_ORIGINS (comma-separated), or webauthn: {rp_id:, origins:} in the
|
|
10
|
+
# credentials.
|
|
11
|
+
webauthn = Rails.application.credentials.webauthn || {}
|
|
12
|
+
|
|
13
|
+
WebAuthn.configure do |config|
|
|
14
|
+
config.rp_name = ENV.fetch("WEBAUTHN_RP_NAME") { Rails.application.class.module_parent_name.titleize }
|
|
15
|
+
config.rp_id = ENV["WEBAUTHN_RP_ID"] || webauthn[:rp_id] || "localhost"
|
|
16
|
+
config.allowed_origins = Array(ENV["WEBAUTHN_ORIGINS"]&.split(",") || webauthn[:origins] || "http://localhost:3000")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A production app still on localhost would accept no passkey at all. The
|
|
20
|
+
# asset build runs in production without the real settings, so it is spared.
|
|
21
|
+
if Rails.env.production? && WebAuthn.configuration.rp_id == "localhost" && ENV["SECRET_KEY_BASE_DUMMY"].blank?
|
|
22
|
+
raise "Passkeys: set WEBAUTHN_RP_ID and WEBAUTHN_ORIGINS (see config/initializers/webauthn.rb)"
|
|
23
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
en:
|
|
2
|
+
flash:
|
|
3
|
+
passkeys:
|
|
4
|
+
created: "Passkey added. You can now sign in with it."
|
|
5
|
+
updated: "Passkey renamed."
|
|
6
|
+
destroyed: "Passkey removed."
|
|
7
|
+
failed: "We couldn't sign you in with that passkey. Try again or use your email."
|
|
8
|
+
unknown: "That passkey is no longer linked to an account. Sign in with your email instead."
|
|
9
|
+
challenge_expired: "The request expired. Please try again."
|
|
10
|
+
registration_failed: "We couldn't add that passkey. Please try again."
|
|
11
|
+
reauthenticate: "Sign in again to manage your passkeys."
|
|
12
|
+
|
|
13
|
+
sessions:
|
|
14
|
+
passkey:
|
|
15
|
+
or: "or"
|
|
16
|
+
sign_in: "Sign in with a passkey"
|
|
17
|
+
error: "Something went wrong with the passkey. Please try again."
|
|
18
|
+
|
|
19
|
+
passkeys:
|
|
20
|
+
index:
|
|
21
|
+
title: "Passkeys"
|
|
22
|
+
description: "Sign in with Face ID, a fingerprint or your device PIN instead of an email code."
|
|
23
|
+
add: "Add a passkey"
|
|
24
|
+
empty: "You don't have any passkeys yet."
|
|
25
|
+
rename: "Save"
|
|
26
|
+
remove: "Remove"
|
|
27
|
+
remove_confirm: "Remove this passkey? You won't be able to sign in with it anymore."
|
|
28
|
+
created: "Added %{date}"
|
|
29
|
+
synced: "Synced"
|
|
30
|
+
last_used: "Last used %{time} ago"
|
|
31
|
+
never_used: "Not used yet"
|
|
32
|
+
add_another: "This passkey lives on one device. Add another so you can still sign in if you lose it."
|
|
33
|
+
fallback: "You can always sign in with a code sent to your email."
|
|
34
|
+
unsupported: "This browser doesn't support passkeys."
|
|
35
|
+
error: "Something went wrong with the passkey. Please try again."
|
|
36
|
+
duplicate: "This device already has a passkey for your account."
|
|
37
|
+
|
|
38
|
+
activerecord:
|
|
39
|
+
models:
|
|
40
|
+
passkey:
|
|
41
|
+
one: "Passkey"
|
|
42
|
+
other: "Passkeys"
|
|
43
|
+
attributes:
|
|
44
|
+
passkey:
|
|
45
|
+
name: "Name"
|
|
46
|
+
last_used_at: "Last used"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
es:
|
|
2
|
+
flash:
|
|
3
|
+
passkeys:
|
|
4
|
+
created: "Llave de acceso agregada. Ya puedes iniciar sesión con ella."
|
|
5
|
+
updated: "Llave de acceso renombrada."
|
|
6
|
+
destroyed: "Llave de acceso eliminada."
|
|
7
|
+
failed: "No pudimos iniciar tu sesión con esa llave de acceso. Inténtalo de nuevo o usa tu correo."
|
|
8
|
+
unknown: "Esa llave de acceso ya no está vinculada a una cuenta. Inicia sesión con tu correo."
|
|
9
|
+
challenge_expired: "La solicitud expiró. Inténtalo de nuevo."
|
|
10
|
+
registration_failed: "No pudimos agregar esa llave de acceso. Inténtalo de nuevo."
|
|
11
|
+
reauthenticate: "Inicia sesión de nuevo para administrar tus llaves de acceso."
|
|
12
|
+
|
|
13
|
+
sessions:
|
|
14
|
+
passkey:
|
|
15
|
+
or: "o"
|
|
16
|
+
sign_in: "Iniciar sesión con llave de acceso"
|
|
17
|
+
error: "Algo salió mal con la llave de acceso. Inténtalo de nuevo."
|
|
18
|
+
|
|
19
|
+
passkeys:
|
|
20
|
+
index:
|
|
21
|
+
title: "Llaves de acceso"
|
|
22
|
+
description: "Inicia sesión con Face ID, tu huella o el PIN de tu dispositivo en lugar de un código por correo."
|
|
23
|
+
add: "Agregar llave de acceso"
|
|
24
|
+
empty: "Todavía no tienes llaves de acceso."
|
|
25
|
+
rename: "Guardar"
|
|
26
|
+
remove: "Eliminar"
|
|
27
|
+
remove_confirm: "¿Eliminar esta llave de acceso? Ya no podrás iniciar sesión con ella."
|
|
28
|
+
created: "Agregada el %{date}"
|
|
29
|
+
synced: "Sincronizada"
|
|
30
|
+
last_used: "Usada hace %{time}"
|
|
31
|
+
never_used: "Sin usar"
|
|
32
|
+
add_another: "Esta llave de acceso vive en un solo dispositivo. Agrega otra para no perder el acceso si lo pierdes."
|
|
33
|
+
fallback: "Siempre puedes iniciar sesión con un código enviado a tu correo."
|
|
34
|
+
unsupported: "Este navegador no es compatible con llaves de acceso."
|
|
35
|
+
error: "Algo salió mal con la llave de acceso. Inténtalo de nuevo."
|
|
36
|
+
duplicate: "Este dispositivo ya tiene una llave de acceso para tu cuenta."
|
|
37
|
+
|
|
38
|
+
activerecord:
|
|
39
|
+
models:
|
|
40
|
+
passkey:
|
|
41
|
+
one: "Llave de acceso"
|
|
42
|
+
other: "Llaves de acceso"
|
|
43
|
+
attributes:
|
|
44
|
+
passkey:
|
|
45
|
+
name: "Nombre"
|
|
46
|
+
last_used_at: "Último uso"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class CreatePasskeys < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :passkeys do |t|
|
|
4
|
+
t.references :user, null: false, foreign_key: true
|
|
5
|
+
t.string :credential_id, null: false
|
|
6
|
+
t.text :public_key, null: false
|
|
7
|
+
t.bigint :sign_count, null: false, default: 0
|
|
8
|
+
t.string :name, null: false
|
|
9
|
+
t.json :transports
|
|
10
|
+
t.string :aaguid, limit: 36
|
|
11
|
+
t.boolean :backup_eligible, null: false, default: false
|
|
12
|
+
t.boolean :backed_up, null: false, default: false
|
|
13
|
+
t.datetime :last_used_at
|
|
14
|
+
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :passkeys, :credential_id, unique: true
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require "test_helper"
|
|
2
|
+
require_relative "../test_helpers/session_test_helper"
|
|
3
|
+
|
|
4
|
+
# The passkey flow end to end (generated by maquina:passkeys), with
|
|
5
|
+
# WebAuthn::FakeClient as the browser and the authenticator.
|
|
6
|
+
class PasskeysTest < ActionDispatch::IntegrationTest
|
|
7
|
+
setup do
|
|
8
|
+
@user = User.create!(account: Account.create!(name: "Acme"), email_address: "passkey@example.com")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
test "adds a passkey and signs in with it" do
|
|
12
|
+
sign_in_as @user
|
|
13
|
+
register_passkey
|
|
14
|
+
assert_response :created
|
|
15
|
+
passkey = @user.passkeys.sole
|
|
16
|
+
|
|
17
|
+
sign_out
|
|
18
|
+
sign_in_with_passkey(@user)
|
|
19
|
+
|
|
20
|
+
assert_response :success
|
|
21
|
+
assert response.parsed_body["redirect_url"].present?
|
|
22
|
+
assert cookies["session_id"].present?
|
|
23
|
+
assert passkey.reload.last_used_at
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
test "a challenge signs in once" do
|
|
27
|
+
sign_in_as @user
|
|
28
|
+
register_passkey
|
|
29
|
+
sign_out
|
|
30
|
+
|
|
31
|
+
post session_passkey_challenge_path, as: :json
|
|
32
|
+
assertion = webauthn_client.get(challenge: response.parsed_body["challenge"], rp_id: WebAuthn.configuration.rp_id,
|
|
33
|
+
user_verified: true, user_handle: Base64.urlsafe_decode64(@user.reload.webauthn_id))
|
|
34
|
+
post session_passkey_path, params: {credential: assertion}, as: :json
|
|
35
|
+
assert_response :success
|
|
36
|
+
|
|
37
|
+
post session_passkey_path, params: {credential: assertion}, as: :json
|
|
38
|
+
assert_response :unprocessable_entity
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
test "a passkey needs user verification" do
|
|
42
|
+
sign_in_as @user
|
|
43
|
+
post passkey_challenge_path, as: :json
|
|
44
|
+
credential = webauthn_client.create(challenge: response.parsed_body["challenge"], rp_id: WebAuthn.configuration.rp_id,
|
|
45
|
+
user_verified: false)
|
|
46
|
+
|
|
47
|
+
post passkeys_path, params: {credential: credential}, as: :json
|
|
48
|
+
|
|
49
|
+
assert_response :unprocessable_entity
|
|
50
|
+
assert_empty @user.passkeys
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
test "a removed passkey no longer signs in" do
|
|
54
|
+
sign_in_as @user
|
|
55
|
+
register_passkey
|
|
56
|
+
@user.passkeys.destroy_all
|
|
57
|
+
sign_out
|
|
58
|
+
|
|
59
|
+
sign_in_with_passkey(@user)
|
|
60
|
+
|
|
61
|
+
assert_response :not_found
|
|
62
|
+
assert response.parsed_body["credential_id"].present?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
test "a blocked user cannot sign in with a passkey" do
|
|
66
|
+
sign_in_as @user
|
|
67
|
+
register_passkey
|
|
68
|
+
sign_out
|
|
69
|
+
@user.block!
|
|
70
|
+
|
|
71
|
+
sign_in_with_passkey(@user)
|
|
72
|
+
|
|
73
|
+
assert_response :unprocessable_entity
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
test "adding a passkey needs a recent sign-in" do
|
|
77
|
+
sign_in_as @user
|
|
78
|
+
Current.session.update_column(:created_at, 2.hours.ago)
|
|
79
|
+
|
|
80
|
+
post passkey_challenge_path, as: :json
|
|
81
|
+
|
|
82
|
+
assert_response :unauthorized
|
|
83
|
+
assert_equal new_session_url, response.parsed_body["redirect_url"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
test "renames and removes a passkey" do
|
|
87
|
+
sign_in_as @user
|
|
88
|
+
register_passkey
|
|
89
|
+
passkey = @user.passkeys.sole
|
|
90
|
+
|
|
91
|
+
patch passkey_path(passkey), params: {passkey: {name: "Work laptop"}}
|
|
92
|
+
assert_redirected_to passkeys_path
|
|
93
|
+
assert_equal "Work laptop", passkey.reload.name
|
|
94
|
+
|
|
95
|
+
delete passkey_path(passkey)
|
|
96
|
+
assert_redirected_to passkeys_path
|
|
97
|
+
assert_empty @user.passkeys.reload
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Passkeys in integration tests (generated by maquina:passkeys).
|
|
2
|
+
#
|
|
3
|
+
# WebAuthn::FakeClient plays the browser and the authenticator, so tests go
|
|
4
|
+
# through the real endpoints: register_passkey needs a signed-in user
|
|
5
|
+
# (sign_in_as), sign_in_with_passkey needs a passkey the same client made.
|
|
6
|
+
#
|
|
7
|
+
# sign_in_as users(:one)
|
|
8
|
+
# register_passkey
|
|
9
|
+
# sign_out
|
|
10
|
+
# sign_in_with_passkey(users(:one))
|
|
11
|
+
# assert_response :success
|
|
12
|
+
require "webauthn/fake_client"
|
|
13
|
+
|
|
14
|
+
module WebauthnTestHelper
|
|
15
|
+
def webauthn_client
|
|
16
|
+
@webauthn_client ||= WebAuthn::FakeClient.new(WebAuthn.configuration.allowed_origins.first)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def register_passkey(client: webauthn_client)
|
|
20
|
+
post passkey_challenge_path, as: :json
|
|
21
|
+
credential = client.create(challenge: response.parsed_body["challenge"], rp_id: WebAuthn.configuration.rp_id,
|
|
22
|
+
user_verified: true)
|
|
23
|
+
|
|
24
|
+
post passkeys_path, params: {credential: credential}, as: :json
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sign_in_with_passkey(user, client: webauthn_client)
|
|
28
|
+
post session_passkey_challenge_path, as: :json
|
|
29
|
+
assertion = client.get(challenge: response.parsed_body["challenge"], rp_id: WebAuthn.configuration.rp_id,
|
|
30
|
+
user_verified: true, user_handle: Base64.urlsafe_decode64(user.reload.webauthn_id))
|
|
31
|
+
|
|
32
|
+
post session_passkey_path, params: {credential: assertion}, as: :json
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
ActiveSupport.on_load(:action_dispatch_integration_test) do
|
|
37
|
+
include WebauthnTestHelper
|
|
38
|
+
end
|