mirror of
https://github.com/System-End/identity-vault.git
synced 2026-04-19 18:35:13 +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 "rotp", "~> 6.3"
|
||||||
gem "rqrcode", "~> 2.0"
|
gem "rqrcode", "~> 2.0"
|
||||||
|
|
||||||
|
gem "webauthn", "~> 3.1"
|
||||||
|
|
||||||
gem "bcrypt", "~> 3.1"
|
gem "bcrypt", "~> 3.1"
|
||||||
|
|
||||||
gem "rack-attack", "~> 6.7"
|
gem "rack-attack", "~> 6.7"
|
||||||
|
|
|
||||||
404
Gemfile.lock
404
Gemfile.lock
|
|
@ -9,31 +9,31 @@ GIT
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
aasm (5.5.0)
|
aasm (5.5.2)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
actioncable (8.0.2)
|
actioncable (8.0.4)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.4)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.4)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (8.0.2)
|
actionmailbox (8.0.4)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.4)
|
||||||
activejob (= 8.0.2)
|
activejob (= 8.0.4)
|
||||||
activerecord (= 8.0.2)
|
activerecord (= 8.0.4)
|
||||||
activestorage (= 8.0.2)
|
activestorage (= 8.0.4)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.4)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.0.2)
|
actionmailer (8.0.4)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.4)
|
||||||
actionview (= 8.0.2)
|
actionview (= 8.0.4)
|
||||||
activejob (= 8.0.2)
|
activejob (= 8.0.4)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.4)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.0.2)
|
actionpack (8.0.4)
|
||||||
actionview (= 8.0.2)
|
actionview (= 8.0.4)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.4)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
|
|
@ -41,15 +41,15 @@ GEM
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.0.2)
|
actiontext (8.0.4)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.4)
|
||||||
activerecord (= 8.0.2)
|
activerecord (= 8.0.4)
|
||||||
activestorage (= 8.0.2)
|
activestorage (= 8.0.4)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.4)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.0.2)
|
actionview (8.0.4)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.4)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
|
|
@ -59,22 +59,22 @@ GEM
|
||||||
block_cipher_kit (>= 0.0.4)
|
block_cipher_kit (>= 0.0.4)
|
||||||
rails (>= 7.2.2.1)
|
rails (>= 7.2.2.1)
|
||||||
serve_byte_range (~> 1.0)
|
serve_byte_range (~> 1.0)
|
||||||
activejob (8.0.2)
|
activejob (8.0.4)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.4)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.0.2)
|
activemodel (8.0.4)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.4)
|
||||||
activerecord (8.0.2)
|
activerecord (8.0.4)
|
||||||
activemodel (= 8.0.2)
|
activemodel (= 8.0.4)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.4)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (8.0.2)
|
activestorage (8.0.4)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.4)
|
||||||
activejob (= 8.0.2)
|
activejob (= 8.0.4)
|
||||||
activerecord (= 8.0.2)
|
activerecord (= 8.0.4)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.4)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.0.2)
|
activesupport (8.0.4)
|
||||||
base64
|
base64
|
||||||
benchmark (>= 0.3)
|
benchmark (>= 0.3)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
|
|
@ -90,13 +90,14 @@ GEM
|
||||||
acts_as_paranoid (0.10.3)
|
acts_as_paranoid (0.10.3)
|
||||||
activerecord (>= 6.1, < 8.1)
|
activerecord (>= 6.1, < 8.1)
|
||||||
activesupport (>= 6.1, < 8.1)
|
activesupport (>= 6.1, < 8.1)
|
||||||
addressable (2.8.7)
|
addressable (2.8.8)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
ahoy_matey (5.4.1)
|
ahoy_matey (5.4.1)
|
||||||
activesupport (>= 7.1)
|
activesupport (>= 7.1)
|
||||||
device_detector (>= 1)
|
device_detector (>= 1)
|
||||||
safely_block (>= 0.4)
|
safely_block (>= 0.4)
|
||||||
annotaterb (4.19.0)
|
android_key_attestation (0.3.0)
|
||||||
|
annotaterb (4.20.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
argon2-kdf (0.3.1)
|
argon2-kdf (0.3.1)
|
||||||
|
|
@ -109,45 +110,48 @@ GEM
|
||||||
turbo-rails
|
turbo-rails
|
||||||
awesome_print (1.9.2)
|
awesome_print (1.9.2)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1110.0)
|
aws-partitions (1.1203.0)
|
||||||
aws-sdk-core (3.225.0)
|
aws-sdk-core (3.241.3)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
base64
|
base64
|
||||||
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.102.0)
|
aws-sdk-kms (1.120.0)
|
||||||
aws-sdk-core (~> 3, >= 3.225.0)
|
aws-sdk-core (~> 3, >= 3.241.3)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.189.0)
|
aws-sdk-s3 (1.211.0)
|
||||||
aws-sdk-core (~> 3, >= 3.225.0)
|
aws-sdk-core (~> 3, >= 3.241.3)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.12.0)
|
aws-sigv4 (1.12.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
base64 (0.2.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.21)
|
||||||
benchmark (0.4.0)
|
benchmark (0.5.0)
|
||||||
better_html (2.1.1)
|
better_html (2.2.0)
|
||||||
actionview (>= 6.0)
|
actionview (>= 7.0)
|
||||||
activesupport (>= 6.0)
|
activesupport (>= 7.0)
|
||||||
ast (~> 2.0)
|
ast (~> 2.0)
|
||||||
erubi (~> 1.4)
|
erubi (~> 1.4)
|
||||||
parser (>= 2.4)
|
parser (>= 2.4)
|
||||||
smart_properties
|
smart_properties
|
||||||
bigdecimal (3.1.9)
|
bigdecimal (4.0.1)
|
||||||
|
bindata (2.5.1)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
blind_index (2.7.0)
|
blind_index (2.7.0)
|
||||||
activesupport (>= 7.1)
|
activesupport (>= 7.1)
|
||||||
argon2-kdf (>= 0.2)
|
argon2-kdf (>= 0.2)
|
||||||
block_cipher_kit (0.0.4)
|
block_cipher_kit (0.0.4)
|
||||||
bootsnap (1.18.6)
|
bootsnap (1.21.0)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (7.1.2)
|
brakeman (7.1.2)
|
||||||
racc
|
racc
|
||||||
browser (6.2.0)
|
browser (6.2.0)
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
|
cbor (0.5.10.1)
|
||||||
chartkick (5.2.1)
|
chartkick (5.2.1)
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
|
|
@ -157,21 +161,24 @@ GEM
|
||||||
activesupport (>= 7.2.0, < 8.2.0)
|
activesupport (>= 7.2.0, < 8.2.0)
|
||||||
railties (>= 7.2.0, < 8.2.0)
|
railties (>= 7.2.0, < 8.2.0)
|
||||||
zeitwerk (>= 2.5.0)
|
zeitwerk (>= 2.5.0)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.6)
|
||||||
connection_pool (2.5.3)
|
connection_pool (3.0.2)
|
||||||
console1984 (0.2.2)
|
console1984 (0.2.2)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
parser
|
parser
|
||||||
rails (>= 7.0)
|
rails (>= 7.0)
|
||||||
rainbow
|
rainbow
|
||||||
|
cose (1.3.1)
|
||||||
|
cbor (~> 0.5.9)
|
||||||
|
openssl-signature_algorithm (~> 1.0)
|
||||||
countries (7.1.1)
|
countries (7.1.1)
|
||||||
unaccent (~> 0.3)
|
unaccent (~> 0.3)
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
css_parser (1.21.1)
|
css_parser (1.21.1)
|
||||||
addressable
|
addressable
|
||||||
csv (3.3.5)
|
csv (3.3.5)
|
||||||
date (3.4.1)
|
date (3.5.1)
|
||||||
debug (1.10.0)
|
debug (1.11.1)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
device_detector (1.1.3)
|
device_detector (1.1.3)
|
||||||
|
|
@ -183,10 +190,10 @@ GEM
|
||||||
doorkeeper (>= 5.5, < 5.9)
|
doorkeeper (>= 5.5, < 5.9)
|
||||||
jwt (>= 2.5)
|
jwt (>= 2.5)
|
||||||
ostruct (>= 0.5)
|
ostruct (>= 0.5)
|
||||||
dotenv (3.1.8)
|
dotenv (3.2.0)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
dry-cli (1.2.0)
|
dry-cli (1.4.0)
|
||||||
erb (5.0.1)
|
erb (6.0.1)
|
||||||
erb_lint (0.9.0)
|
erb_lint (0.9.0)
|
||||||
activesupport
|
activesupport
|
||||||
better_html (>= 2.0.1)
|
better_html (>= 2.0.1)
|
||||||
|
|
@ -195,60 +202,60 @@ GEM
|
||||||
rubocop (>= 1)
|
rubocop (>= 1)
|
||||||
smart_properties
|
smart_properties
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.4.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
factory_bot (6.5.6)
|
factory_bot (6.5.6)
|
||||||
activesupport (>= 6.1.0)
|
activesupport (>= 6.1.0)
|
||||||
factory_bot_rails (6.5.1)
|
factory_bot_rails (6.5.1)
|
||||||
factory_bot (~> 6.5)
|
factory_bot (~> 6.5)
|
||||||
railties (>= 6.1.0)
|
railties (>= 6.1.0)
|
||||||
faraday (2.13.1)
|
faraday (2.14.0)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
faraday-mashify (1.0.0)
|
faraday-mashify (1.0.2)
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
hashie
|
hashie
|
||||||
faraday-multipart (1.1.0)
|
faraday-multipart (1.2.0)
|
||||||
multipart-post (~> 2.0)
|
multipart-post (~> 2.0)
|
||||||
faraday-net_http (3.4.0)
|
faraday-net_http (3.4.2)
|
||||||
net-http (>= 0.5.0)
|
net-http (~> 0.5)
|
||||||
ffi (1.17.2-aarch64-linux-gnu)
|
ffi (1.17.3-aarch64-linux-gnu)
|
||||||
ffi (1.17.2-aarch64-linux-musl)
|
ffi (1.17.3-aarch64-linux-musl)
|
||||||
ffi (1.17.2-arm-linux-gnu)
|
ffi (1.17.3-arm-linux-gnu)
|
||||||
ffi (1.17.2-arm-linux-musl)
|
ffi (1.17.3-arm-linux-musl)
|
||||||
ffi (1.17.2-arm64-darwin)
|
ffi (1.17.3-arm64-darwin)
|
||||||
ffi (1.17.2-x86_64-darwin)
|
ffi (1.17.3-x86_64-darwin)
|
||||||
ffi (1.17.2-x86_64-linux-gnu)
|
ffi (1.17.3-x86_64-linux-gnu)
|
||||||
ffi (1.17.2-x86_64-linux-musl)
|
ffi (1.17.3-x86_64-linux-musl)
|
||||||
ffi-compiler (1.3.2)
|
ffi-compiler (1.3.2)
|
||||||
ffi (>= 1.15.5)
|
ffi (>= 1.15.5)
|
||||||
rake
|
rake
|
||||||
fiddle (1.1.8)
|
fiddle (1.1.8)
|
||||||
flipper (1.3.5)
|
flipper (1.3.6)
|
||||||
concurrent-ruby (< 2)
|
concurrent-ruby (< 2)
|
||||||
flipper-active_record (1.3.5)
|
flipper-active_record (1.3.6)
|
||||||
activerecord (>= 4.2, < 9)
|
activerecord (>= 4.2, < 9)
|
||||||
flipper (~> 1.3.5)
|
flipper (~> 1.3.6)
|
||||||
flipper-ui (1.3.5)
|
flipper-ui (1.3.6)
|
||||||
erubi (>= 1.0.0, < 2.0.0)
|
erubi (>= 1.0.0, < 2.0.0)
|
||||||
flipper (~> 1.3.5)
|
flipper (~> 1.3.6)
|
||||||
rack (>= 1.4, < 4)
|
rack (>= 1.4, < 4)
|
||||||
rack-protection (>= 1.5.3, < 5.0.0)
|
rack-protection (>= 1.5.3, < 5.0.0)
|
||||||
rack-session (>= 1.0.2, < 3.0.0)
|
rack-session (>= 1.0.2, < 3.0.0)
|
||||||
sanitize (< 8)
|
sanitize (< 8)
|
||||||
front_matter_parser (1.0.1)
|
front_matter_parser (1.0.1)
|
||||||
fugit (1.11.1)
|
fugit (1.12.1)
|
||||||
et-orbi (~> 1, >= 1.2.11)
|
et-orbi (~> 1.4)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
geocoder (1.8.6)
|
geocoder (1.8.6)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
csv (>= 3.0.0)
|
csv (>= 3.0.0)
|
||||||
gli (2.22.2)
|
gli (2.22.2)
|
||||||
ostruct
|
ostruct
|
||||||
globalid (1.2.1)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
good_job (4.10.2)
|
good_job (4.13.1)
|
||||||
activejob (>= 6.1.0)
|
activejob (>= 6.1.0)
|
||||||
activerecord (>= 6.1.0)
|
activerecord (>= 6.1.0)
|
||||||
concurrent-ruby (>= 1.3.1)
|
concurrent-ruby (>= 1.3.1)
|
||||||
|
|
@ -261,30 +268,30 @@ GEM
|
||||||
activerecord (>= 4.0)
|
activerecord (>= 4.0)
|
||||||
hashids (~> 1.0)
|
hashids (~> 1.0)
|
||||||
hashids (1.0.6)
|
hashids (1.0.6)
|
||||||
hashie (5.0.0)
|
hashie (5.1.0)
|
||||||
|
logger
|
||||||
htmlentities (4.4.2)
|
htmlentities (4.4.2)
|
||||||
http (5.2.0)
|
http (5.3.1)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
base64 (~> 0.1)
|
|
||||||
http-cookie (~> 1.0)
|
http-cookie (~> 1.0)
|
||||||
http-form_data (~> 2.2)
|
http-form_data (~> 2.2)
|
||||||
llhttp-ffi (~> 0.5.0)
|
llhttp-ffi (~> 0.5.0)
|
||||||
http-cookie (1.0.8)
|
http-cookie (1.1.0)
|
||||||
domain_name (~> 0.5)
|
domain_name (~> 0.5)
|
||||||
http-form_data (2.3.0)
|
http-form_data (2.3.0)
|
||||||
i18n (1.14.7)
|
i18n (1.14.8)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.14.0)
|
image_processing (1.14.0)
|
||||||
mini_magick (>= 4.9.5, < 6)
|
mini_magick (>= 4.9.5, < 6)
|
||||||
ruby-vips (>= 2.0.17, < 3)
|
ruby-vips (>= 2.0.17, < 3)
|
||||||
io-console (0.8.0)
|
io-console (0.8.2)
|
||||||
irb (1.15.2)
|
irb (1.16.0)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jb (0.8.2)
|
jb (0.8.2)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.12.0)
|
json (2.18.0)
|
||||||
jwt (3.1.2)
|
jwt (3.1.2)
|
||||||
base64
|
base64
|
||||||
kaminari (1.2.2)
|
kaminari (1.2.2)
|
||||||
|
|
@ -312,35 +319,36 @@ GEM
|
||||||
railties (>= 6.1)
|
railties (>= 6.1)
|
||||||
rexml
|
rexml
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
literal (1.7.1)
|
literal (1.8.1)
|
||||||
zeitwerk
|
zeitwerk
|
||||||
llhttp-ffi (0.5.1)
|
llhttp-ffi (0.5.1)
|
||||||
ffi-compiler (~> 1.0)
|
ffi-compiler (~> 1.0)
|
||||||
rake (~> 13.0)
|
rake (~> 13.0)
|
||||||
lockbox (2.0.1)
|
lockbox (2.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.24.1)
|
loofah (2.25.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
lz_string (0.3.0)
|
lz_string (0.3.0)
|
||||||
mail (2.8.1)
|
mail (2.9.0)
|
||||||
|
logger
|
||||||
mini_mime (>= 0.1.1)
|
mini_mime (>= 0.1.1)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.0.4)
|
marcel (1.1.0)
|
||||||
mini-levenshtein (0.1.2)
|
mini-levenshtein (0.1.2)
|
||||||
mini_magick (5.2.0)
|
mini_magick (5.3.1)
|
||||||
benchmark
|
|
||||||
logger
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.25.5)
|
minitest (6.0.1)
|
||||||
|
prism (~> 1.5)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
net-http (0.6.0)
|
net-http (0.9.1)
|
||||||
uri
|
uri (>= 0.11.1)
|
||||||
net-imap (0.5.8)
|
net-imap (0.6.2)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
|
|
@ -350,40 +358,49 @@ GEM
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.18.8-aarch64-linux-gnu)
|
nokogiri (1.19.0-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.8-aarch64-linux-musl)
|
nokogiri (1.19.0-aarch64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.8-arm-linux-gnu)
|
nokogiri (1.19.0-arm-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.8-arm-linux-musl)
|
nokogiri (1.19.0-arm-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.8-arm64-darwin)
|
nokogiri (1.19.0-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.8-x86_64-darwin)
|
nokogiri (1.19.0-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
nokogiri (1.19.0-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.8-x86_64-linux-musl)
|
nokogiri (1.19.0-x86_64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri-xmlsec-instructure (0.12.0)
|
nokogiri-xmlsec-instructure (0.12.0)
|
||||||
nokogiri (~> 1.13)
|
nokogiri (~> 1.13)
|
||||||
openssl (3.3.2)
|
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)
|
paper_trail (16.0.0)
|
||||||
activerecord (>= 6.1)
|
activerecord (>= 6.1)
|
||||||
request_store (~> 1.4)
|
request_store (~> 1.4)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.8.0)
|
parser (3.3.10.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
pg (1.5.9)
|
pg (1.6.3)
|
||||||
phlex (2.2.1)
|
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)
|
zeitwerk (~> 2.7)
|
||||||
phlex-rails (2.2.0)
|
phlex-rails (2.3.1)
|
||||||
phlex (~> 2.2.1)
|
phlex (~> 2.3.0)
|
||||||
railties (>= 7.1, < 9)
|
railties (>= 7.1, < 9)
|
||||||
pp (0.6.2)
|
zeitwerk (~> 2.7)
|
||||||
|
pp (0.6.3)
|
||||||
prettyprint
|
prettyprint
|
||||||
premailer (1.27.0)
|
premailer (1.27.0)
|
||||||
addressable
|
addressable
|
||||||
|
|
@ -394,31 +411,30 @@ GEM
|
||||||
net-smtp
|
net-smtp
|
||||||
premailer (~> 1.7, >= 1.7.9)
|
premailer (~> 1.7, >= 1.7.9)
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.4.0)
|
prism (1.8.0)
|
||||||
propshaft (1.1.0)
|
propshaft (1.3.1)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
railties (>= 7.0.0)
|
psych (5.3.1)
|
||||||
psych (5.2.6)
|
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_activity (3.0.1)
|
public_activity (3.0.2)
|
||||||
actionpack (>= 6.1.0)
|
actionpack (>= 6.1)
|
||||||
activerecord (>= 6.1)
|
activerecord (>= 6.1)
|
||||||
i18n (>= 0.5.0)
|
i18n (>= 0.5.0)
|
||||||
railties (>= 6.1.0)
|
railties (>= 6.1)
|
||||||
public_suffix (6.0.2)
|
public_suffix (7.0.2)
|
||||||
puma (7.1.0)
|
puma (7.1.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.5.0)
|
pundit (2.5.2)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.1.15)
|
rack (3.2.4)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.8.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-protection (4.1.1)
|
rack-protection (4.2.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
logger (>= 1.6.0)
|
logger (>= 1.6.0)
|
||||||
rack (>= 3.0.0, < 4)
|
rack (>= 3.0.0, < 4)
|
||||||
|
|
@ -429,22 +445,22 @@ GEM
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
rack-test (2.2.0)
|
rack-test (2.2.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.2.1)
|
rackup (2.3.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.0.2)
|
rails (8.0.4)
|
||||||
actioncable (= 8.0.2)
|
actioncable (= 8.0.4)
|
||||||
actionmailbox (= 8.0.2)
|
actionmailbox (= 8.0.4)
|
||||||
actionmailer (= 8.0.2)
|
actionmailer (= 8.0.4)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.4)
|
||||||
actiontext (= 8.0.2)
|
actiontext (= 8.0.4)
|
||||||
actionview (= 8.0.2)
|
actionview (= 8.0.4)
|
||||||
activejob (= 8.0.2)
|
activejob (= 8.0.4)
|
||||||
activemodel (= 8.0.2)
|
activemodel (= 8.0.4)
|
||||||
activerecord (= 8.0.2)
|
activerecord (= 8.0.4)
|
||||||
activestorage (= 8.0.2)
|
activestorage (= 8.0.4)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.4)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.0.2)
|
railties (= 8.0.4)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
|
|
@ -452,33 +468,35 @@ GEM
|
||||||
rails-html-sanitizer (1.6.2)
|
rails-html-sanitizer (1.6.2)
|
||||||
loofah (~> 2.21)
|
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)
|
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
|
rack
|
||||||
railties (>= 5.1)
|
railties (>= 5.1)
|
||||||
semantic_logger (~> 4.16)
|
semantic_logger (~> 4.16)
|
||||||
railties (8.0.2)
|
railties (8.0.4)
|
||||||
actionpack (= 8.0.2)
|
actionpack (= 8.0.4)
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.4)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
thor (~> 1.0, >= 1.2.2)
|
thor (~> 1.0, >= 1.2.2)
|
||||||
|
tsort (>= 0.2)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.2.1)
|
rake (13.3.1)
|
||||||
rdoc (6.14.0)
|
rdoc (7.1.0)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
|
tsort
|
||||||
redcarpet (3.6.1)
|
redcarpet (3.6.1)
|
||||||
regexp_parser (2.10.0)
|
regexp_parser (2.11.3)
|
||||||
reline (0.6.1)
|
reline (0.6.3)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
request_store (1.7.0)
|
request_store (1.7.0)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
rexml (3.4.1)
|
rexml (3.4.4)
|
||||||
rinku (2.0.6)
|
rinku (2.0.6)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rouge (4.5.2)
|
rouge (4.7.0)
|
||||||
rqrcode (2.2.0)
|
rqrcode (2.2.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 1.0)
|
rqrcode_core (~> 1.0)
|
||||||
|
|
@ -488,7 +506,7 @@ GEM
|
||||||
rspec-expectations (3.13.5)
|
rspec-expectations (3.13.5)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-mocks (3.13.6)
|
rspec-mocks (3.13.7)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-rails (7.1.1)
|
rspec-rails (7.1.1)
|
||||||
|
|
@ -500,7 +518,7 @@ GEM
|
||||||
rspec-mocks (~> 3.13)
|
rspec-mocks (~> 3.13)
|
||||||
rspec-support (~> 3.13)
|
rspec-support (~> 3.13)
|
||||||
rspec-support (3.13.6)
|
rspec-support (3.13.6)
|
||||||
rubocop (1.75.7)
|
rubocop (1.82.1)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
|
|
@ -508,17 +526,17 @@ GEM
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.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)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.44.1)
|
rubocop-ast (1.49.0)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.7)
|
||||||
rubocop-performance (1.25.0)
|
rubocop-performance (1.26.1)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.38.0, < 2.0)
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
rubocop-rails (2.32.0)
|
rubocop-rails (2.34.3)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
|
|
@ -529,11 +547,13 @@ GEM
|
||||||
rubocop-performance (>= 1.24)
|
rubocop-performance (>= 1.24)
|
||||||
rubocop-rails (>= 2.30)
|
rubocop-rails (>= 2.30)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
ruby-vips (2.2.5)
|
ruby-vips (2.3.0)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
safely_block (0.5.0)
|
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)
|
activesupport (>= 3.2, < 8.2)
|
||||||
nokogiri (>= 1.5.8, < 2.0)
|
nokogiri (>= 1.5.8, < 2.0)
|
||||||
nokogiri-xmlsec-instructure (~> 0.9, >= 0.9.5)
|
nokogiri-xmlsec-instructure (~> 0.9, >= 0.9.5)
|
||||||
|
|
@ -541,7 +561,7 @@ GEM
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.16.8)
|
nokogiri (>= 1.16.8)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
semantic_logger (4.16.1)
|
semantic_logger (4.17.0)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
sentry-rails (5.28.1)
|
sentry-rails (5.28.1)
|
||||||
railties (>= 5.0)
|
railties (>= 5.0)
|
||||||
|
|
@ -551,8 +571,8 @@ GEM
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
serve_byte_range (1.0.0)
|
serve_byte_range (1.0.0)
|
||||||
rack (>= 1.0)
|
rack (>= 1.0)
|
||||||
slack-ruby-client (2.6.0)
|
slack-ruby-client (2.7.0)
|
||||||
faraday (>= 2.0)
|
faraday (>= 2.0.1)
|
||||||
faraday-mashify
|
faraday-mashify
|
||||||
faraday-multipart
|
faraday-multipart
|
||||||
gli
|
gli
|
||||||
|
|
@ -562,29 +582,34 @@ GEM
|
||||||
actionview (>= 6.0)
|
actionview (>= 6.0)
|
||||||
activesupport (>= 6.0)
|
activesupport (>= 6.0)
|
||||||
smart_properties (1.17.0)
|
smart_properties (1.17.0)
|
||||||
stringio (3.1.7)
|
stringio (3.2.0)
|
||||||
superform (0.5.1)
|
superform (0.5.1)
|
||||||
phlex-rails (>= 1.0, < 3.0)
|
phlex-rails (>= 1.0, < 3.0)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
thor (1.3.2)
|
thor (1.5.0)
|
||||||
thruster (0.1.13)
|
thruster (0.1.17)
|
||||||
thruster (0.1.13-aarch64-linux)
|
thruster (0.1.17-aarch64-linux)
|
||||||
thruster (0.1.13-arm64-darwin)
|
thruster (0.1.17-arm64-darwin)
|
||||||
thruster (0.1.13-x86_64-darwin)
|
thruster (0.1.17-x86_64-darwin)
|
||||||
thruster (0.1.13-x86_64-linux)
|
thruster (0.1.17-x86_64-linux)
|
||||||
timeout (0.4.3)
|
timeout (0.6.0)
|
||||||
turbo-rails (2.0.16)
|
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)
|
actionpack (>= 7.1.0)
|
||||||
railties (>= 7.1.0)
|
railties (>= 7.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
unaccent (0.4.0)
|
unaccent (0.4.0)
|
||||||
unicode-display_width (3.1.4)
|
unicode-display_width (3.2.0)
|
||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.1)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.2.0)
|
||||||
uri (1.0.3)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
vite_rails (3.0.19)
|
vite_rails (3.0.20)
|
||||||
railties (>= 5.1, < 9)
|
railties (>= 5.1, < 9)
|
||||||
vite_ruby (~> 3.0, >= 3.2.2)
|
vite_ruby (~> 3.0, >= 3.2.2)
|
||||||
vite_ruby (3.9.2)
|
vite_ruby (3.9.2)
|
||||||
|
|
@ -598,13 +623,21 @@ GEM
|
||||||
activemodel (>= 6.0.0)
|
activemodel (>= 6.0.0)
|
||||||
bindex (>= 0.4.0)
|
bindex (>= 0.4.0)
|
||||||
railties (>= 6.0.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
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
wicked (2.0.0)
|
wicked (2.0.0)
|
||||||
railties (>= 3.0.7)
|
railties (>= 3.0.7)
|
||||||
zeitwerk (2.7.3)
|
zeitwerk (2.7.4)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
aarch64-linux
|
||||||
|
|
@ -691,6 +724,7 @@ DEPENDENCIES
|
||||||
valid_email2!
|
valid_email2!
|
||||||
vite_rails
|
vite_rails
|
||||||
web-console
|
web-console
|
||||||
|
webauthn (~> 3.1)
|
||||||
wicked (~> 2.0)
|
wicked (~> 2.0)
|
||||||
|
|
||||||
BUNDLED WITH
|
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
|
- make sure you have working installations of ruby ≥ 3.4.4 & nodejs
|
||||||
- clone repo
|
- 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 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 `bundle install`
|
||||||
- run `bin/rails db:prepare`
|
- run `bin/rails db:prepare`
|
||||||
- console in (`bin/rails console`)
|
- 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 SAMLHelper
|
||||||
include SafeUrlValidation
|
include SafeUrlValidation
|
||||||
include AhoyAnalytics
|
include AhoyAnalytics
|
||||||
|
include WebauthnAuthenticatable
|
||||||
|
|
||||||
|
WEBAUTHN_SESSION_KEY = :webauthn_authentication_challenge
|
||||||
|
|
||||||
skip_before_action :authenticate_identity!
|
skip_before_action :authenticate_identity!
|
||||||
before_action :set_return_to, only: [ :new, :create ]
|
before_action :set_return_to, only: [ :new, :create ]
|
||||||
|
|
@ -44,9 +47,13 @@ class LoginsController < ApplicationController
|
||||||
same_site: :lax
|
same_site: :lax
|
||||||
}
|
}
|
||||||
|
|
||||||
send_v2_login_code(identity, attempt)
|
if identity.webauthn_enabled?
|
||||||
track_event("login.code_sent", is_signup: attempt.provenance == "signup", scenario: analytics_scenario_from_return_to(@return_to))
|
redirect_to webauthn_login_attempt_path(id: attempt.to_param), status: :see_other
|
||||||
redirect_to 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
|
rescue => e
|
||||||
flash[:error] = e.message
|
flash[:error] = e.message
|
||||||
redirect_to login_path(return_to: @return_to)
|
redirect_to login_path(return_to: @return_to)
|
||||||
|
|
@ -167,6 +174,57 @@ class LoginsController < ApplicationController
|
||||||
handle_post_verification_redirect
|
handle_post_verification_redirect
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def set_attempt
|
def set_attempt
|
||||||
|
|
@ -331,6 +389,8 @@ class LoginsController < ApplicationController
|
||||||
|
|
||||||
if available.include?(:totp)
|
if available.include?(:totp)
|
||||||
redirect_to totp_login_attempt_path(id: @attempt.to_param), status: :see_other
|
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)
|
elsif available.include?(:backup_code)
|
||||||
redirect_to backup_code_login_attempt_path(id: @attempt.to_param), status: :see_other
|
redirect_to backup_code_login_attempt_path(id: @attempt.to_param), status: :see_other
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,60 @@
|
||||||
class StepUpController < ApplicationController
|
class StepUpController < ApplicationController
|
||||||
|
include WebauthnAuthenticatable
|
||||||
|
|
||||||
helper_method :step_up_cancel_path
|
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
|
def new
|
||||||
@action = params[:action_type] # e.g., "remove_totp", "disable_2fa", "oidc_reauth", "email_change"
|
@action = params[:action_type] # e.g., "remove_totp", "disable_2fa", "oidc_reauth", "email_change"
|
||||||
@return_to = params[:return_to]
|
@return_to = params[:return_to]
|
||||||
@available_methods = current_identity.available_step_up_methods
|
@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?
|
@code_sent = params[:code_sent].present?
|
||||||
end
|
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
|
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"
|
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])
|
redirect_to new_step_up_path(action_type: params[:action_type], return_to: params[:return_to])
|
||||||
return
|
return
|
||||||
|
|
@ -37,13 +81,12 @@ class StepUpController < ApplicationController
|
||||||
return
|
return
|
||||||
end
|
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"
|
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])
|
redirect_to new_step_up_path(action_type: action_type, return_to: params[:return_to])
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Verify based on the method they chose
|
|
||||||
verified = case method
|
verified = case method
|
||||||
when :totp
|
when :totp
|
||||||
totp = current_identity.totp
|
totp = current_identity.totp
|
||||||
|
|
@ -76,43 +119,7 @@ class StepUpController < ApplicationController
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Mark step-up as completed on the identity session, bound to the specific action
|
complete_step_up(action_type, params[:return_to])
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def resend_email
|
def resend_email
|
||||||
|
|
@ -134,6 +141,50 @@ class StepUpController < ApplicationController
|
||||||
|
|
||||||
private
|
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
|
def send_step_up_email_code
|
||||||
login_code = current_identity.v2_login_codes.create!
|
login_code = current_identity.v2_login_codes.create!
|
||||||
IdentityMailer.v2_login_code(login_code).deliver_later
|
IdentityMailer.v2_login_code(login_code).deliver_later
|
||||||
|
|
@ -148,20 +199,15 @@ class StepUpController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Prevent open redirect attacks - only allow internal paths
|
|
||||||
def safe_internal_redirect(return_to)
|
def safe_internal_redirect(return_to)
|
||||||
return nil if return_to.blank?
|
return nil if return_to.blank?
|
||||||
|
|
||||||
uri = URI.parse(return_to) rescue nil
|
uri = URI.parse(return_to) rescue nil
|
||||||
return nil unless uri
|
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
|
return nil if uri.scheme || uri.host
|
||||||
|
|
||||||
# Must be a path starting with /
|
|
||||||
return nil unless uri.path&.start_with?("/")
|
return nil unless uri.path&.start_with?("/")
|
||||||
|
|
||||||
# Return just the path + query string
|
|
||||||
[ uri.path, uri.query ].compact.join("?")
|
[ uri.path, uri.query ].compact.join("?")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -34,4 +34,4 @@ window.copyErrorId = function(element) {
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('Failed to copy:', err);
|
console.error('Failed to copy:', err);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
import Alpine from 'alpinejs'
|
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
|
window.Alpine = Alpine
|
||||||
Alpine.start()
|
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;
|
flex: 1;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
|
|
||||||
@include dark-mode {
|
@include dark-mode {
|
||||||
background: #1a1d23;
|
background: #1a1d23;
|
||||||
}
|
}
|
||||||
|
|
@ -71,24 +71,23 @@
|
||||||
padding: 2.5rem;
|
padding: 2.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 610px;
|
max-width: 610px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
4px 4px 16px rgba(0, 0, 0, 0.04),
|
4px 4px 16px rgba(0, 0, 0, 0.04),
|
||||||
2px 2px 8px rgba(0, 0, 0, 0.02),
|
2px 2px 8px rgba(0, 0, 0, 0.02),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
|
|
||||||
@include dark-mode {
|
@include dark-mode {
|
||||||
background: linear-gradient(165deg, #252932 0%, #1f2329 100%);
|
background: linear-gradient(165deg, #252932 0%, #1f2329 100%);
|
||||||
border-color: rgba(255, 255, 255, 0.08);
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
4px 4px 16px rgba(0, 0, 0, 0.3),
|
4px 4px 16px rgba(0, 0, 0, 0.3),
|
||||||
2px 2px 8px rgba(0, 0, 0, 0.2),
|
2px 2px 8px rgba(0, 0, 0, 0.2),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: $space-7;
|
margin-bottom: $space-7;
|
||||||
|
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.85rem;
|
font-size: 1.85rem;
|
||||||
|
|
@ -97,19 +96,19 @@
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
small {
|
small {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--text-muted-strong);
|
color: var(--text-muted-strong);
|
||||||
display: block;
|
display: block;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--pico-primary);
|
color: var(--pico-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@include transition-default(color);
|
@include transition-default(color);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--pico-primary-hover);
|
color: var(--pico-primary-hover);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|
@ -117,24 +116,52 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
@include form-fieldset;
|
@include form-fieldset;
|
||||||
margin: 0 0 1.75rem;
|
margin: 0 0 1.75rem;
|
||||||
legend { font-size: 0.95rem; margin-bottom: $space-4; }
|
|
||||||
label { font-size: 0.9rem; margin-bottom: 0.625rem; }
|
legend {
|
||||||
input, select, textarea {
|
font-size: 0.95rem;
|
||||||
margin-bottom: $space-1; background: var(--surface-2);
|
font-weight: 600;
|
||||||
@include transition-default(background);
|
color: var(--pico-color);
|
||||||
&:focus { background: var(--surface-1); }
|
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 {
|
.grid {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
button[type="submit"],
|
button[type="submit"],
|
||||||
input[type="submit"] {
|
input[type="submit"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -143,25 +170,25 @@
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: $space-7;
|
margin-top: $space-7;
|
||||||
padding-top: $space-6;
|
padding-top: $space-6;
|
||||||
border-top: 1px solid var(--surface-2-border);
|
border-top: 1px solid var(--surface-2-border);
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: $space-1 0;
|
margin: $space-1 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-muted-strong);
|
color: var(--text-muted-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--pico-primary);
|
color: var(--pico-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@include transition-default(color);
|
@include transition-default(color);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--pico-primary-hover);
|
color: var(--pico-primary-hover);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|
@ -182,6 +209,7 @@
|
||||||
.auth-flash-wrapper {
|
.auth-flash-wrapper {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 2rem 1rem 0;
|
padding: 2rem 1rem 0;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
.banner {
|
.banner {
|
||||||
max-width: 640px;
|
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 {
|
.loading-text {
|
||||||
color: var(--text-muted-strong);
|
color: var(--text-muted-strong);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|
@ -8,7 +13,7 @@
|
||||||
.totp-disabled {
|
.totp-disabled {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: $space-6 $space-3;
|
padding: $space-6 $space-3;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
color: var(--text-muted-strong);
|
color: var(--text-muted-strong);
|
||||||
margin-bottom: $space-5;
|
margin-bottom: $space-5;
|
||||||
|
|
@ -26,28 +31,28 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $space-4;
|
gap: $space-4;
|
||||||
|
|
||||||
.status-icon {
|
.status-icon {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-info {
|
.status-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
p.status-detail {
|
p.status-detail {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-muted-strong);
|
color: var(--text-muted-strong);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
font-size: 0.875rem !important;
|
font-size: 0.875rem !important;
|
||||||
padding: $space-1 $space-3 !important;
|
padding: $space-1 $space-3 !important;
|
||||||
|
|
@ -65,13 +70,13 @@
|
||||||
.totp-setup-header {
|
.totp-setup-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: $space-5;
|
margin-bottom: $space-5;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 0 $space-1;
|
margin: 0 0 $space-1;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-indicator {
|
.step-indicator {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|
@ -94,13 +99,13 @@
|
||||||
margin-bottom: $space-5;
|
margin-bottom: $space-5;
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border-radius: $radius-lg;
|
border-radius: $radius-lg;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-bottom: $space-5;
|
margin-bottom: $space-5;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr {
|
.qr {
|
||||||
display: block;
|
display: block;
|
||||||
width: min(60vw, 200px) !important;
|
width: min(60vw, 200px) !important;
|
||||||
|
|
@ -110,15 +115,15 @@
|
||||||
padding: $space-3;
|
padding: $space-3;
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
details {
|
details {
|
||||||
margin-top: $space-5;
|
margin-top: $space-5;
|
||||||
|
|
||||||
summary {
|
summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-muted-strong);
|
color: var(--text-muted-strong);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--pico-primary);
|
color: var(--pico-primary);
|
||||||
}
|
}
|
||||||
|
|
@ -134,7 +139,7 @@
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
font-family: $font-mono;
|
font-family: $font-mono;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-secret-btn {
|
.copy-secret-btn {
|
||||||
margin-top: $space-2;
|
margin-top: $space-2;
|
||||||
font-size: 0.875rem !important;
|
font-size: 0.875rem !important;
|
||||||
|
|
@ -157,11 +162,12 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $space-3;
|
gap: $space-3;
|
||||||
margin-top: $space-5;
|
margin-top: $space-5;
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
button, input[type="submit"] {
|
button,
|
||||||
|
input[type="submit"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -171,7 +177,7 @@
|
||||||
margin-top: $space-5;
|
margin-top: $space-5;
|
||||||
padding-top: $space-5;
|
padding-top: $space-5;
|
||||||
border-top: 1px solid var(--surface-2-border);
|
border-top: 1px solid var(--surface-2-border);
|
||||||
|
|
||||||
details {
|
details {
|
||||||
summary {
|
summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -180,12 +186,12 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $space-2;
|
gap: $space-2;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--pico-primary);
|
color: var(--pico-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-top: $space-3;
|
margin-top: $space-3;
|
||||||
padding-left: $space-6;
|
padding-left: $space-6;
|
||||||
|
|
@ -201,7 +207,7 @@
|
||||||
.sessions-actions {
|
.sessions-actions {
|
||||||
margin-bottom: $space-4;
|
margin-bottom: $space-4;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
font-size: 0.875rem !important;
|
font-size: 0.875rem !important;
|
||||||
padding: $space-1 $space-3 !important;
|
padding: $space-1 $space-3 !important;
|
||||||
|
|
@ -215,19 +221,42 @@
|
||||||
gap: $space-3;
|
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 {
|
.current-session {
|
||||||
background: linear-gradient(135deg, #f7fee7, #ecfccb);
|
background: linear-gradient(135deg, #f7fee7, #ecfccb);
|
||||||
border-color: #84cc16;
|
border-color: #84cc16;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 2px 8px rgba(0, 0, 0, 0.08),
|
0 2px 8px rgba(0, 0, 0, 0.08),
|
||||||
0 1px 4px rgba(0, 0, 0, 0.04),
|
0 1px 4px rgba(0, 0, 0, 0.04),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||||
|
|
||||||
@include dark-mode {
|
@include dark-mode {
|
||||||
background: linear-gradient(135deg, rgba(132, 204, 22, 0.15), rgba(132, 204, 22, 0.08));
|
background: linear-gradient(135deg, rgba(132, 204, 22, 0.15), rgba(132, 204, 22, 0.08));
|
||||||
border-color: #84cc16;
|
border-color: #84cc16;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 2px 8px rgba(0, 0, 0, 0.3),
|
0 2px 8px rgba(0, 0, 0, 0.3),
|
||||||
0 1px 4px rgba(0, 0, 0, 0.2);
|
0 1px 4px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
@ -260,14 +289,14 @@
|
||||||
@include badge(#84cc16, #1a1d23, 4px);
|
@include badge(#84cc16, #1a1d23, 4px);
|
||||||
margin-left: $space-1;
|
margin-left: $space-1;
|
||||||
float: right;
|
float: right;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 3px rgba(0, 0, 0, 0.12),
|
0 1px 3px rgba(0, 0, 0, 0.12),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||||
|
|
||||||
@include dark-mode {
|
@include dark-mode {
|
||||||
background: #84cc16;
|
background: #84cc16;
|
||||||
color: #1a1d23;
|
color: #1a1d23;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 3px rgba(132, 204, 22, 0.3),
|
0 1px 3px rgba(132, 204, 22, 0.3),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
@ -285,7 +314,7 @@
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-muted-strong);
|
color: var(--text-muted-strong);
|
||||||
margin-bottom: $space-3;
|
margin-bottom: $space-3;
|
||||||
|
|
||||||
div {
|
div {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $space-1;
|
gap: $space-1;
|
||||||
|
|
@ -300,6 +329,7 @@
|
||||||
.backup-code-method {
|
.backup-code-method {
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
background: linear-gradient(135deg, rgba(220, 38, 38, 0.15), rgba(153, 27, 27, 0.15));
|
background: linear-gradient(135deg, rgba(220, 38, 38, 0.15), rgba(153, 27, 27, 0.15));
|
||||||
|
|
||||||
@include dark-mode {
|
@include dark-mode {
|
||||||
color: #fca5a5;
|
color: #fca5a5;
|
||||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.25), rgba(220, 38, 38, 0.25));
|
background: linear-gradient(135deg, rgba(239, 68, 68, 0.25), rgba(220, 38, 38, 0.25));
|
||||||
|
|
@ -340,12 +370,12 @@
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|
||||||
@include dark-mode {
|
@include dark-mode {
|
||||||
background: color-mix(in srgb, #d97706 15%, #1f2937 85%);
|
background: color-mix(in srgb, #d97706 15%, #1f2937 85%);
|
||||||
color: #fbbf24;
|
color: #fbbf24;
|
||||||
}
|
}
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
@ -381,16 +411,18 @@
|
||||||
gap: $space-3;
|
gap: $space-3;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: $space-5;
|
margin-bottom: $space-5;
|
||||||
|
|
||||||
button, a {
|
button,
|
||||||
|
a {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
button, a {
|
button,
|
||||||
|
a {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: unset;
|
min-width: unset;
|
||||||
}
|
}
|
||||||
|
|
@ -400,7 +432,7 @@
|
||||||
.backup-codes-confirmation {
|
.backup-codes-confirmation {
|
||||||
padding-top: $space-5;
|
padding-top: $space-5;
|
||||||
border-top: 1px solid var(--surface-2-border);
|
border-top: 1px solid var(--surface-2-border);
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -409,15 +441,15 @@
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
input[type="checkbox"] {
|
input[type="checkbox"] {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|
@ -429,23 +461,23 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $space-4;
|
gap: $space-4;
|
||||||
|
|
||||||
.status-info {
|
.status-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-detail {
|
.status-detail {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-muted-strong);
|
color: var(--text-muted-strong);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
font-size: 0.875rem !important;
|
font-size: 0.875rem !important;
|
||||||
padding: $space-1 $space-3 !important;
|
padding: $space-1 $space-3 !important;
|
||||||
|
|
@ -460,3 +492,74 @@
|
||||||
color: var(--text-muted-strong);
|
color: var(--text-muted-strong);
|
||||||
font-size: 0.95rem;
|
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 :v2_login_codes, class_name: "Identity::V2LoginCode", dependent: :destroy
|
||||||
has_many :totps, class_name: "Identity::TOTP", dependent: :destroy
|
has_many :totps, class_name: "Identity::TOTP", dependent: :destroy
|
||||||
has_many :backup_codes, class_name: "Identity::BackupCode", 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_many :email_change_requests, class_name: "Identity::EmailChangeRequest", dependent: :destroy
|
||||||
|
|
||||||
has_one :backend_user, class_name: "Backend::User", dependent: :destroy
|
has_one :backend_user, class_name: "Backend::User", dependent: :destroy
|
||||||
|
|
@ -62,10 +63,6 @@ class Identity < ApplicationRecord
|
||||||
backend_user&.active?
|
backend_user&.active?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
has_many :documents, class_name: "Identity::Document", dependent: :destroy
|
has_many :documents, class_name: "Identity::Document", dependent: :destroy
|
||||||
has_many :verifications, class_name: "Verification", dependent: :destroy
|
has_many :verifications, class_name: "Verification", dependent: :destroy
|
||||||
has_many :document_verifications, class_name: "Verification::DocumentVerification", 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 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
|
def available_step_up_methods
|
||||||
methods = []
|
methods = []
|
||||||
methods << :totp if totp.present?
|
methods << :totp if totp.present?
|
||||||
methods << :backup_code if backup_codes_enabled?
|
methods << :backup_code if backup_codes_enabled?
|
||||||
|
methods << :webauthn if webauthn_enabled?
|
||||||
# Future: methods << :sms if sms_verified?
|
# Future: methods << :sms if sms_verified?
|
||||||
methods
|
methods
|
||||||
end
|
end
|
||||||
|
|
@ -331,7 +338,8 @@ class Identity < ApplicationRecord
|
||||||
# Generic 2FA method helpers
|
# Generic 2FA method helpers
|
||||||
def two_factor_methods
|
def two_factor_methods
|
||||||
[
|
[
|
||||||
totps.verified
|
totps.verified,
|
||||||
|
webauthn_credentials
|
||||||
# Future: sms_two_factors.verified,
|
# Future: sms_two_factors.verified,
|
||||||
].flatten.compact
|
].flatten.compact
|
||||||
end
|
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
|
has_encrypted :browser_token
|
||||||
before_validation :ensure_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
|
EXPIRATION = 15.minutes
|
||||||
|
|
||||||
|
|
@ -72,9 +72,12 @@ class LoginAttempt < ApplicationRecord
|
||||||
|
|
||||||
def backup_code_available? = !authenticated_with_backup_code && identity.backup_codes_enabled?
|
def backup_code_available? = !authenticated_with_backup_code && identity.backup_codes_enabled?
|
||||||
|
|
||||||
|
def webauthn_available? = !authenticated_with_webauthn && identity.webauthn_enabled?
|
||||||
|
|
||||||
def available_factors
|
def available_factors
|
||||||
factors = []
|
factors = []
|
||||||
factors << :email if email_available?
|
factors << :email if email_available?
|
||||||
|
factors << :webauthn if webauthn_available?
|
||||||
factors << :totp if totp_available?
|
factors << :totp if totp_available?
|
||||||
factors << :backup_code if backup_code_available?
|
factors << :backup_code if backup_code_available?
|
||||||
factors
|
factors
|
||||||
|
|
@ -83,8 +86,12 @@ class LoginAttempt < ApplicationRecord
|
||||||
private
|
private
|
||||||
|
|
||||||
def required_authentication_factors_count
|
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
|
# Require 2FA if enabled AND at least one 2FA method is configured
|
||||||
if identity.requires_two_factor?
|
elsif identity.requires_two_factor?
|
||||||
2
|
2
|
||||||
else
|
else
|
||||||
1
|
1
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@
|
||||||
<% auth_methods = [] %>
|
<% 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.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.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 << t("auth_type.backup_code") if identity_session.login_attempt.authenticated_with_backup_code %>
|
||||||
<%= auth_methods.join(", ") %>
|
<%= auth_methods.join(", ") %>
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<section class="section-card">
|
<section class="section-card">
|
||||||
<h3><%= t ".applications" %></h3>
|
<h3><%= t ".mfa" %></h3>
|
||||||
<%= render Components::BootlegTurbo.new(authorized_applications_path, id: "authorized-apps-container", hx_swap: "innerHTML") %>
|
<%= render Components::BootlegTurbo.new(identity_totps_path, id: "totp-container", hx_swap: "innerHTML") %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section-card">
|
<section class="section-card">
|
||||||
<h3><%= t ".mfa" %></h3>
|
<h3><%= t ".webauthn" %></h3>
|
||||||
<%= render Components::BootlegTurbo.new(identity_totps_path, id: "totp-container", hx_swap: "innerHTML") %>
|
<%= render Components::BootlegTurbo.new(identity_webauthn_credentials_path, id: "webauthn-container", hx_swap: "innerHTML") %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section-card">
|
<section class="section-card">
|
||||||
|
|
@ -25,9 +25,14 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section-card">
|
<section class="section-card">
|
||||||
<h3>Activity Log</h3>
|
<h3><%= t ".applications" %></h3>
|
||||||
<p>View your recent account activity and security events.</p>
|
<%= render Components::BootlegTurbo.new(authorized_applications_path, id: "authorized-apps-container", hx_swap: "innerHTML") %>
|
||||||
<%= link_to "View Activity Log", audit_logs_path, class: "button" %>
|
</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>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<% if @available_methods.include?(:totp) %>
|
<% if @available_methods.include?(:totp) %>
|
||||||
<%= link_to new_step_up_path(action_type: @action, method: :totp, return_to: @return_to),
|
<%= link_to new_step_up_path(action_type: @action, method: :totp, return_to: @return_to),
|
||||||
class: "step-up-method-option" do %>
|
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="flex: 1;">
|
||||||
<div style="font-weight: 600;"><%= t(".methods.totp.title") %></div>
|
<div style="font-weight: 600;"><%= t(".methods.totp.title") %></div>
|
||||||
<small><%= t(".methods.totp.description") %></small>
|
<small><%= t(".methods.totp.description") %></small>
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
<% if @available_methods.include?(:backup_code) %>
|
<% if @available_methods.include?(:backup_code) %>
|
||||||
<%= link_to new_step_up_path(action_type: @action, method: :backup_code, return_to: @return_to),
|
<%= link_to new_step_up_path(action_type: @action, method: :backup_code, return_to: @return_to),
|
||||||
class: "step-up-method-option" do %>
|
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="flex: 1;">
|
||||||
<div style="font-weight: 600;"><%= t(".methods.backup_code.title") %></div>
|
<div style="font-weight: 600;"><%= t(".methods.backup_code.title") %></div>
|
||||||
<small><%= t(".methods.backup_code.description") %></small>
|
<small><%= t(".methods.backup_code.description") %></small>
|
||||||
|
|
@ -32,13 +32,13 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if @available_methods.include?(:sms) %>
|
<% if @available_methods.include?(:webauthn) %>
|
||||||
<%= link_to new_step_up_path(action_type: @action, method: :sms, return_to: @return_to),
|
<%= link_to new_step_up_path(action_type: @action, method: :webauthn, return_to: @return_to),
|
||||||
class: "step-up-method-option" do %>
|
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="flex: 1;">
|
||||||
<div style="font-weight: 600;"><%= t(".methods.sms.title") %></div>
|
<div style="font-weight: 600;"><%= t(".methods.webauthn.title", default: "Passkey") %></div>
|
||||||
<small><%= t(".methods.sms.description") %></small>
|
<small><%= t(".methods.webauthn.description", default: "Use your passkey to verify") %></small>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
@ -79,6 +79,42 @@
|
||||||
<p><%= link_to t(".cancel"), step_up_cancel_path(@action) %></p>
|
<p><%= link_to t(".cancel"), step_up_cancel_path(@action) %></p>
|
||||||
</footer>
|
</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 %>
|
<% else %>
|
||||||
<%# Show form for selected method %>
|
<%# Show form for selected method %>
|
||||||
<header>
|
<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
|
email: Email
|
||||||
totp: TOTP
|
totp: TOTP
|
||||||
backup_code: Backup code
|
backup_code: Backup code
|
||||||
|
webauthn: Passkey
|
||||||
logins:
|
logins:
|
||||||
new:
|
new:
|
||||||
title: Welcome back!
|
title: Welcome back!
|
||||||
|
|
@ -371,6 +372,14 @@ en:
|
||||||
verify: Verify
|
verify: Verify
|
||||||
lost_authenticator: Lost your authenticator?
|
lost_authenticator: Lost your authenticator?
|
||||||
use_backup_code: Use a backup code instead
|
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:
|
saml:
|
||||||
http_post:
|
http_post:
|
||||||
title: SAMLing...
|
title: SAMLing...
|
||||||
|
|
@ -384,10 +393,14 @@ en:
|
||||||
security_link: Security
|
security_link: Security
|
||||||
security:
|
security:
|
||||||
heading: Security
|
heading: Security
|
||||||
sessions: Active sessions
|
sessions: Active Sessions
|
||||||
applications: Linked apps
|
mfa: Authenticator App
|
||||||
mfa: 2-Factor Authentication
|
webauthn: Passkeys
|
||||||
backup_codes: Backup Codes
|
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:
|
identity_backup_codes:
|
||||||
totp_heading: "Save your backup codes"
|
totp_heading: "Save your backup codes"
|
||||||
save_warning_title: Save these codes now!
|
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.
|
require_2fa_description: When enabled, you must use a second factor (authenticator app or backup code) to sign in.
|
||||||
disable_requirement: Disable Requirement
|
disable_requirement: Disable Requirement
|
||||||
enable_requirement: Enable 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
|
setup_authenticator: Set up two-factor authentication
|
||||||
show:
|
show:
|
||||||
heading: Set up two-factor authentication
|
heading: Set up two-factor authentication
|
||||||
|
|
@ -451,6 +464,17 @@ en:
|
||||||
auth_type: Login method
|
auth_type: Login method
|
||||||
terminate: Log out
|
terminate: Log out
|
||||||
terminate_confirmation: Log out this session?
|
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:
|
step_up:
|
||||||
new:
|
new:
|
||||||
title: Gotta confirm it's you...
|
title: Gotta confirm it's you...
|
||||||
|
|
@ -462,13 +486,18 @@ en:
|
||||||
backup_code:
|
backup_code:
|
||||||
title: Backup Code
|
title: Backup Code
|
||||||
description: Use one of your backup codes
|
description: Use one of your backup codes
|
||||||
|
webauthn:
|
||||||
|
title: Passkey
|
||||||
|
description: Use your passkey to verify
|
||||||
cancel: Cancel
|
cancel: Cancel
|
||||||
enter_code_labels:
|
enter_code_labels:
|
||||||
totp: Enter authenticator code
|
totp: Enter authenticator code
|
||||||
backup_code: Enter backup code
|
backup_code: Enter backup code
|
||||||
|
webauthn: Verify with passkey
|
||||||
enter_code_descriptions:
|
enter_code_descriptions:
|
||||||
totp: Enter the 6-digit code from your authenticator app
|
totp: Enter the 6-digit code from your authenticator app
|
||||||
backup_code: Enter one of your backup codes
|
backup_code: Enter one of your backup codes
|
||||||
|
webauthn: Use your passkey to confirm your identity
|
||||||
code_label: Enter code
|
code_label: Enter code
|
||||||
backup_placeholder: XXXXXXXXXX
|
backup_placeholder: XXXXXXXXXX
|
||||||
totp_placeholder: "000000"
|
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
|
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
|
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
|
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
|
delete "/logout", to: "sessions#logout", as: :logout
|
||||||
|
|
||||||
|
|
@ -330,11 +334,19 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :identity_webauthn_credentials, only: [ :index, :new, :create, :destroy ] do
|
||||||
|
collection do
|
||||||
|
post :options
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Step-up authentication flow
|
# Step-up authentication flow
|
||||||
get "/step_up", to: "step_up#new", as: :new_step_up
|
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/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/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/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
|
resources :identity_backup_codes, only: [ :index, :create ] do
|
||||||
patch :confirm, on: :collection
|
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 "saml_debug"
|
||||||
t.boolean "is_in_workspace", default: false, null: false
|
t.boolean "is_in_workspace", default: false, null: false
|
||||||
t.string "slack_dm_channel_id"
|
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 "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 ["aadhaar_number_bidx"], name: "index_identities_on_aadhaar_number_bidx", unique: true
|
||||||
t.index ["deleted_at"], name: "index_identities_on_deleted_at"
|
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"
|
t.index ["login_attempt_id"], name: "index_identity_v2_login_codes_on_login_attempt_id"
|
||||||
end
|
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|
|
create_table "login_attempts", force: :cascade do |t|
|
||||||
t.bigint "identity_id", null: false
|
t.bigint "identity_id", null: false
|
||||||
t.bigint "session_id"
|
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"
|
t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id"
|
||||||
end
|
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_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "addresses", "identities"
|
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_totps", "identities"
|
||||||
add_foreign_key "identity_v2_login_codes", "identities"
|
add_foreign_key "identity_v2_login_codes", "identities"
|
||||||
add_foreign_key "identity_v2_login_codes", "login_attempts"
|
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", "identities"
|
||||||
add_foreign_key "login_attempts", "identity_sessions", column: "session_id"
|
add_foreign_key "login_attempts", "identity_sessions", column: "session_id"
|
||||||
add_foreign_key "oauth_access_grants", "identities", column: "resource_owner_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", "identities"
|
||||||
add_foreign_key "verifications", "identity_aadhaar_records", column: "aadhaar_record_id"
|
add_foreign_key "verifications", "identity_aadhaar_records", column: "aadhaar_record_id"
|
||||||
add_foreign_key "verifications", "identity_documents"
|
add_foreign_key "verifications", "identity_documents"
|
||||||
|
add_foreign_key "webauthn_credentials", "identities"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue