mirror of
https://github.com/System-End/identity-vault.git
synced 2026-04-19 16:28:21 +00:00
parent
820983b4fc
commit
5562fe2c06
33 changed files with 1493 additions and 327 deletions
2
Gemfile
2
Gemfile
|
|
@ -140,6 +140,8 @@ gem "geocoder", "~> 1.8"
|
|||
gem "rotp", "~> 6.3"
|
||||
gem "rqrcode", "~> 2.0"
|
||||
|
||||
gem "webauthn", "~> 3.1"
|
||||
|
||||
gem "bcrypt", "~> 3.1"
|
||||
|
||||
gem "rack-attack", "~> 6.7"
|
||||
|
|
|
|||
404
Gemfile.lock
404
Gemfile.lock
|
|
@ -9,31 +9,31 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
aasm (5.5.0)
|
||||
aasm (5.5.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
actioncable (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actioncable (8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailbox (8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
activejob (= 8.0.4)
|
||||
activerecord (= 8.0.4)
|
||||
activestorage (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionmailer (8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
actionview (= 8.0.4)
|
||||
activejob (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionpack (8.0.4)
|
||||
actionview (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
|
|
@ -41,15 +41,15 @@ GEM
|
|||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actiontext (8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
activerecord (= 8.0.4)
|
||||
activestorage (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
actionview (8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
|
|
@ -59,22 +59,22 @@ GEM
|
|||
block_cipher_kit (>= 0.0.4)
|
||||
rails (>= 7.2.2.1)
|
||||
serve_byte_range (~> 1.0)
|
||||
activejob (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activejob (8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activerecord (8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activemodel (8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
activerecord (8.0.4)
|
||||
activemodel (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
activestorage (8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
activejob (= 8.0.4)
|
||||
activerecord (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.0.2)
|
||||
activesupport (8.0.4)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
|
|
@ -90,13 +90,14 @@ GEM
|
|||
acts_as_paranoid (0.10.3)
|
||||
activerecord (>= 6.1, < 8.1)
|
||||
activesupport (>= 6.1, < 8.1)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
ahoy_matey (5.4.1)
|
||||
activesupport (>= 7.1)
|
||||
device_detector (>= 1)
|
||||
safely_block (>= 0.4)
|
||||
annotaterb (4.19.0)
|
||||
android_key_attestation (0.3.0)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
argon2-kdf (0.3.1)
|
||||
|
|
@ -109,45 +110,48 @@ GEM
|
|||
turbo-rails
|
||||
awesome_print (1.9.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1110.0)
|
||||
aws-sdk-core (3.225.0)
|
||||
aws-partitions (1.1203.0)
|
||||
aws-sdk-core (3.241.3)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.102.0)
|
||||
aws-sdk-core (~> 3, >= 3.225.0)
|
||||
aws-sdk-kms (1.120.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.3)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.189.0)
|
||||
aws-sdk-core (~> 3, >= 3.225.0)
|
||||
aws-sdk-s3 (1.211.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.3)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.0)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.0)
|
||||
better_html (2.1.1)
|
||||
actionview (>= 6.0)
|
||||
activesupport (>= 6.0)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.21)
|
||||
benchmark (0.5.0)
|
||||
better_html (2.2.0)
|
||||
actionview (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
ast (~> 2.0)
|
||||
erubi (~> 1.4)
|
||||
parser (>= 2.4)
|
||||
smart_properties
|
||||
bigdecimal (3.1.9)
|
||||
bigdecimal (4.0.1)
|
||||
bindata (2.5.1)
|
||||
bindex (0.8.1)
|
||||
blind_index (2.7.0)
|
||||
activesupport (>= 7.1)
|
||||
argon2-kdf (>= 0.2)
|
||||
block_cipher_kit (0.0.4)
|
||||
bootsnap (1.18.6)
|
||||
bootsnap (1.21.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.1.2)
|
||||
racc
|
||||
browser (6.2.0)
|
||||
builder (3.3.0)
|
||||
cbor (0.5.10.1)
|
||||
chartkick (5.2.1)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
|
|
@ -157,21 +161,24 @@ GEM
|
|||
activesupport (>= 7.2.0, < 8.2.0)
|
||||
railties (>= 7.2.0, < 8.2.0)
|
||||
zeitwerk (>= 2.5.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (3.0.2)
|
||||
console1984 (0.2.2)
|
||||
irb (~> 1.13)
|
||||
parser
|
||||
rails (>= 7.0)
|
||||
rainbow
|
||||
cose (1.3.1)
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
countries (7.1.1)
|
||||
unaccent (~> 0.3)
|
||||
crass (1.0.6)
|
||||
css_parser (1.21.1)
|
||||
addressable
|
||||
csv (3.3.5)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
date (3.5.1)
|
||||
debug (1.11.1)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
device_detector (1.1.3)
|
||||
|
|
@ -183,10 +190,10 @@ GEM
|
|||
doorkeeper (>= 5.5, < 5.9)
|
||||
jwt (>= 2.5)
|
||||
ostruct (>= 0.5)
|
||||
dotenv (3.1.8)
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
dry-cli (1.2.0)
|
||||
erb (5.0.1)
|
||||
dry-cli (1.4.0)
|
||||
erb (6.0.1)
|
||||
erb_lint (0.9.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
|
|
@ -195,60 +202,60 @@ GEM
|
|||
rubocop (>= 1)
|
||||
smart_properties
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
factory_bot (6.5.6)
|
||||
activesupport (>= 6.1.0)
|
||||
factory_bot_rails (6.5.1)
|
||||
factory_bot (~> 6.5)
|
||||
railties (>= 6.1.0)
|
||||
faraday (2.13.1)
|
||||
faraday (2.14.0)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-mashify (1.0.0)
|
||||
faraday-mashify (1.0.2)
|
||||
faraday (~> 2.0)
|
||||
hashie
|
||||
faraday-multipart (1.1.0)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
ffi (1.17.2-arm-linux-gnu)
|
||||
ffi (1.17.2-arm-linux-musl)
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86_64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
ffi (1.17.2-x86_64-linux-musl)
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
ffi (1.17.3-aarch64-linux-gnu)
|
||||
ffi (1.17.3-aarch64-linux-musl)
|
||||
ffi (1.17.3-arm-linux-gnu)
|
||||
ffi (1.17.3-arm-linux-musl)
|
||||
ffi (1.17.3-arm64-darwin)
|
||||
ffi (1.17.3-x86_64-darwin)
|
||||
ffi (1.17.3-x86_64-linux-gnu)
|
||||
ffi (1.17.3-x86_64-linux-musl)
|
||||
ffi-compiler (1.3.2)
|
||||
ffi (>= 1.15.5)
|
||||
rake
|
||||
fiddle (1.1.8)
|
||||
flipper (1.3.5)
|
||||
flipper (1.3.6)
|
||||
concurrent-ruby (< 2)
|
||||
flipper-active_record (1.3.5)
|
||||
flipper-active_record (1.3.6)
|
||||
activerecord (>= 4.2, < 9)
|
||||
flipper (~> 1.3.5)
|
||||
flipper-ui (1.3.5)
|
||||
flipper (~> 1.3.6)
|
||||
flipper-ui (1.3.6)
|
||||
erubi (>= 1.0.0, < 2.0.0)
|
||||
flipper (~> 1.3.5)
|
||||
flipper (~> 1.3.6)
|
||||
rack (>= 1.4, < 4)
|
||||
rack-protection (>= 1.5.3, < 5.0.0)
|
||||
rack-session (>= 1.0.2, < 3.0.0)
|
||||
sanitize (< 8)
|
||||
front_matter_parser (1.0.1)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
fugit (1.12.1)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
geocoder (1.8.6)
|
||||
base64 (>= 0.1.0)
|
||||
csv (>= 3.0.0)
|
||||
gli (2.22.2)
|
||||
ostruct
|
||||
globalid (1.2.1)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.10.2)
|
||||
good_job (4.13.1)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
|
|
@ -261,30 +268,30 @@ GEM
|
|||
activerecord (>= 4.0)
|
||||
hashids (~> 1.0)
|
||||
hashids (1.0.6)
|
||||
hashie (5.0.0)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
htmlentities (4.4.2)
|
||||
http (5.2.0)
|
||||
http (5.3.1)
|
||||
addressable (~> 2.8)
|
||||
base64 (~> 0.1)
|
||||
http-cookie (~> 1.0)
|
||||
http-form_data (~> 2.2)
|
||||
llhttp-ffi (~> 0.5.0)
|
||||
http-cookie (1.0.8)
|
||||
http-cookie (1.1.0)
|
||||
domain_name (~> 0.5)
|
||||
http-form_data (2.3.0)
|
||||
i18n (1.14.7)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
io-console (0.8.0)
|
||||
irb (1.15.2)
|
||||
io-console (0.8.2)
|
||||
irb (1.16.0)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jb (0.8.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.12.0)
|
||||
json (2.18.0)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
|
|
@ -312,35 +319,36 @@ GEM
|
|||
railties (>= 6.1)
|
||||
rexml
|
||||
lint_roller (1.1.0)
|
||||
literal (1.7.1)
|
||||
literal (1.8.1)
|
||||
zeitwerk
|
||||
llhttp-ffi (0.5.1)
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
lockbox (2.0.1)
|
||||
lockbox (2.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.24.1)
|
||||
loofah (2.25.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
lz_string (0.3.0)
|
||||
mail (2.8.1)
|
||||
mail (2.9.0)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
marcel (1.1.0)
|
||||
mini-levenshtein (0.1.2)
|
||||
mini_magick (5.2.0)
|
||||
benchmark
|
||||
mini_magick (5.3.1)
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.5)
|
||||
minitest (6.0.1)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.8)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.6.2)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
|
|
@ -350,40 +358,49 @@ GEM
|
|||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.18.8-aarch64-linux-gnu)
|
||||
nokogiri (1.19.0-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-aarch64-linux-musl)
|
||||
nokogiri (1.19.0-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-arm-linux-gnu)
|
||||
nokogiri (1.19.0-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-arm-linux-musl)
|
||||
nokogiri (1.19.0-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-arm64-darwin)
|
||||
nokogiri (1.19.0-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-darwin)
|
||||
nokogiri (1.19.0-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||
nokogiri (1.19.0-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-linux-musl)
|
||||
nokogiri (1.19.0-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri-xmlsec-instructure (0.12.0)
|
||||
nokogiri (~> 1.13)
|
||||
openssl (3.3.2)
|
||||
ostruct (0.6.1)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
ostruct (0.6.3)
|
||||
paper_trail (16.0.0)
|
||||
activerecord (>= 6.1)
|
||||
request_store (~> 1.4)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
parser (3.3.10.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
phlex (2.2.1)
|
||||
pg (1.6.3)
|
||||
pg (1.6.3-aarch64-linux)
|
||||
pg (1.6.3-aarch64-linux-musl)
|
||||
pg (1.6.3-arm64-darwin)
|
||||
pg (1.6.3-x86_64-darwin)
|
||||
pg (1.6.3-x86_64-linux)
|
||||
pg (1.6.3-x86_64-linux-musl)
|
||||
phlex (2.3.1)
|
||||
zeitwerk (~> 2.7)
|
||||
phlex-rails (2.2.0)
|
||||
phlex (~> 2.2.1)
|
||||
phlex-rails (2.3.1)
|
||||
phlex (~> 2.3.0)
|
||||
railties (>= 7.1, < 9)
|
||||
pp (0.6.2)
|
||||
zeitwerk (~> 2.7)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
premailer (1.27.0)
|
||||
addressable
|
||||
|
|
@ -394,31 +411,30 @@ GEM
|
|||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
propshaft (1.1.0)
|
||||
prism (1.8.0)
|
||||
propshaft (1.3.1)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.2.6)
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
public_activity (3.0.1)
|
||||
actionpack (>= 6.1.0)
|
||||
public_activity (3.0.2)
|
||||
actionpack (>= 6.1)
|
||||
activerecord (>= 6.1)
|
||||
i18n (>= 0.5.0)
|
||||
railties (>= 6.1.0)
|
||||
public_suffix (6.0.2)
|
||||
railties (>= 6.1)
|
||||
public_suffix (7.0.2)
|
||||
puma (7.1.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.0)
|
||||
pundit (2.5.2)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.15)
|
||||
rack-attack (6.7.0)
|
||||
rack (3.2.4)
|
||||
rack-attack (6.8.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-protection (4.1.1)
|
||||
rack-protection (4.2.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
|
|
@ -429,22 +445,22 @@ GEM
|
|||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.0.2)
|
||||
actioncable (= 8.0.2)
|
||||
actionmailbox (= 8.0.2)
|
||||
actionmailer (= 8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
actiontext (= 8.0.2)
|
||||
actionview (= 8.0.2)
|
||||
activejob (= 8.0.2)
|
||||
activemodel (= 8.0.2)
|
||||
activerecord (= 8.0.2)
|
||||
activestorage (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
rails (8.0.4)
|
||||
actioncable (= 8.0.4)
|
||||
actionmailbox (= 8.0.4)
|
||||
actionmailer (= 8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
actiontext (= 8.0.4)
|
||||
actionview (= 8.0.4)
|
||||
activejob (= 8.0.4)
|
||||
activemodel (= 8.0.4)
|
||||
activerecord (= 8.0.4)
|
||||
activestorage (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2)
|
||||
railties (= 8.0.4)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
|
|
@ -452,33 +468,35 @@ GEM
|
|||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails_semantic_logger (4.17.0)
|
||||
rails_semantic_logger (4.19.0)
|
||||
rack
|
||||
railties (>= 5.1)
|
||||
semantic_logger (~> 4.16)
|
||||
railties (8.0.2)
|
||||
actionpack (= 8.0.2)
|
||||
activesupport (= 8.0.2)
|
||||
railties (8.0.4)
|
||||
actionpack (= 8.0.4)
|
||||
activesupport (= 8.0.4)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rdoc (6.14.0)
|
||||
rake (13.3.1)
|
||||
rdoc (7.1.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redcarpet (3.6.1)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.1)
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.7.0)
|
||||
rack (>= 1.4)
|
||||
rexml (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rinku (2.0.6)
|
||||
rotp (6.3.0)
|
||||
rouge (4.5.2)
|
||||
rouge (4.7.0)
|
||||
rqrcode (2.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
|
|
@ -488,7 +506,7 @@ GEM
|
|||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.6)
|
||||
rspec-mocks (3.13.7)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (7.1.1)
|
||||
|
|
@ -500,7 +518,7 @@ GEM
|
|||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.6)
|
||||
rubocop (1.75.7)
|
||||
rubocop (1.82.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
|
@ -508,17 +526,17 @@ GEM
|
|||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-ast (>= 1.48.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.44.1)
|
||||
rubocop-ast (1.49.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-performance (1.25.0)
|
||||
prism (~> 1.7)
|
||||
rubocop-performance (1.26.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rails (2.32.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.34.3)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
|
|
@ -529,11 +547,13 @@ GEM
|
|||
rubocop-performance (>= 1.24)
|
||||
rubocop-rails (>= 2.30)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.5)
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
safely_block (0.5.0)
|
||||
saml2 (3.2.3)
|
||||
safety_net_attestation (0.5.0)
|
||||
jwt (>= 2.0, < 4.0)
|
||||
saml2 (3.3.0)
|
||||
activesupport (>= 3.2, < 8.2)
|
||||
nokogiri (>= 1.5.8, < 2.0)
|
||||
nokogiri-xmlsec-instructure (~> 0.9, >= 0.9.5)
|
||||
|
|
@ -541,7 +561,7 @@ GEM
|
|||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.16.8)
|
||||
securerandom (0.4.1)
|
||||
semantic_logger (4.16.1)
|
||||
semantic_logger (4.17.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
sentry-rails (5.28.1)
|
||||
railties (>= 5.0)
|
||||
|
|
@ -551,8 +571,8 @@ GEM
|
|||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
serve_byte_range (1.0.0)
|
||||
rack (>= 1.0)
|
||||
slack-ruby-client (2.6.0)
|
||||
faraday (>= 2.0)
|
||||
slack-ruby-client (2.7.0)
|
||||
faraday (>= 2.0.1)
|
||||
faraday-mashify
|
||||
faraday-multipart
|
||||
gli
|
||||
|
|
@ -562,29 +582,34 @@ GEM
|
|||
actionview (>= 6.0)
|
||||
activesupport (>= 6.0)
|
||||
smart_properties (1.17.0)
|
||||
stringio (3.1.7)
|
||||
stringio (3.2.0)
|
||||
superform (0.5.1)
|
||||
phlex-rails (>= 1.0, < 3.0)
|
||||
zeitwerk (~> 2.6)
|
||||
thor (1.3.2)
|
||||
thruster (0.1.13)
|
||||
thruster (0.1.13-aarch64-linux)
|
||||
thruster (0.1.13-arm64-darwin)
|
||||
thruster (0.1.13-x86_64-darwin)
|
||||
thruster (0.1.13-x86_64-linux)
|
||||
timeout (0.4.3)
|
||||
turbo-rails (2.0.16)
|
||||
thor (1.5.0)
|
||||
thruster (0.1.17)
|
||||
thruster (0.1.17-aarch64-linux)
|
||||
thruster (0.1.17-arm64-darwin)
|
||||
thruster (0.1.17-x86_64-darwin)
|
||||
thruster (0.1.17-x86_64-linux)
|
||||
timeout (0.6.0)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
openssl (> 2.0)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.20)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unaccent (0.4.0)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.3)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
vite_rails (3.0.19)
|
||||
vite_rails (3.0.20)
|
||||
railties (>= 5.1, < 9)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
vite_ruby (3.9.2)
|
||||
|
|
@ -598,13 +623,21 @@ GEM
|
|||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
websocket-driver (0.7.7)
|
||||
webauthn (3.4.3)
|
||||
android_key_attestation (~> 0.3.0)
|
||||
bindata (~> 2.4)
|
||||
cbor (~> 0.5.9)
|
||||
cose (~> 1.1)
|
||||
openssl (>= 2.2)
|
||||
safety_net_attestation (~> 0.5.0)
|
||||
tpm-key_attestation (~> 0.14.0)
|
||||
websocket-driver (0.8.0)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
wicked (2.0.0)
|
||||
railties (>= 3.0.7)
|
||||
zeitwerk (2.7.3)
|
||||
zeitwerk (2.7.4)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
|
|
@ -691,6 +724,7 @@ DEPENDENCIES
|
|||
valid_email2!
|
||||
vite_rails
|
||||
web-console
|
||||
webauthn (~> 3.1)
|
||||
wicked (~> 2.0)
|
||||
|
||||
BUNDLED WITH
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ so is the onboarding controller, she should really be ripped out and replaced.
|
|||
|
||||
- make sure you have working installations of ruby ≥ 3.4.4 & nodejs
|
||||
- clone repo
|
||||
- create .env.development, populate `DATABASE_URL` w/ a local postgres instance
|
||||
- create .env.development, populate `DATABASE_URL` w/ a local postgres instance and `LOCKBOX_MASTER_KEY` with the value of `openssl rand -hex 32`
|
||||
- if you want to use docker, you can run `docker compose -f docker-compose-dbonly.yml up` to spin up a database and plug `postgresql://postgres@localhost:5432/identity_vault_development` in as your `DATABASE_URL`
|
||||
- if you don't have docker and are on macOS, [orbstack](https://orbstack.dev) may be helpful
|
||||
- run `bundle install`
|
||||
- run `bin/rails db:prepare`
|
||||
- console in (`bin/rails console`)
|
||||
|
|
|
|||
62
app/controllers/concerns/webauthn_authenticatable.rb
Normal file
62
app/controllers/concerns/webauthn_authenticatable.rb
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
module WebauthnAuthenticatable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def generate_webauthn_authentication_options(identity, session_key:, user_verification: "required")
|
||||
credentials = identity.webauthn_credentials.active.pluck(:external_id).map { |id| Base64.urlsafe_decode64(id) }
|
||||
|
||||
options = WebAuthn::Credential.options_for_get(
|
||||
allow: credentials,
|
||||
user_verification: user_verification
|
||||
)
|
||||
|
||||
session[session_key] = options.challenge
|
||||
Rails.logger.info "WebAuthn options generated: session_key=#{session_key}"
|
||||
options
|
||||
end
|
||||
|
||||
def verify_webauthn_credential(identity, credential_data:, session_key:)
|
||||
webauthn_credential = WebAuthn::Credential.from_get(credential_data)
|
||||
|
||||
# Delete challenge immediately to prevent replay attacks (single-use)
|
||||
stored_challenge = session.delete(session_key)
|
||||
Rails.logger.info "WebAuthn verify: session_key=#{session_key}, challenge_present=#{stored_challenge.present?}"
|
||||
|
||||
return nil unless stored_challenge.present?
|
||||
|
||||
Identity::WebauthnCredential.transaction do
|
||||
credential = identity.webauthn_credentials.active.lock.find_by(
|
||||
external_id: Base64.urlsafe_encode64(webauthn_credential.id, padding: false)
|
||||
)
|
||||
|
||||
return nil unless credential
|
||||
|
||||
begin
|
||||
webauthn_credential.verify(
|
||||
stored_challenge,
|
||||
public_key: credential.webauthn_public_key,
|
||||
sign_count: credential.sign_count
|
||||
)
|
||||
|
||||
credential.update!(sign_count: webauthn_credential.sign_count)
|
||||
credential.touch unless credential.saved_change_to_sign_count?
|
||||
|
||||
credential
|
||||
rescue WebAuthn::SignCountVerificationError => e
|
||||
Rails.logger.warn "WebAuthn sign count anomaly detected: credential_id=#{credential.id}, identity_id=#{identity.id}"
|
||||
credential.mark_as_compromised!
|
||||
raise WebauthnCredentialCompromisedError.new(credential)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class WebauthnCredentialCompromisedError < StandardError
|
||||
attr_reader :credential
|
||||
|
||||
def initialize(credential)
|
||||
@credential = credential
|
||||
super("Passkey may be compromised due to sign count anomaly")
|
||||
end
|
||||
end
|
||||
92
app/controllers/identity_webauthn_credentials_controller.rb
Normal file
92
app/controllers/identity_webauthn_credentials_controller.rb
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
class IdentityWebauthnCredentialsController < ApplicationController
|
||||
include WebauthnAuthenticatable
|
||||
|
||||
before_action :require_step_up_for_destroy, only: [ :destroy ]
|
||||
|
||||
def index
|
||||
@webauthn_credentials = current_identity.webauthn_credentials.order(created_at: :desc)
|
||||
render layout: request.headers["HX-Request"] ? "htmx" : false
|
||||
end
|
||||
|
||||
def new
|
||||
render layout: request.headers["HX-Request"] ? "htmx" : false
|
||||
end
|
||||
|
||||
def options
|
||||
challenge = WebAuthn::Credential.options_for_create(
|
||||
user: {
|
||||
id: current_identity.webauthn_user_id,
|
||||
name: current_identity.primary_email,
|
||||
display_name: "#{current_identity.first_name} #{current_identity.last_name}"
|
||||
},
|
||||
exclude: current_identity.webauthn_credentials.raw_credential_ids,
|
||||
authenticator_selection: {
|
||||
user_verification: "required",
|
||||
resident_key: "preferred"
|
||||
}
|
||||
)
|
||||
|
||||
session[:webauthn_registration_challenge] = challenge.challenge
|
||||
|
||||
render json: challenge
|
||||
end
|
||||
|
||||
def create
|
||||
# Delete challenge immediately to prevent replay attacks (single-use)
|
||||
stored_challenge = session.delete(:webauthn_registration_challenge)
|
||||
|
||||
unless stored_challenge.present?
|
||||
flash[:error] = "Registration session expired. Please try again."
|
||||
redirect_to security_path and return
|
||||
end
|
||||
|
||||
begin
|
||||
credential_data = JSON.parse(params[:credential_data])
|
||||
nickname = params[:nickname]
|
||||
|
||||
webauthn_credential = WebAuthn::Credential.from_create(credential_data)
|
||||
|
||||
webauthn_credential.verify(stored_challenge)
|
||||
|
||||
credential = current_identity.webauthn_credentials.create!(
|
||||
webauthn_id: webauthn_credential.id,
|
||||
webauthn_public_key: webauthn_credential.public_key,
|
||||
nickname: nickname.presence,
|
||||
sign_count: webauthn_credential.sign_count
|
||||
)
|
||||
|
||||
flash[:success] = t(".successfully_added")
|
||||
redirect_to security_path
|
||||
rescue WebAuthn::Error => e
|
||||
Rails.logger.error "WebAuthn registration error: credential creation failed"
|
||||
flash[:error] = "Passkey registration failed. Please try again."
|
||||
render :new, status: :unprocessable_entity
|
||||
rescue => e
|
||||
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class.name}"
|
||||
flash[:error] = "An unexpected error occurred. Please try again."
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
credential = current_identity.webauthn_credentials.find(params[:id])
|
||||
credential.destroy
|
||||
|
||||
consume_step_up!
|
||||
|
||||
flash[:success] = t(".successfully_removed")
|
||||
redirect_to security_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_step_up_for_destroy
|
||||
return if current_session.step_up_valid_for?(action: "remove_passkey")
|
||||
|
||||
session[:pending_destroy_credential_id] = params[:id]
|
||||
redirect_to new_step_up_path(
|
||||
action_type: "remove_passkey",
|
||||
return_to: identity_webauthn_credential_path(params[:id])
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -3,6 +3,9 @@ class LoginsController < ApplicationController
|
|||
include SAMLHelper
|
||||
include SafeUrlValidation
|
||||
include AhoyAnalytics
|
||||
include WebauthnAuthenticatable
|
||||
|
||||
WEBAUTHN_SESSION_KEY = :webauthn_authentication_challenge
|
||||
|
||||
skip_before_action :authenticate_identity!
|
||||
before_action :set_return_to, only: [ :new, :create ]
|
||||
|
|
@ -44,9 +47,13 @@ class LoginsController < ApplicationController
|
|||
same_site: :lax
|
||||
}
|
||||
|
||||
send_v2_login_code(identity, attempt)
|
||||
track_event("login.code_sent", is_signup: attempt.provenance == "signup", scenario: analytics_scenario_from_return_to(@return_to))
|
||||
redirect_to login_attempt_path(id: attempt.to_param), status: :see_other
|
||||
if identity.webauthn_enabled?
|
||||
redirect_to webauthn_login_attempt_path(id: attempt.to_param), status: :see_other
|
||||
else
|
||||
send_v2_login_code(identity, attempt)
|
||||
track_event("login.code_sent", is_signup: attempt.provenance == "signup", scenario: analytics_scenario_from_return_to(@return_to))
|
||||
redirect_to login_attempt_path(id: attempt.to_param), status: :see_other
|
||||
end
|
||||
rescue => e
|
||||
flash[:error] = e.message
|
||||
redirect_to login_path(return_to: @return_to)
|
||||
|
|
@ -167,6 +174,57 @@ class LoginsController < ApplicationController
|
|||
handle_post_verification_redirect
|
||||
end
|
||||
|
||||
def webauthn
|
||||
render status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def skip_webauthn
|
||||
# the user wants to skip using a passkey, use email code instead
|
||||
send_v2_login_code(@identity, @attempt)
|
||||
redirect_to login_attempt_path(id: @attempt.to_param), status: :see_other
|
||||
end
|
||||
|
||||
def webauthn_options
|
||||
options = generate_webauthn_authentication_options(
|
||||
@identity,
|
||||
session_key: WEBAUTHN_SESSION_KEY,
|
||||
user_verification: "preferred"
|
||||
)
|
||||
render json: options
|
||||
end
|
||||
|
||||
def verify_webauthn
|
||||
flash.clear
|
||||
|
||||
credential_data = JSON.parse(params[:credential_data])
|
||||
|
||||
credential = verify_webauthn_credential(
|
||||
@identity,
|
||||
credential_data: credential_data,
|
||||
session_key: WEBAUTHN_SESSION_KEY
|
||||
)
|
||||
|
||||
unless credential
|
||||
flash.now[:error] = "Passkey not found"
|
||||
render :webauthn, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
factors = (@attempt.authentication_factors || {}).dup
|
||||
factors[:webauthn] = true
|
||||
@attempt.update!(authentication_factors: factors)
|
||||
|
||||
handle_post_verification_redirect
|
||||
rescue WebAuthn::Error => e
|
||||
Rails.logger.error "WebAuthn authentication error: #{e.message}"
|
||||
flash.now[:error] = "Passkey verification failed. Please try again or use email code."
|
||||
render :webauthn, status: :unprocessable_entity
|
||||
rescue => e
|
||||
Rails.logger.error "Unexpected WebAuthn error: #{e.message}"
|
||||
flash.now[:error] = "An unexpected error occurred. Please try again."
|
||||
render :webauthn, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_attempt
|
||||
|
|
@ -331,6 +389,8 @@ class LoginsController < ApplicationController
|
|||
|
||||
if available.include?(:totp)
|
||||
redirect_to totp_login_attempt_path(id: @attempt.to_param), status: :see_other
|
||||
elsif available.include?(:webauthn)
|
||||
redirect_to webauthn_login_attempt_path(id: @attempt.to_param), status: :see_other
|
||||
elsif available.include?(:backup_code)
|
||||
redirect_to backup_code_login_attempt_path(id: @attempt.to_param), status: :see_other
|
||||
else
|
||||
|
|
|
|||
|
|
@ -1,16 +1,60 @@
|
|||
class StepUpController < ApplicationController
|
||||
include WebauthnAuthenticatable
|
||||
|
||||
helper_method :step_up_cancel_path
|
||||
|
||||
WEBAUTHN_SESSION_KEY = :step_up_webauthn_challenge
|
||||
ACTIONS_WITHOUT_EMAIL_FALLBACK = %w[email_change disable_2fa remove_passkey].freeze
|
||||
|
||||
def new
|
||||
@action = params[:action_type] # e.g., "remove_totp", "disable_2fa", "oidc_reauth", "email_change"
|
||||
@return_to = params[:return_to]
|
||||
@available_methods = current_identity.available_step_up_methods
|
||||
@available_methods << :email unless @action == "email_change" # Email fallback not available for email change (already verifying old email)
|
||||
@available_methods << :email unless @action.in?(ACTIONS_WITHOUT_EMAIL_FALLBACK)
|
||||
@code_sent = params[:code_sent].present?
|
||||
end
|
||||
|
||||
def webauthn_options
|
||||
options = generate_webauthn_authentication_options(
|
||||
current_identity,
|
||||
session_key: WEBAUTHN_SESSION_KEY,
|
||||
user_verification: "required"
|
||||
)
|
||||
render json: options
|
||||
end
|
||||
|
||||
def verify_webauthn
|
||||
credential_data = JSON.parse(params[:credential_data])
|
||||
|
||||
credential = verify_webauthn_credential(
|
||||
current_identity,
|
||||
credential_data: credential_data,
|
||||
session_key: WEBAUTHN_SESSION_KEY
|
||||
)
|
||||
|
||||
unless credential
|
||||
flash[:error] = "Passkey not found"
|
||||
redirect_to new_step_up_path(action_type: params[:action_type], return_to: params[:return_to])
|
||||
return
|
||||
end
|
||||
|
||||
complete_step_up(params[:action_type], params[:return_to])
|
||||
rescue WebauthnCredentialCompromisedError => e
|
||||
Rails.logger.warn "Step-up blocked: compromised credential detected for identity #{current_identity.id}"
|
||||
flash[:error] = "Security issue detected with your passkey. It has been disabled for your protection. Please use another verification method or register a new passkey."
|
||||
redirect_to new_step_up_path(action_type: params[:action_type], return_to: params[:return_to])
|
||||
rescue WebAuthn::Error => e
|
||||
Rails.logger.error "Step-up WebAuthn error: verification failed"
|
||||
flash[:error] = "Passkey verification failed. Please try again."
|
||||
redirect_to new_step_up_path(action_type: params[:action_type], method: :webauthn, return_to: params[:return_to])
|
||||
rescue => e
|
||||
Rails.logger.error "Unexpected step-up WebAuthn error: #{e.class.name}"
|
||||
flash[:error] = "An unexpected error occurred. Please try again."
|
||||
redirect_to new_step_up_path(action_type: params[:action_type], return_to: params[:return_to])
|
||||
end
|
||||
|
||||
def send_email_code
|
||||
if params[:action_type] == "email_change"
|
||||
if params[:action_type].in?(ACTIONS_WITHOUT_EMAIL_FALLBACK)
|
||||
flash[:error] = "Email verification is not available for this action"
|
||||
redirect_to new_step_up_path(action_type: params[:action_type], return_to: params[:return_to])
|
||||
return
|
||||
|
|
@ -37,13 +81,12 @@ class StepUpController < ApplicationController
|
|||
return
|
||||
end
|
||||
|
||||
if action_type == "email_change" && method == :email
|
||||
if action_type.in?(ACTIONS_WITHOUT_EMAIL_FALLBACK) && method == :email
|
||||
flash[:error] = "Email verification is not available for this action"
|
||||
redirect_to new_step_up_path(action_type: action_type, return_to: params[:return_to])
|
||||
return
|
||||
end
|
||||
|
||||
# Verify based on the method they chose
|
||||
verified = case method
|
||||
when :totp
|
||||
totp = current_identity.totp
|
||||
|
|
@ -76,43 +119,7 @@ class StepUpController < ApplicationController
|
|||
return
|
||||
end
|
||||
|
||||
# Mark step-up as completed on the identity session, bound to the specific action
|
||||
current_session.record_step_up!(action: action_type)
|
||||
|
||||
# Execute the verified action
|
||||
case action_type
|
||||
when "remove_totp"
|
||||
totp = current_identity.totp
|
||||
totp&.destroy
|
||||
TwoFactorMailer.authentication_method_disabled(current_identity).deliver_later
|
||||
|
||||
if current_identity.two_factor_methods.empty?
|
||||
current_identity.update!(use_two_factor_authentication: false)
|
||||
current_identity.backup_codes.active.each(&:mark_discarded!)
|
||||
end
|
||||
|
||||
consume_step_up!
|
||||
redirect_to security_path, notice: "Two-factor authentication disabled"
|
||||
|
||||
when "disable_2fa"
|
||||
current_identity.update!(use_two_factor_authentication: false)
|
||||
TwoFactorMailer.required_authentication_disabled(current_identity).deliver_later
|
||||
consume_step_up!
|
||||
redirect_to security_path, notice: "2FA requirement disabled"
|
||||
|
||||
when "oidc_reauth"
|
||||
# OIDC re-authentication completed, redirect back to OAuth flow
|
||||
safe_path = safe_internal_redirect(params[:return_to])
|
||||
redirect_to safe_path || root_path
|
||||
|
||||
when "email_change"
|
||||
# Email change step-up completed, redirect to the email change form
|
||||
safe_path = safe_internal_redirect(params[:return_to])
|
||||
redirect_to safe_path || new_email_change_path
|
||||
|
||||
else
|
||||
redirect_to security_path, alert: "Unknown action"
|
||||
end
|
||||
complete_step_up(action_type, params[:return_to])
|
||||
end
|
||||
|
||||
def resend_email
|
||||
|
|
@ -134,6 +141,50 @@ class StepUpController < ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def complete_step_up(action_type, return_to)
|
||||
current_session.record_step_up!(action: action_type)
|
||||
|
||||
case action_type
|
||||
when "remove_totp"
|
||||
totp = current_identity.totp
|
||||
totp&.destroy
|
||||
TwoFactorMailer.authentication_method_disabled(current_identity).deliver_later
|
||||
|
||||
if current_identity.two_factor_methods.empty?
|
||||
current_identity.update!(use_two_factor_authentication: false)
|
||||
current_identity.backup_codes.active.each(&:mark_discarded!)
|
||||
end
|
||||
|
||||
consume_step_up!
|
||||
redirect_to security_path, notice: "Two-factor authentication disabled"
|
||||
|
||||
when "disable_2fa"
|
||||
current_identity.update!(use_two_factor_authentication: false)
|
||||
TwoFactorMailer.required_authentication_disabled(current_identity).deliver_later
|
||||
consume_step_up!
|
||||
redirect_to security_path, notice: "2FA requirement disabled"
|
||||
|
||||
when "oidc_reauth"
|
||||
safe_path = safe_internal_redirect(return_to)
|
||||
redirect_to safe_path || root_path
|
||||
|
||||
when "email_change"
|
||||
safe_path = safe_internal_redirect(return_to)
|
||||
redirect_to safe_path || new_email_change_path
|
||||
|
||||
when "remove_passkey"
|
||||
credential_id = session.delete(:pending_destroy_credential_id)
|
||||
if credential_id
|
||||
redirect_to identity_webauthn_credential_path(credential_id), method: :delete
|
||||
else
|
||||
redirect_to security_path
|
||||
end
|
||||
|
||||
else
|
||||
redirect_to security_path, alert: "Unknown action"
|
||||
end
|
||||
end
|
||||
|
||||
def send_step_up_email_code
|
||||
login_code = current_identity.v2_login_codes.create!
|
||||
IdentityMailer.v2_login_code(login_code).deliver_later
|
||||
|
|
@ -148,20 +199,15 @@ class StepUpController < ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
# Prevent open redirect attacks - only allow internal paths
|
||||
def safe_internal_redirect(return_to)
|
||||
return nil if return_to.blank?
|
||||
|
||||
uri = URI.parse(return_to) rescue nil
|
||||
return nil unless uri
|
||||
|
||||
# Reject if it has a scheme or host (absolute URL or protocol-relative like //evil.com)
|
||||
return nil if uri.scheme || uri.host
|
||||
|
||||
# Must be a path starting with /
|
||||
return nil unless uri.path&.start_with?("/")
|
||||
|
||||
# Return just the path + query string
|
||||
[ uri.path, uri.query ].compact.join("?")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -34,4 +34,4 @@ window.copyErrorId = function(element) {
|
|||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,11 @@
|
|||
import Alpine from 'alpinejs'
|
||||
import { webauthnRegister } from './webauthn-registration.js'
|
||||
import { webauthnAuth } from './webauthn-authentication.js'
|
||||
import { stepUpWebauthn } from './webauthn-step-up.js'
|
||||
|
||||
Alpine.data('webauthnRegister', webauthnRegister)
|
||||
Alpine.data('webauthnAuth', webauthnAuth)
|
||||
Alpine.data('stepUpWebauthn', stepUpWebauthn)
|
||||
|
||||
window.Alpine = Alpine
|
||||
Alpine.start()
|
||||
101
app/frontend/js/webauthn-authentication.js
Normal file
101
app/frontend/js/webauthn-authentication.js
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
export function webauthnAuth() {
|
||||
return {
|
||||
loading: false,
|
||||
error: null,
|
||||
browserSupported: true,
|
||||
|
||||
init() {
|
||||
this.browserSupported = !!(
|
||||
globalThis.PublicKeyCredential?.parseRequestOptionsFromJSON &&
|
||||
navigator.credentials?.get
|
||||
);
|
||||
|
||||
if (this.browserSupported) {
|
||||
this.authenticate();
|
||||
}
|
||||
},
|
||||
|
||||
getLoginAttemptId() {
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const loginIndex = pathParts.indexOf('login');
|
||||
if (loginIndex >= 0 && pathParts.length > loginIndex + 1) {
|
||||
return pathParts[loginIndex + 1];
|
||||
}
|
||||
throw new Error('Could not determine login attempt ID');
|
||||
},
|
||||
|
||||
async getAuthenticationOptions() {
|
||||
const loginAttemptId = this.getLoginAttemptId();
|
||||
const response = await fetch(`/login/${loginAttemptId}/webauthn/options`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get authentication options from server');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async authenticate() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const options = await this.getAuthenticationOptions();
|
||||
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options);
|
||||
const credential = await navigator.credentials.get({ publicKey });
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Authentication failed - no credential returned');
|
||||
}
|
||||
|
||||
const response = credential.response;
|
||||
const toBase64Url = (buffer) => {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
};
|
||||
const credentialJSON = {
|
||||
id: credential.id,
|
||||
rawId: toBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: toBase64Url(response.clientDataJSON),
|
||||
authenticatorData: toBase64Url(response.authenticatorData),
|
||||
signature: toBase64Url(response.signature),
|
||||
userHandle: response.userHandle ? toBase64Url(response.userHandle) : null,
|
||||
},
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
};
|
||||
|
||||
const credentialDataField = document.getElementById('credential-data');
|
||||
const form = document.getElementById('webauthn-form');
|
||||
|
||||
if (!credentialDataField || !form) {
|
||||
throw new Error('Form elements not found');
|
||||
}
|
||||
|
||||
credentialDataField.value = JSON.stringify(credentialJSON);
|
||||
form.submit();
|
||||
} catch (error) {
|
||||
console.error('Passkey authentication error:', error);
|
||||
|
||||
if (error.name === 'NotAllowedError') {
|
||||
this.error = 'Authentication was cancelled or not allowed';
|
||||
} else if (error.name === 'InvalidStateError') {
|
||||
this.error = 'No passkey found for this account';
|
||||
} else if (error.name === 'NotSupportedError') {
|
||||
this.error = 'Passkeys are not supported on this device';
|
||||
} else {
|
||||
this.error = error.message || 'An unexpected error occurred';
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
88
app/frontend/js/webauthn-registration.js
Normal file
88
app/frontend/js/webauthn-registration.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
export function webauthnRegister() {
|
||||
return {
|
||||
nickname: '',
|
||||
loading: false,
|
||||
error: null,
|
||||
browserSupported: true,
|
||||
|
||||
init() {
|
||||
this.browserSupported = !!(
|
||||
globalThis.PublicKeyCredential?.parseCreationOptionsFromJSON &&
|
||||
navigator.credentials?.create
|
||||
);
|
||||
},
|
||||
|
||||
async getRegistrationOptions() {
|
||||
const response = await fetch('/identity_webauthn_credentials/options', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get registration options from server');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async register() {
|
||||
if (!this.nickname.trim()) {
|
||||
this.error = 'Please enter a nickname for your passkey';
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const options = await this.getRegistrationOptions();
|
||||
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options);
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Credential creation failed');
|
||||
}
|
||||
|
||||
const response = credential.response;
|
||||
const toBase64Url = (buffer) => {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
};
|
||||
const credentialJSON = {
|
||||
id: credential.id,
|
||||
rawId: toBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: toBase64Url(response.clientDataJSON),
|
||||
attestationObject: toBase64Url(response.attestationObject),
|
||||
},
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
};
|
||||
|
||||
const credentialDataField = document.getElementById('registration-credential-data');
|
||||
const nicknameField = document.getElementById('registration-nickname');
|
||||
const form = document.getElementById('webauthn-registration-form');
|
||||
|
||||
credentialDataField.value = JSON.stringify(credentialJSON);
|
||||
nicknameField.value = this.nickname;
|
||||
form.submit();
|
||||
} catch (error) {
|
||||
console.error('Passkey registration error:', error);
|
||||
if (error.name === 'NotAllowedError') {
|
||||
this.error = 'Registration was cancelled or not allowed';
|
||||
} else if (error.name === 'InvalidStateError') {
|
||||
this.error = 'This passkey is already registered';
|
||||
} else if (error.name === 'NotSupportedError') {
|
||||
this.error = 'Passkeys are not supported on this device';
|
||||
} else {
|
||||
this.error = error.message || 'An unexpected error occurred';
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
86
app/frontend/js/webauthn-step-up.js
Normal file
86
app/frontend/js/webauthn-step-up.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
export function stepUpWebauthn() {
|
||||
return {
|
||||
loading: false,
|
||||
error: null,
|
||||
browserSupported: true,
|
||||
initialized: false,
|
||||
|
||||
init() {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
|
||||
this.browserSupported = !!(
|
||||
globalThis.PublicKeyCredential?.parseRequestOptionsFromJSON &&
|
||||
navigator.credentials?.get
|
||||
);
|
||||
|
||||
if (this.browserSupported) {
|
||||
this.authenticate();
|
||||
}
|
||||
},
|
||||
|
||||
async authenticate() {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/step_up/webauthn/options', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get authentication options from server');
|
||||
}
|
||||
|
||||
const options = await response.json();
|
||||
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options);
|
||||
const credential = await navigator.credentials.get({ publicKey });
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('Authentication failed - no credential returned');
|
||||
}
|
||||
|
||||
const credResponse = credential.response;
|
||||
const toBase64Url = (buffer) => {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
};
|
||||
|
||||
const credentialJSON = {
|
||||
id: credential.id,
|
||||
rawId: toBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: toBase64Url(credResponse.clientDataJSON),
|
||||
authenticatorData: toBase64Url(credResponse.authenticatorData),
|
||||
signature: toBase64Url(credResponse.signature),
|
||||
userHandle: credResponse.userHandle ? toBase64Url(credResponse.userHandle) : null,
|
||||
},
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
};
|
||||
|
||||
document.getElementById('step-up-credential-data').value = JSON.stringify(credentialJSON);
|
||||
document.getElementById('step-up-webauthn-form').submit();
|
||||
} catch (error) {
|
||||
console.error('Step-up passkey error:', error);
|
||||
|
||||
if (error.name === 'NotAllowedError') {
|
||||
this.error = 'Authentication was cancelled or not allowed';
|
||||
} else if (error.name === 'InvalidStateError') {
|
||||
this.error = 'No passkey found for this account';
|
||||
} else if (error.name === 'NotSupportedError') {
|
||||
this.error = 'Passkeys are not supported on this device';
|
||||
} else {
|
||||
this.error = error.message || 'An unexpected error occurred';
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
flex: 1;
|
||||
padding: 2rem 1rem;
|
||||
background: #f5f5f5;
|
||||
|
||||
|
||||
@include dark-mode {
|
||||
background: #1a1d23;
|
||||
}
|
||||
|
|
@ -71,24 +71,23 @@
|
|||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 610px;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
4px 4px 16px rgba(0, 0, 0, 0.04),
|
||||
2px 2px 8px rgba(0, 0, 0, 0.02),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
|
||||
|
||||
@include dark-mode {
|
||||
background: linear-gradient(165deg, #252932 0%, #1f2329 100%);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
4px 4px 16px rgba(0, 0, 0, 0.3),
|
||||
2px 2px 8px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: $space-7;
|
||||
|
||||
|
||||
h1 {
|
||||
font-size: 1.85rem;
|
||||
|
|
@ -97,19 +96,19 @@
|
|||
color: var(--text-strong);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
|
||||
small {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted-strong);
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
|
||||
|
||||
a {
|
||||
color: var(--pico-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
@include transition-default(color);
|
||||
|
||||
|
||||
&:hover {
|
||||
color: var(--pico-primary-hover);
|
||||
text-decoration: underline;
|
||||
|
|
@ -117,24 +116,52 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fieldset {
|
||||
@include form-fieldset;
|
||||
margin: 0 0 1.75rem;
|
||||
legend { font-size: 0.95rem; margin-bottom: $space-4; }
|
||||
label { font-size: 0.9rem; margin-bottom: 0.625rem; }
|
||||
input, select, textarea {
|
||||
margin-bottom: $space-1; background: var(--surface-2);
|
||||
@include transition-default(background);
|
||||
&:focus { background: var(--surface-1); }
|
||||
|
||||
legend {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--pico-color);
|
||||
margin-bottom: $space-4;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.625rem;
|
||||
display: block;
|
||||
color: var(--pico-color);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
margin-bottom: $space-1;
|
||||
border-radius: $radius-md;
|
||||
background: var(--surface-2);
|
||||
@include transition-default(background);
|
||||
|
||||
&:focus {
|
||||
background: var(--surface-1);
|
||||
}
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted-strong);
|
||||
margin-top: 0.375rem;
|
||||
display: block;
|
||||
}
|
||||
small { font-size: 0.85rem; color: var(--text-muted-strong); margin-top: 0.375rem; display: block; }
|
||||
}
|
||||
|
||||
|
||||
.grid {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
button[type="submit"],
|
||||
input[type="submit"] {
|
||||
width: 100%;
|
||||
|
|
@ -143,25 +170,25 @@
|
|||
font-size: 1rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
margin-top: $space-7;
|
||||
padding-top: $space-6;
|
||||
border-top: 1px solid var(--surface-2-border);
|
||||
|
||||
|
||||
p {
|
||||
margin: $space-1 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted-strong);
|
||||
}
|
||||
|
||||
|
||||
a {
|
||||
color: var(--pico-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
@include transition-default(color);
|
||||
|
||||
|
||||
&:hover {
|
||||
color: var(--pico-primary-hover);
|
||||
text-decoration: underline;
|
||||
|
|
@ -182,6 +209,7 @@
|
|||
.auth-flash-wrapper {
|
||||
flex-shrink: 0;
|
||||
padding: 2rem 1rem 0;
|
||||
pointer-events: none;
|
||||
|
||||
.banner {
|
||||
max-width: 640px;
|
||||
|
|
@ -190,3 +218,28 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WebAuthn
|
||||
.webauthn-button {
|
||||
width: 100%;
|
||||
padding: 0.7rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.webauthn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,8 @@
|
|||
.security-sections {
|
||||
max-width: 900px;
|
||||
margin: 2rem auto 0;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--text-muted-strong);
|
||||
font-size: 0.95rem;
|
||||
|
|
@ -8,7 +13,7 @@
|
|||
.totp-disabled {
|
||||
text-align: center;
|
||||
padding: $space-6 $space-3;
|
||||
|
||||
|
||||
p {
|
||||
color: var(--text-muted-strong);
|
||||
margin-bottom: $space-5;
|
||||
|
|
@ -26,28 +31,28 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-4;
|
||||
|
||||
|
||||
.status-icon {
|
||||
font-size: 2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.status-info {
|
||||
flex: 1;
|
||||
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
p.status-detail {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted-strong);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
font-size: 0.875rem !important;
|
||||
padding: $space-1 $space-3 !important;
|
||||
|
|
@ -65,13 +70,13 @@
|
|||
.totp-setup-header {
|
||||
text-align: center;
|
||||
margin-bottom: $space-5;
|
||||
|
||||
|
||||
h3 {
|
||||
margin: 0 0 $space-1;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.step-indicator {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
|
|
@ -94,13 +99,13 @@
|
|||
margin-bottom: $space-5;
|
||||
background: var(--surface-2);
|
||||
border-radius: $radius-lg;
|
||||
|
||||
|
||||
p {
|
||||
margin-bottom: $space-5;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
|
||||
.qr {
|
||||
display: block;
|
||||
width: min(60vw, 200px) !important;
|
||||
|
|
@ -110,15 +115,15 @@
|
|||
padding: $space-3;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
|
||||
details {
|
||||
margin-top: $space-5;
|
||||
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted-strong);
|
||||
|
||||
|
||||
&:hover {
|
||||
color: var(--pico-primary);
|
||||
}
|
||||
|
|
@ -134,7 +139,7 @@
|
|||
word-break: break-all;
|
||||
font-family: $font-mono;
|
||||
}
|
||||
|
||||
|
||||
.copy-secret-btn {
|
||||
margin-top: $space-2;
|
||||
font-size: 0.875rem !important;
|
||||
|
|
@ -157,11 +162,12 @@
|
|||
display: flex;
|
||||
gap: $space-3;
|
||||
margin-top: $space-5;
|
||||
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
|
||||
button, input[type="submit"] {
|
||||
|
||||
button,
|
||||
input[type="submit"] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -171,7 +177,7 @@
|
|||
margin-top: $space-5;
|
||||
padding-top: $space-5;
|
||||
border-top: 1px solid var(--surface-2-border);
|
||||
|
||||
|
||||
details {
|
||||
summary {
|
||||
cursor: pointer;
|
||||
|
|
@ -180,12 +186,12 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
|
||||
|
||||
&:hover {
|
||||
color: var(--pico-primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
p {
|
||||
margin-top: $space-3;
|
||||
padding-left: $space-6;
|
||||
|
|
@ -201,7 +207,7 @@
|
|||
.sessions-actions {
|
||||
margin-bottom: $space-4;
|
||||
text-align: right;
|
||||
|
||||
|
||||
button {
|
||||
font-size: 0.875rem !important;
|
||||
padding: $space-1 $space-3 !important;
|
||||
|
|
@ -215,19 +221,42 @@
|
|||
gap: $space-3;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
@include card($padding: $space-4, $radius: $radius-lg);
|
||||
@include transition-default();
|
||||
|
||||
@include dark-mode {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.1),
|
||||
0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
|
||||
@include dark-mode {
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.current-session {
|
||||
background: linear-gradient(135deg, #f7fee7, #ecfccb);
|
||||
border-color: #84cc16;
|
||||
border-width: 1px;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.08),
|
||||
0 1px 4px rgba(0, 0, 0, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
|
||||
|
||||
@include dark-mode {
|
||||
background: linear-gradient(135deg, rgba(132, 204, 22, 0.15), rgba(132, 204, 22, 0.08));
|
||||
border-color: #84cc16;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.3),
|
||||
0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
|
@ -260,14 +289,14 @@
|
|||
@include badge(#84cc16, #1a1d23, 4px);
|
||||
margin-left: $space-1;
|
||||
float: right;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
|
||||
|
||||
@include dark-mode {
|
||||
background: #84cc16;
|
||||
color: #1a1d23;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 1px 3px rgba(132, 204, 22, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
|
@ -285,7 +314,7 @@
|
|||
font-size: 0.85rem;
|
||||
color: var(--text-muted-strong);
|
||||
margin-bottom: $space-3;
|
||||
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
gap: $space-1;
|
||||
|
|
@ -300,6 +329,7 @@
|
|||
.backup-code-method {
|
||||
color: #dc2626;
|
||||
background: linear-gradient(135deg, rgba(220, 38, 38, 0.15), rgba(153, 27, 27, 0.15));
|
||||
|
||||
@include dark-mode {
|
||||
color: #fca5a5;
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.25), rgba(220, 38, 38, 0.25));
|
||||
|
|
@ -340,12 +370,12 @@
|
|||
border-radius: $radius-md;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
|
||||
|
||||
@include dark-mode {
|
||||
background: color-mix(in srgb, #d97706 15%, #1f2937 85%);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -381,16 +411,18 @@
|
|||
gap: $space-3;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: $space-5;
|
||||
|
||||
button, a {
|
||||
|
||||
button,
|
||||
a {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
|
||||
button, a {
|
||||
|
||||
button,
|
||||
a {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
}
|
||||
|
|
@ -400,7 +432,7 @@
|
|||
.backup-codes-confirmation {
|
||||
padding-top: $space-5;
|
||||
border-top: 1px solid var(--surface-2-border);
|
||||
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -409,15 +441,15 @@
|
|||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
|
@ -429,23 +461,23 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-4;
|
||||
|
||||
|
||||
.status-info {
|
||||
flex: 1;
|
||||
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
.status-detail {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted-strong);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
font-size: 0.875rem !important;
|
||||
padding: $space-1 $space-3 !important;
|
||||
|
|
@ -460,3 +492,74 @@
|
|||
color: var(--text-muted-strong);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
// WebAuthn!
|
||||
.webauthn-disabled {
|
||||
text-align: center;
|
||||
padding: $space-6 $space-3;
|
||||
|
||||
p {
|
||||
color: var(--text-muted-strong);
|
||||
margin-bottom: $space-5;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.webauthn-setup-description {
|
||||
margin-bottom: $space-5;
|
||||
font-size: 0.95rem;
|
||||
font-style: italic;
|
||||
color: var(--text-muted-strong);
|
||||
}
|
||||
|
||||
// Inline confirmation for delete actions
|
||||
.confirmation-inline {
|
||||
padding: $space-4;
|
||||
background: var(--surface-2);
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid var(--surface-2-border);
|
||||
|
||||
[data-theme="dark"] & {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.confirmation-text {
|
||||
margin: 0 0 $space-4;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirmation-actions {
|
||||
display: flex;
|
||||
gap: $space-2;
|
||||
justify-content: flex-end;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
|
||||
button,
|
||||
form {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
form button {
|
||||
font-size: 0.875rem !important;
|
||||
padding: $space-1 $space-3 !important;
|
||||
width: auto !important;
|
||||
margin: 0 !important;
|
||||
min-width: 80px;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -54,6 +54,7 @@ class Identity < ApplicationRecord
|
|||
has_many :v2_login_codes, class_name: "Identity::V2LoginCode", dependent: :destroy
|
||||
has_many :totps, class_name: "Identity::TOTP", dependent: :destroy
|
||||
has_many :backup_codes, class_name: "Identity::BackupCode", dependent: :destroy
|
||||
has_many :webauthn_credentials, class_name: "Identity::WebauthnCredential", dependent: :destroy
|
||||
has_many :email_change_requests, class_name: "Identity::EmailChangeRequest", dependent: :destroy
|
||||
|
||||
has_one :backend_user, class_name: "Backend::User", dependent: :destroy
|
||||
|
|
@ -62,10 +63,6 @@ class Identity < ApplicationRecord
|
|||
backend_user&.active?
|
||||
end
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
has_many :documents, class_name: "Identity::Document", dependent: :destroy
|
||||
has_many :verifications, class_name: "Verification", dependent: :destroy
|
||||
has_many :document_verifications, class_name: "Verification::DocumentVerification", dependent: :destroy
|
||||
|
|
@ -320,10 +317,20 @@ class Identity < ApplicationRecord
|
|||
|
||||
def backup_codes_enabled? = backup_codes.active.any?
|
||||
|
||||
def webauthn_enabled? = webauthn_credentials.any?
|
||||
|
||||
# Encode identity ID as base64url for WebAuthn user.id
|
||||
# Uses 64-bit unsigned big-endian binary format
|
||||
def webauthn_user_id
|
||||
user_id_binary = [ id ].pack("Q>")
|
||||
Base64.urlsafe_encode64(user_id_binary, padding: false)
|
||||
end
|
||||
|
||||
def available_step_up_methods
|
||||
methods = []
|
||||
methods << :totp if totp.present?
|
||||
methods << :backup_code if backup_codes_enabled?
|
||||
methods << :webauthn if webauthn_enabled?
|
||||
# Future: methods << :sms if sms_verified?
|
||||
methods
|
||||
end
|
||||
|
|
@ -331,7 +338,8 @@ class Identity < ApplicationRecord
|
|||
# Generic 2FA method helpers
|
||||
def two_factor_methods
|
||||
[
|
||||
totps.verified
|
||||
totps.verified,
|
||||
webauthn_credentials
|
||||
# Future: sms_two_factors.verified,
|
||||
].flatten.compact
|
||||
end
|
||||
|
|
|
|||
73
app/models/identity/webauthn_credential.rb
Normal file
73
app/models/identity/webauthn_credential.rb
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
class Identity::WebauthnCredential < ApplicationRecord
|
||||
belongs_to :identity
|
||||
|
||||
has_paper_trail
|
||||
|
||||
include PublicActivity::Model
|
||||
tracked owner: proc { |controller, record| record.identity }, recipient: proc { |controller, record| record.identity }, only: [ :create, :destroy ]
|
||||
|
||||
scope :active, -> { where(compromised_at: nil) }
|
||||
scope :compromised, -> { where.not(compromised_at: nil) }
|
||||
|
||||
validates :external_id, presence: true, uniqueness: true
|
||||
validates :public_key, presence: true
|
||||
validates :sign_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
validates :nickname, length: { maximum: 50 }, allow_blank: true
|
||||
|
||||
before_validation :set_initial_sign_count, on: :create
|
||||
|
||||
# WebAuthn credential IDs and public keys are binary data that need to be
|
||||
# base64url encoded for storage and transmission
|
||||
def webauthn_id
|
||||
Base64.urlsafe_decode64(external_id)
|
||||
end
|
||||
|
||||
def webauthn_id=(value)
|
||||
self.external_id = Base64.urlsafe_encode64(value, padding: false)
|
||||
end
|
||||
|
||||
def webauthn_public_key
|
||||
Base64.urlsafe_decode64(public_key)
|
||||
end
|
||||
|
||||
def webauthn_public_key=(value)
|
||||
self.public_key = Base64.urlsafe_encode64(value, padding: false)
|
||||
end
|
||||
|
||||
# Increment the sign count after successful authentication
|
||||
# This helps detect credential cloning attacks
|
||||
def increment_sign_count!
|
||||
increment!(:sign_count)
|
||||
end
|
||||
|
||||
# Mark credential as potentially compromised (e.g., sign count anomaly)
|
||||
def mark_as_compromised!
|
||||
update!(compromised_at: Time.current)
|
||||
Rails.logger.warn "WebAuthn credential marked as compromised: id=#{id}, identity_id=#{identity_id}"
|
||||
end
|
||||
|
||||
def compromised?
|
||||
compromised_at.present?
|
||||
end
|
||||
|
||||
def active?
|
||||
!compromised?
|
||||
end
|
||||
|
||||
# Human-readable display for the credential
|
||||
def display_name
|
||||
nickname.presence || "Passkey created #{created_at.strftime('%b %d, %Y')}"
|
||||
end
|
||||
|
||||
# Class method to get all decoded credential IDs for a collection
|
||||
# Useful for building WebAuthn allow/exclude lists
|
||||
def self.raw_credential_ids
|
||||
pluck(:external_id).map { |id| Base64.urlsafe_decode64(id) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_initial_sign_count
|
||||
self.sign_count ||= 0
|
||||
end
|
||||
end
|
||||
|
|
@ -8,7 +8,7 @@ class LoginAttempt < ApplicationRecord
|
|||
has_encrypted :browser_token
|
||||
before_validation :ensure_browser_token
|
||||
|
||||
store_accessor :authentication_factors, :email, :totp, :backup_code, :legacy_email, prefix: :authenticated_with
|
||||
store_accessor :authentication_factors, :email, :totp, :backup_code, :webauthn, :legacy_email, prefix: :authenticated_with
|
||||
|
||||
EXPIRATION = 15.minutes
|
||||
|
||||
|
|
@ -72,9 +72,12 @@ class LoginAttempt < ApplicationRecord
|
|||
|
||||
def backup_code_available? = !authenticated_with_backup_code && identity.backup_codes_enabled?
|
||||
|
||||
def webauthn_available? = !authenticated_with_webauthn && identity.webauthn_enabled?
|
||||
|
||||
def available_factors
|
||||
factors = []
|
||||
factors << :email if email_available?
|
||||
factors << :webauthn if webauthn_available?
|
||||
factors << :totp if totp_available?
|
||||
factors << :backup_code if backup_code_available?
|
||||
factors
|
||||
|
|
@ -83,8 +86,12 @@ class LoginAttempt < ApplicationRecord
|
|||
private
|
||||
|
||||
def required_authentication_factors_count
|
||||
# WebAuthn inherently provides 2FA (possession + biometric/PIN)
|
||||
# So if WebAuthn is used, we only need 1 factor
|
||||
if authenticated_with_webauthn
|
||||
1
|
||||
# Require 2FA if enabled AND at least one 2FA method is configured
|
||||
if identity.requires_two_factor?
|
||||
elsif identity.requires_two_factor?
|
||||
2
|
||||
else
|
||||
1
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@
|
|||
<% auth_methods = [] %>
|
||||
<% auth_methods << t("auth_type.email") if identity_session.login_attempt.authenticated_with_email || identity_session.login_attempt.authenticated_with_legacy_email %>
|
||||
<% auth_methods << t("auth_type.totp") if identity_session.login_attempt.authenticated_with_totp %>
|
||||
<% auth_methods << t("auth_type.webauthn") if identity_session.login_attempt.authenticated_with_webauthn %>
|
||||
<% auth_methods << t("auth_type.backup_code") if identity_session.login_attempt.authenticated_with_backup_code %>
|
||||
<%= auth_methods.join(", ") %>
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<div class="session-card">
|
||||
<div class="session-header">
|
||||
<div class="session-icon">
|
||||
<%= inline_icon("fingerprint", size: 32) %>
|
||||
</div>
|
||||
<div class="session-info">
|
||||
<div class="session-device">
|
||||
<%= identity_webauthn_credential.display_name %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-details">
|
||||
<div>
|
||||
<span class="detail-label"><%= t "identity_webauthn_credentials.created_at" %></span>
|
||||
<span><%= identity_webauthn_credential.created_at.strftime("%b %d, %Y at %l:%M %p") %></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="detail-label"><%= t "identity_webauthn_credentials.last_used" %></span>
|
||||
<span><%= time_ago_in_words(identity_webauthn_credential.updated_at) %> ago</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="{ showConfirm: false }">
|
||||
<div x-show="!showConfirm">
|
||||
<button x-on:click="showConfirm = true" class="danger small-btn" type="button">
|
||||
<%= t("identity_webauthn_credentials.remove") %> <%= inline_icon("delete", size: 16) %>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="showConfirm" x-cloak class="confirmation-inline">
|
||||
<p class="confirmation-text"><%= t("identity_webauthn_credentials.remove_confirmation") %></p>
|
||||
<div class="confirmation-actions">
|
||||
<button x-on:click="showConfirm = false" class="button" type="button">Cancel</button>
|
||||
<%= button_to identity_webauthn_credential_path(identity_webauthn_credential),
|
||||
method: :delete,
|
||||
class: "button danger" do %>
|
||||
Confirm
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
30
app/views/identity_webauthn_credentials/index.html.erb
Normal file
30
app/views/identity_webauthn_credentials/index.html.erb
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<div id="webauthn-credentials-container">
|
||||
<% if @webauthn_credentials.empty? %>
|
||||
<div class="webauthn-disabled">
|
||||
<p class="webauthn-description"><%= t(".setup_description") %></p>
|
||||
<%= link_to t(".setup_webauthn"),
|
||||
new_identity_webauthn_credential_path,
|
||||
class: "webauthn-enable-btn",
|
||||
data: {
|
||||
"hx-get": new_identity_webauthn_credential_path,
|
||||
"hx-target": "#webauthn-credentials-container",
|
||||
"hx-swap": "innerHTML"
|
||||
} %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="sessions-list">
|
||||
<%= render partial: "identity_webauthn_credential", collection: @webauthn_credentials %>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
<%= link_to "Add another passkey",
|
||||
new_identity_webauthn_credential_path,
|
||||
class: "btn btn-secondary",
|
||||
data: {
|
||||
"hx-get": new_identity_webauthn_credential_path,
|
||||
"hx-target": "#webauthn-credentials-container",
|
||||
"hx-swap": "innerHTML"
|
||||
} %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
52
app/views/identity_webauthn_credentials/new.html.erb
Normal file
52
app/views/identity_webauthn_credentials/new.html.erb
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<div class="passkey-setup" x-data="webauthnRegister()">
|
||||
<p class="webauthn-setup-description">
|
||||
A passwordless way to sign in using your device's biometric authentication or PIN.
|
||||
</p>
|
||||
|
||||
<div x-show="!browserSupported" x-cloak class="alert alert-warning">
|
||||
<strong>Browser not supported</strong>
|
||||
<p>Your browser doesn't support the latest WebAuthn features. Use something modern!</p>
|
||||
</div>
|
||||
|
||||
<%= form_with url: identity_webauthn_credentials_path, method: :post, local: true, id: "webauthn-registration-form", style: "display: none;" do |form| %>
|
||||
<%= form.hidden_field :credential_data, id: "registration-credential-data" %>
|
||||
<%= form.hidden_field :nickname, id: "registration-nickname" %>
|
||||
<% end %>
|
||||
|
||||
<div x-show="browserSupported">
|
||||
<form x-on:submit.prevent="register()">
|
||||
<div class="form-group">
|
||||
<label for="webauthn-nickname">Passkey Nickname</label>
|
||||
<input
|
||||
type="text"
|
||||
id="webauthn-nickname"
|
||||
x-model="nickname"
|
||||
placeholder="e.g., Heidi's iPhone, MacBook Air"
|
||||
required
|
||||
maxlength="50"
|
||||
autocomplete="off">
|
||||
<small class="form-hint">Give this passkey a memorable name to identify it later!</small>
|
||||
</div>
|
||||
|
||||
<div x-show="error" x-cloak class="alert alert-error" role="alert">
|
||||
<strong>Registration Failed</strong>
|
||||
<p x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
<span x-show="!loading">Register Passkey</span>
|
||||
<span x-show="loading" x-cloak>Registering...</span>
|
||||
</button>
|
||||
<%= link_to "Cancel",
|
||||
identity_webauthn_credentials_path,
|
||||
class: "btn btn-secondary",
|
||||
data: {
|
||||
"hx-get": identity_webauthn_credentials_path,
|
||||
"hx-target": "#webauthn-credentials-container",
|
||||
"hx-swap": "innerHTML"
|
||||
} %>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
46
app/views/logins/webauthn.html.erb
Normal file
46
app/views/logins/webauthn.html.erb
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<div class="auth-container" x-data="webauthnAuth()">
|
||||
<div class="auth-card passkey-auth" x-show="browserSupported">
|
||||
<header>
|
||||
<h1><%= t(".title") %></h1>
|
||||
<small><%= t(".subtitle") %></small>
|
||||
</header>
|
||||
|
||||
<div x-show="error" x-cloak role="alert">
|
||||
<strong>Authentication Failed</strong>
|
||||
<p x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<%= form_with url: verify_webauthn_login_attempt_path(@attempt), method: :post, local: true, id: "webauthn-form" do |form| %>
|
||||
<%= form.hidden_field :credential_data, id: "credential-data" %>
|
||||
<% end %>
|
||||
|
||||
<div class="passkey-prompt">
|
||||
<button
|
||||
type="button"
|
||||
class="webauthn-button"
|
||||
x-on:click="authenticate()"
|
||||
x-bind:disabled="loading">
|
||||
<span class="webauthn-icon"><%= inline_icon("fingerprint", size: 16) %></span>
|
||||
<span x-show="!loading"><%= t(".authenticate") %></span>
|
||||
<span x-show="loading" x-cloak><%= t(".authenticating") %>...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
<%= t(".prefer_email") %>
|
||||
<%= button_to t(".use_email_code"),
|
||||
skip_webauthn_login_attempt_path(@attempt),
|
||||
method: :post,
|
||||
class: "secondary small-btn" %>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div x-show="!browserSupported" x-cloak class="auth-card">
|
||||
<div class="alert alert-warning">
|
||||
<strong>Browser Not Supported</strong>
|
||||
<p><%= t(".browser_not_supported") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<%= render Components::PublicActivity::Snippet.new(activity, owner: activity.trackable&.identity) do %>
|
||||
added a passkey: <%= activity.trackable&.display_name %>.
|
||||
<% end %>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<%= render Components::PublicActivity::Snippet.new(activity, owner: activity.trackable&.identity) do %>
|
||||
removed a passkey: <%= activity.trackable&.display_name %>.
|
||||
<% end %>
|
||||
|
|
@ -10,13 +10,13 @@
|
|||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h3><%= t ".applications" %></h3>
|
||||
<%= render Components::BootlegTurbo.new(authorized_applications_path, id: "authorized-apps-container", hx_swap: "innerHTML") %>
|
||||
<h3><%= t ".mfa" %></h3>
|
||||
<%= render Components::BootlegTurbo.new(identity_totps_path, id: "totp-container", hx_swap: "innerHTML") %>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h3><%= t ".mfa" %></h3>
|
||||
<%= render Components::BootlegTurbo.new(identity_totps_path, id: "totp-container", hx_swap: "innerHTML") %>
|
||||
<h3><%= t ".webauthn" %></h3>
|
||||
<%= render Components::BootlegTurbo.new(identity_webauthn_credentials_path, id: "webauthn-container", hx_swap: "innerHTML") %>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
|
|
@ -25,9 +25,14 @@
|
|||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h3>Activity Log</h3>
|
||||
<p>View your recent account activity and security events.</p>
|
||||
<%= link_to "View Activity Log", audit_logs_path, class: "button" %>
|
||||
<h3><%= t ".applications" %></h3>
|
||||
<%= render Components::BootlegTurbo.new(authorized_applications_path, id: "authorized-apps-container", hx_swap: "innerHTML") %>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<h3><%= t ".activity" %></h3>
|
||||
<p><%= t ".activity_description" %></p>
|
||||
<%= link_to t(".view_activity_log"), audit_logs_path, class: "button" %>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
<% if @available_methods.include?(:totp) %>
|
||||
<%= link_to new_step_up_path(action_type: @action, method: :totp, return_to: @return_to),
|
||||
class: "step-up-method-option" do %>
|
||||
<div><%= inline_icon("private", size: 32) %></div>
|
||||
<div><%= inline_icon("clock", size: 32) %></div>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 600;"><%= t(".methods.totp.title") %></div>
|
||||
<small><%= t(".methods.totp.description") %></small>
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
<% if @available_methods.include?(:backup_code) %>
|
||||
<%= link_to new_step_up_path(action_type: @action, method: :backup_code, return_to: @return_to),
|
||||
class: "step-up-method-option" do %>
|
||||
<div><%= inline_icon("private", size: 32) %></div>
|
||||
<div><%= inline_icon("docs", size: 32) %></div>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 600;"><%= t(".methods.backup_code.title") %></div>
|
||||
<small><%= t(".methods.backup_code.description") %></small>
|
||||
|
|
@ -32,13 +32,13 @@
|
|||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @available_methods.include?(:sms) %>
|
||||
<%= link_to new_step_up_path(action_type: @action, method: :sms, return_to: @return_to),
|
||||
<% if @available_methods.include?(:webauthn) %>
|
||||
<%= link_to new_step_up_path(action_type: @action, method: :webauthn, return_to: @return_to),
|
||||
class: "step-up-method-option" do %>
|
||||
<div><%= inline_icon("message", size: 32) %></div>
|
||||
<div><%= inline_icon("fingerprint", size: 32) %></div>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 600;"><%= t(".methods.sms.title") %></div>
|
||||
<small><%= t(".methods.sms.description") %></small>
|
||||
<div style="font-weight: 600;"><%= t(".methods.webauthn.title", default: "Passkey") %></div>
|
||||
<small><%= t(".methods.webauthn.description", default: "Use your passkey to verify") %></small>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
@ -79,6 +79,42 @@
|
|||
<p><%= link_to t(".cancel"), step_up_cancel_path(@action) %></p>
|
||||
</footer>
|
||||
|
||||
<% elsif method == :webauthn %>
|
||||
<%# Show WebAuthn/Passkey authentication %>
|
||||
<header>
|
||||
<h1><%= t(".enter_code_labels.webauthn", default: "Verify with passkey") %></h1>
|
||||
<small><%= t(".enter_code_descriptions.webauthn", default: "Use your passkey to confirm your identity") %></small>
|
||||
</header>
|
||||
|
||||
<div x-data="stepUpWebauthn()" x-init="init()">
|
||||
<div x-show="loading" style="text-align: center; padding: 2rem;">
|
||||
<p>Waiting for passkey...</p>
|
||||
</div>
|
||||
|
||||
<div x-show="error" x-cloak style="margin: 1rem 0;">
|
||||
<div class="alert alert-error" x-text="error"></div>
|
||||
<button @click="authenticate()" class="btn" style="width: 100%; margin-top: 1rem;">
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="!browserSupported" x-cloak>
|
||||
<div class="alert alert-error">
|
||||
Your browser doesn't support passkeys. Please use a different verification method.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with url: verify_step_up_webauthn_path, method: :post, local: true, id: "step-up-webauthn-form" do |f| %>
|
||||
<%= f.hidden_field :action_type, value: @action %>
|
||||
<%= f.hidden_field :return_to, value: @return_to %>
|
||||
<%= f.hidden_field :credential_data, id: "step-up-credential-data" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p><%= link_to t(".use_different_method"), new_step_up_path(action_type: @action, return_to: @return_to) %></p>
|
||||
</footer>
|
||||
|
||||
<% else %>
|
||||
<%# Show form for selected method %>
|
||||
<header>
|
||||
|
|
|
|||
29
config/initializers/webauthn.rb
Normal file
29
config/initializers/webauthn.rb
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Configure WebAuthn for passkey authentication
|
||||
WebAuthn.configure do |config|
|
||||
# The allowed origins - where WebAuthn requests can come from
|
||||
config.allowed_origins = if Rails.env.production?
|
||||
[ "https://auth.hackclub.com" ]
|
||||
elsif Rails.env.development?
|
||||
[ "http://localhost:3000" ]
|
||||
else
|
||||
# For test environment or other environments
|
||||
[ "http://localhost:3000" ]
|
||||
end
|
||||
|
||||
# The Relying Party name - shown in authenticator UI
|
||||
config.rp_name = "Hack Club Auth"
|
||||
|
||||
# Explicitly set the Relying Party ID to prevent misconfiguration
|
||||
# This must match the domain where WebAuthn is used
|
||||
config.rp_id = if Rails.env.production?
|
||||
"auth.hackclub.com"
|
||||
else
|
||||
"localhost"
|
||||
end
|
||||
|
||||
# Credential options (optional - these are the defaults)
|
||||
# Algorithms we support for credential public keys
|
||||
# ES256 is ECDSA with SHA-256, the most widely supported algorithm
|
||||
# RS256 is RSA with SHA-256, supported by some older authenticators
|
||||
config.algorithms = [ "ES256", "RS256" ]
|
||||
end
|
||||
|
|
@ -335,6 +335,7 @@ en:
|
|||
email: Email
|
||||
totp: TOTP
|
||||
backup_code: Backup code
|
||||
webauthn: Passkey
|
||||
logins:
|
||||
new:
|
||||
title: Welcome back!
|
||||
|
|
@ -371,6 +372,14 @@ en:
|
|||
verify: Verify
|
||||
lost_authenticator: Lost your authenticator?
|
||||
use_backup_code: Use a backup code instead
|
||||
webauthn:
|
||||
title: Welcome back!
|
||||
subtitle: Use the passkey saved on this device or another nearby device
|
||||
authenticate: Use passkey
|
||||
authenticating: Authenticating
|
||||
browser_not_supported: Your browser doesn't support passkeys. Please use a modern browser or use email code instead.
|
||||
prefer_email: Don't have a passkey?
|
||||
use_email_code: Use email code instead
|
||||
saml:
|
||||
http_post:
|
||||
title: SAMLing...
|
||||
|
|
@ -384,10 +393,14 @@ en:
|
|||
security_link: Security
|
||||
security:
|
||||
heading: Security
|
||||
sessions: Active sessions
|
||||
applications: Linked apps
|
||||
mfa: 2-Factor Authentication
|
||||
sessions: Active Sessions
|
||||
mfa: Authenticator App
|
||||
webauthn: Passkeys
|
||||
backup_codes: Backup Codes
|
||||
applications: Connected Apps
|
||||
activity: Activity Log
|
||||
activity_description: View your recent account activity and security events.
|
||||
view_activity_log: View Activity Log
|
||||
identity_backup_codes:
|
||||
totp_heading: "Save your backup codes"
|
||||
save_warning_title: Save these codes now!
|
||||
|
|
@ -415,7 +428,7 @@ en:
|
|||
require_2fa_description: When enabled, you must use a second factor (authenticator app or backup code) to sign in.
|
||||
disable_requirement: Disable Requirement
|
||||
enable_requirement: Enable Requirement
|
||||
setup_description: Add an extra layer of security by requiring a second factor in addition to your email.
|
||||
setup_description: Add an extra layer of security to email sign-ins by requiring a code from an authenticator app.
|
||||
setup_authenticator: Set up two-factor authentication
|
||||
show:
|
||||
heading: Set up two-factor authentication
|
||||
|
|
@ -451,6 +464,17 @@ en:
|
|||
auth_type: Login method
|
||||
terminate: Log out
|
||||
terminate_confirmation: Log out this session?
|
||||
identity_webauthn_credentials:
|
||||
index:
|
||||
setup_description: Sign in securely using a passkey stored on your device. Passkeys don't require two-factor authentication.
|
||||
setup_webauthn: Set up passkey
|
||||
added: Added
|
||||
created_at: Created
|
||||
last_used: Last used
|
||||
remove: Remove
|
||||
remove_confirmation: Are you sure you want to remove this passkey? You won't be able to use it to sign in anymore.
|
||||
successfully_added: passkey added!
|
||||
successfully_removed: passkey removed!
|
||||
step_up:
|
||||
new:
|
||||
title: Gotta confirm it's you...
|
||||
|
|
@ -462,13 +486,18 @@ en:
|
|||
backup_code:
|
||||
title: Backup Code
|
||||
description: Use one of your backup codes
|
||||
webauthn:
|
||||
title: Passkey
|
||||
description: Use your passkey to verify
|
||||
cancel: Cancel
|
||||
enter_code_labels:
|
||||
totp: Enter authenticator code
|
||||
backup_code: Enter backup code
|
||||
webauthn: Verify with passkey
|
||||
enter_code_descriptions:
|
||||
totp: Enter the 6-digit code from your authenticator app
|
||||
backup_code: Enter one of your backup codes
|
||||
webauthn: Use your passkey to confirm your identity
|
||||
code_label: Enter code
|
||||
backup_placeholder: XXXXXXXXXX
|
||||
totp_placeholder: "000000"
|
||||
|
|
|
|||
|
|
@ -291,6 +291,10 @@ Rails.application.routes.draw do
|
|||
post "/login/:id/totp", to: "logins#verify_totp", as: :verify_totp_login_attempt
|
||||
get "/login/:id/backup_code", to: "logins#backup_code", as: :backup_code_login_attempt
|
||||
post "/login/:id/backup_code", to: "logins#verify_backup_code", as: :verify_backup_code_login_attempt
|
||||
get "/login/:id/webauthn", to: "logins#webauthn", as: :webauthn_login_attempt
|
||||
post "/login/:id/webauthn/options", to: "logins#webauthn_options", as: :webauthn_options_login_attempt
|
||||
post "/login/:id/webauthn/verify", to: "logins#verify_webauthn", as: :verify_webauthn_login_attempt
|
||||
post "/login/:id/webauthn/skip", to: "logins#skip_webauthn", as: :skip_webauthn_login_attempt
|
||||
|
||||
delete "/logout", to: "sessions#logout", as: :logout
|
||||
|
||||
|
|
@ -330,11 +334,19 @@ Rails.application.routes.draw do
|
|||
end
|
||||
end
|
||||
|
||||
resources :identity_webauthn_credentials, only: [ :index, :new, :create, :destroy ] do
|
||||
collection do
|
||||
post :options
|
||||
end
|
||||
end
|
||||
|
||||
# Step-up authentication flow
|
||||
get "/step_up", to: "step_up#new", as: :new_step_up
|
||||
post "/step_up/verify", to: "step_up#verify", as: :verify_step_up
|
||||
post "/step_up/send_email_code", to: "step_up#send_email_code", as: :send_step_up_email_code
|
||||
post "/step_up/resend_email", to: "step_up#resend_email", as: :resend_step_up_email
|
||||
post "/step_up/webauthn/options", to: "step_up#webauthn_options", as: :step_up_webauthn_options
|
||||
post "/step_up/webauthn/verify", to: "step_up#verify_webauthn", as: :verify_step_up_webauthn
|
||||
|
||||
resources :identity_backup_codes, only: [ :index, :create ] do
|
||||
patch :confirm, on: :collection
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
class CreateIdentityWebauthnCredentials < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :identity_webauthn_credentials do |t|
|
||||
t.references :identity, null: false, foreign_key: true
|
||||
t.string :external_id
|
||||
t.string :public_key
|
||||
t.string :nickname
|
||||
t.integer :sign_count
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
class ImproveWebauthnCredentialsConstraints < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
change_column_null :identity_webauthn_credentials, :external_id, false
|
||||
change_column_null :identity_webauthn_credentials, :public_key, false
|
||||
add_index :identity_webauthn_credentials, :external_id, unique: true, if_not_exists: true
|
||||
add_index :identity_webauthn_credentials, :identity_id, if_not_exists: true
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
class AddCompromisedAtToIdentityWebauthnCredentials < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :identity_webauthn_credentials, :compromised_at, :datetime
|
||||
end
|
||||
end
|
||||
27
db/schema.rb
generated
27
db/schema.rb
generated
|
|
@ -301,6 +301,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_01_170716) do
|
|||
t.boolean "saml_debug"
|
||||
t.boolean "is_in_workspace", default: false, null: false
|
||||
t.string "slack_dm_channel_id"
|
||||
t.string "webauthn_id"
|
||||
t.index "lower((primary_email)::text)", name: "idx_identities_unique_primary_email", unique: true, where: "(deleted_at IS NULL)"
|
||||
t.index ["aadhaar_number_bidx"], name: "index_identities_on_aadhaar_number_bidx", unique: true
|
||||
t.index ["deleted_at"], name: "index_identities_on_deleted_at"
|
||||
|
|
@ -436,6 +437,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_01_170716) do
|
|||
t.index ["login_attempt_id"], name: "index_identity_v2_login_codes_on_login_attempt_id"
|
||||
end
|
||||
|
||||
create_table "identity_webauthn_credentials", force: :cascade do |t|
|
||||
t.bigint "identity_id", null: false
|
||||
t.string "external_id", null: false
|
||||
t.string "public_key", null: false
|
||||
t.string "nickname"
|
||||
t.integer "sign_count"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["external_id"], name: "index_identity_webauthn_credentials_on_external_id", unique: true
|
||||
t.index ["identity_id"], name: "index_identity_webauthn_credentials_on_identity_id"
|
||||
end
|
||||
|
||||
create_table "login_attempts", force: :cascade do |t|
|
||||
t.bigint "identity_id", null: false
|
||||
t.bigint "session_id"
|
||||
|
|
@ -573,6 +586,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_01_170716) do
|
|||
t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id"
|
||||
end
|
||||
|
||||
create_table "webauthn_credentials", force: :cascade do |t|
|
||||
t.bigint "identity_id", null: false
|
||||
t.string "external_id", null: false
|
||||
t.string "public_key", null: false
|
||||
t.string "nickname", null: false
|
||||
t.integer "sign_count", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["external_id"], name: "index_webauthn_credentials_on_external_id", unique: true
|
||||
t.index ["identity_id"], name: "index_webauthn_credentials_on_identity_id"
|
||||
end
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "addresses", "identities"
|
||||
|
|
@ -594,6 +619,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_01_170716) do
|
|||
add_foreign_key "identity_totps", "identities"
|
||||
add_foreign_key "identity_v2_login_codes", "identities"
|
||||
add_foreign_key "identity_v2_login_codes", "login_attempts"
|
||||
add_foreign_key "identity_webauthn_credentials", "identities"
|
||||
add_foreign_key "login_attempts", "identities"
|
||||
add_foreign_key "login_attempts", "identity_sessions", column: "session_id"
|
||||
add_foreign_key "oauth_access_grants", "identities", column: "resource_owner_id"
|
||||
|
|
@ -604,4 +630,5 @@ ActiveRecord::Schema[8.0].define(version: 2026_01_01_170716) do
|
|||
add_foreign_key "verifications", "identities"
|
||||
add_foreign_key "verifications", "identity_aadhaar_records", column: "aadhaar_record_id"
|
||||
add_foreign_key "verifications", "identity_documents"
|
||||
add_foreign_key "webauthn_credentials", "identities"
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue