Merge branch 'master' into glitch-soc/merge-upstream

Conflicts:
- `app/controllers/accounts_controller.rb`:
  Upstream change too close to a glitch-soc change related to
  instance-local toots. Merged upstream changes.
- `app/services/fan_out_on_write_service.rb`:
  Minor conflict due to glitch-soc's handling of Direct Messages,
  merged upstream changes.
- `yarn.lock`:
  Not really a conflict, caused by glitch-soc-only dependencies
  being textually too close to updated upstream dependencies.
  Merged upstream changes.
main
Thibaut Girka 2020-08-30 16:13:08 +02:00
commit 8c3c27bf06
109 changed files with 2948 additions and 1238 deletions

View File

@ -5,7 +5,6 @@ libidn11
libidn11-dev libidn11-dev
libpq-dev libpq-dev
libprotobuf-dev libprotobuf-dev
libssl-dev
libxdamage1 libxdamage1
libxfixes3 libxfixes3
protobuf-compiler protobuf-compiler

View File

@ -20,7 +20,7 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.7' gem 'pghero', '~> 2.7'
gem 'dotenv-rails', '~> 2.7' gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.76', require: false gem 'aws-sdk-s3', '~> 1.78', require: false
gem 'fog-core', '<= 2.1.0' gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0' gem 'paperclip', '~> 6.0'
@ -56,7 +56,7 @@ gem 'fast_blank', '~> 1.0'
gem 'fastimage' gem 'fastimage'
gem 'goldfinger', '~> 2.1' gem 'goldfinger', '~> 2.1'
gem 'hiredis', '~> 0.6' gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.7' gem 'redis-namespace', '~> 1.8'
gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b' gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b'
gem 'htmlentities', '~> 4.3' gem 'htmlentities', '~> 4.3'
gem 'http', '~> 4.4' gem 'http', '~> 4.4'
@ -97,8 +97,9 @@ gem 'strong_migrations', '~> 0.7'
gem 'tty-prompt', '~> 0.22', require: false gem 'tty-prompt', '~> 0.22', require: false
gem 'twitter-text', '~> 1.14' gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2020' gem 'tzinfo-data', '~> 1.2020'
gem 'webpacker', '~> 5.1' gem 'webpacker', '~> 5.2'
gem 'webpush' gem 'webpush'
gem 'webauthn', '~> 3.0.0.alpha1'
gem 'json-ld' gem 'json-ld'
gem 'json-ld-preloaded', '~> 3.1' gem 'json-ld-preloaded', '~> 3.1'
@ -126,7 +127,7 @@ group :test do
gem 'microformats', '~> 4.2' gem 'microformats', '~> 4.2'
gem 'rails-controller-testing', '~> 1.0' gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.1' gem 'rspec-sidekiq', '~> 3.1'
gem 'simplecov', '~> 0.18', require: false gem 'simplecov', '~> 0.19', require: false
gem 'webmock', '~> 3.8' gem 'webmock', '~> 3.8'
gem 'parallel_tests', '~> 3.1' gem 'parallel_tests', '~> 3.1'
gem 'rspec_junit_formatter', '~> 0.4' gem 'rspec_junit_formatter', '~> 0.4'

View File

@ -67,6 +67,7 @@ GEM
public_suffix (>= 2.0.2, < 5.0) public_suffix (>= 2.0.2, < 5.0)
airbrussh (1.4.0) airbrussh (1.4.0)
sshkit (>= 1.6.1, != 1.7.0) sshkit (>= 1.6.1, != 1.7.0)
android_key_attestation (0.3.0)
annotate (3.1.1) annotate (3.1.1)
activerecord (>= 3.2, < 7.0) activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
@ -76,9 +77,10 @@ GEM
encryptor (~> 3.0.0) encryptor (~> 3.0.0)
av (0.9.0) av (0.9.0)
cocaine (~> 0.5.3) cocaine (~> 0.5.3)
awrence (1.1.1)
aws-eventstream (1.1.0) aws-eventstream (1.1.0)
aws-partitions (1.356.0) aws-partitions (1.358.0)
aws-sdk-core (3.104.3) aws-sdk-core (3.104.4)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0) aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
@ -86,22 +88,24 @@ GEM
aws-sdk-kms (1.36.0) aws-sdk-kms (1.36.0)
aws-sdk-core (~> 3, >= 3.99.0) aws-sdk-core (~> 3, >= 3.99.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.76.0) aws-sdk-s3 (1.78.0)
aws-sdk-core (~> 3, >= 3.104.1) aws-sdk-core (~> 3, >= 3.104.3)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sigv4 (1.2.1) aws-sigv4 (1.2.2)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
bcrypt (3.1.15) bcrypt (3.1.15)
better_errors (2.7.1) better_errors (2.7.1)
coderay (>= 1.0.0) coderay (>= 1.0.0)
erubi (>= 1.0.0) erubi (>= 1.0.0)
rack (>= 0.9.0) rack (>= 0.9.0)
bigdecimal (2.0.0)
bindata (2.4.8)
binding_of_caller (0.8.0) binding_of_caller (0.8.0)
debug_inspector (>= 0.0.1) debug_inspector (>= 0.0.1)
blurhash (0.1.4) blurhash (0.1.4)
ffi (~> 1.10.0) ffi (~> 1.10.0)
bootsnap (1.4.7) bootsnap (1.4.8)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (4.9.0) brakeman (4.9.0)
browser (4.2.0) browser (4.2.0)
@ -138,6 +142,7 @@ GEM
xpath (~> 3.2) xpath (~> 3.2)
case_transform (0.2) case_transform (0.2)
activesupport activesupport
cbor (0.5.9.6)
charlock_holmes (0.7.7) charlock_holmes (0.7.7)
chewy (5.1.0) chewy (5.1.0)
activesupport (>= 4.0) activesupport (>= 4.0)
@ -153,6 +158,9 @@ GEM
color_diff (0.1) color_diff (0.1)
concurrent-ruby (1.1.7) concurrent-ruby (1.1.7)
connection_pool (2.2.3) connection_pool (2.2.3)
cose (1.0.0)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 0.4.0)
crack (0.4.3) crack (0.4.3)
safe_yaml (~> 1.0.0) safe_yaml (~> 1.0.0)
crass (1.0.6) crass (1.0.6)
@ -188,13 +196,13 @@ GEM
railties (>= 3.2) railties (>= 3.2)
e2mmap (0.1.0) e2mmap (0.1.0)
ed25519 (1.2.4) ed25519 (1.2.4)
elasticsearch (7.8.1) elasticsearch (7.9.0)
elasticsearch-api (= 7.8.1) elasticsearch-api (= 7.9.0)
elasticsearch-transport (= 7.8.1) elasticsearch-transport (= 7.9.0)
elasticsearch-api (7.8.1) elasticsearch-api (7.9.0)
multi_json multi_json
elasticsearch-dsl (0.1.9) elasticsearch-dsl (0.1.9)
elasticsearch-transport (7.8.1) elasticsearch-transport (7.9.0)
faraday (~> 1) faraday (~> 1)
multi_json multi_json
encryptor (3.0.0) encryptor (3.0.0)
@ -299,7 +307,7 @@ GEM
json-ld (~> 3.1) json-ld (~> 3.1)
rdf (~> 3.1) rdf (~> 3.1)
jsonapi-renderer (0.2.2) jsonapi-renderer (0.2.2)
jwt (2.2.1) jwt (2.2.2)
kaminari (1.2.1) kaminari (1.2.1)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.1) kaminari-actionview (= 1.2.1)
@ -352,7 +360,7 @@ GEM
msgpack (1.3.3) msgpack (1.3.3)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.1.1) multipart-post (2.1.1)
net-ldap (0.16.2) net-ldap (0.16.3)
net-scp (3.0.0) net-scp (3.0.0)
net-ssh (>= 2.6.5, < 7.0.0) net-ssh (>= 2.6.5, < 7.0.0)
net-ssh (6.1.0) net-ssh (6.1.0)
@ -366,7 +374,8 @@ GEM
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5) sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0) statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.10.8) oj (3.10.12)
bigdecimal (>= 1.0, < 3)
omniauth (1.9.1) omniauth (1.9.1)
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 1.6.2, < 3) rack (>= 1.6.2, < 3)
@ -377,6 +386,8 @@ GEM
omniauth-saml (1.10.2) omniauth-saml (1.10.2)
omniauth (~> 1.3, >= 1.3.2) omniauth (~> 1.3, >= 1.3.2)
ruby-saml (~> 1.9) ruby-saml (~> 1.9)
openssl (2.2.0)
openssl-signature_algorithm (0.4.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ox (2.13.2) ox (2.13.2)
paperclip (6.0.0) paperclip (6.0.0)
@ -481,9 +492,9 @@ GEM
redis-activesupport (5.2.0) redis-activesupport (5.2.0)
activesupport (>= 3, < 7) activesupport (>= 3, < 7)
redis-store (>= 1.3, < 2) redis-store (>= 1.3, < 2)
redis-namespace (1.7.0) redis-namespace (1.8.0)
redis (>= 3.0.4) redis (>= 3.0.4)
redis-rack (2.1.2) redis-rack (2.1.3)
rack (>= 2.0.8, < 3) rack (>= 2.0.8, < 3)
redis-store (>= 1.2, < 2) redis-store (>= 1.2, < 2)
redis-rails (5.0.2) redis-rails (5.0.2)
@ -548,10 +559,13 @@ GEM
rufus-scheduler (3.6.0) rufus-scheduler (3.6.0)
fugit (~> 1.1, >= 1.1.6) fugit (~> 1.1, >= 1.1.6)
safe_yaml (1.0.5) safe_yaml (1.0.5)
safety_net_attestation (0.4.0)
jwt (~> 2.0)
sanitize (5.2.1) sanitize (5.2.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.8.0) nokogiri (>= 1.8.0)
nokogumbo (~> 2.0) nokogumbo (~> 2.0)
securecompare (1.0.0)
semantic_range (2.3.0) semantic_range (2.3.0)
sidekiq (6.1.1) sidekiq (6.1.1)
connection_pool (>= 2.2.2) connection_pool (>= 2.2.2)
@ -575,7 +589,7 @@ GEM
simple_form (5.0.2) simple_form (5.0.2)
actionpack (>= 5.0) actionpack (>= 5.0)
activemodel (>= 5.0) activemodel (>= 5.0)
simplecov (0.18.5) simplecov (0.19.0)
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
simplecov-html (0.12.2) simplecov-html (0.12.2)
@ -606,6 +620,9 @@ GEM
thwait (0.2.0) thwait (0.2.0)
e2mmap e2mmap
tilt (2.0.10) tilt (2.0.10)
tpm-key_attestation (0.9.0)
bindata (~> 2.4)
openssl-signature_algorithm (~> 0.4.0)
tty-color (0.5.2) tty-color (0.5.2)
tty-cursor (0.7.1) tty-cursor (0.7.1)
tty-prompt (0.22.0) tty-prompt (0.22.0)
@ -629,11 +646,21 @@ GEM
uniform_notifier (1.13.0) uniform_notifier (1.13.0)
warden (1.2.8) warden (1.2.8)
rack (>= 2.0.6) rack (>= 2.0.6)
webauthn (3.0.0.alpha1)
android_key_attestation (~> 0.3.0)
awrence (~> 1.1)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.0)
openssl (~> 2.0)
safety_net_attestation (~> 0.4.0)
securecompare (~> 1.0)
tpm-key_attestation (~> 0.9.0)
webmock (3.8.3) webmock (3.8.3)
addressable (>= 2.3.6) addressable (>= 2.3.6)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webpacker (5.1.1) webpacker (5.2.1)
activesupport (>= 5.2) activesupport (>= 5.2)
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
@ -656,7 +683,7 @@ DEPENDENCIES
active_record_query_trace (~> 1.7) active_record_query_trace (~> 1.7)
addressable (~> 2.7) addressable (~> 2.7)
annotate (~> 3.1) annotate (~> 3.1)
aws-sdk-s3 (~> 1.76) aws-sdk-s3 (~> 1.78)
better_errors (~> 2.7) better_errors (~> 2.7)
binding_of_caller (~> 0.7) binding_of_caller (~> 0.7)
blurhash (~> 0.1) blurhash (~> 0.1)
@ -749,7 +776,7 @@ DEPENDENCIES
rdf-normalize (~> 0.4) rdf-normalize (~> 0.4)
redcarpet (~> 3.4) redcarpet (~> 3.4)
redis (~> 4.2) redis (~> 4.2)
redis-namespace (~> 1.7) redis-namespace (~> 1.8)
redis-rails (~> 5.0) redis-rails (~> 5.0)
rqrcode (~> 1.1) rqrcode (~> 1.1)
rspec-rails (~> 4.0) rspec-rails (~> 4.0)
@ -765,7 +792,7 @@ DEPENDENCIES
sidekiq-unique-jobs (~> 6.0) sidekiq-unique-jobs (~> 6.0)
simple-navigation (~> 4.1) simple-navigation (~> 4.1)
simple_form (~> 5.0) simple_form (~> 5.0)
simplecov (~> 0.18) simplecov (~> 0.19)
sprockets (~> 3.7.2) sprockets (~> 3.7.2)
sprockets-rails (~> 3.2) sprockets-rails (~> 3.2)
stackprof stackprof
@ -777,6 +804,7 @@ DEPENDENCIES
tty-prompt (~> 0.22) tty-prompt (~> 0.22)
twitter-text (~> 1.14) twitter-text (~> 1.14)
tzinfo-data (~> 1.2020) tzinfo-data (~> 1.2020)
webauthn (~> 3.0.0.alpha1)
webmock (~> 3.8) webmock (~> 3.8)
webpacker (~> 5.1) webpacker (~> 5.2)
webpush webpush

View File

@ -1,4 +1,4 @@
web: if [ "$RUN_STREAMING" != "true" ]; then BIND=0.0.0.0 bundle exec puma -C config/puma.rb; else BIND=0.0.0.0 node ./streaming; fi web: bin/heroku-web
worker: bundle exec sidekiq worker: bundle exec sidekiq
# For the streaming API, you need a separate app that shares Postgres and Redis: # For the streaming API, you need a separate app that shares Postgres and Redis:

View File

@ -29,8 +29,7 @@ class AccountsController < ApplicationController
end end
@pinned_statuses = cache_collection(@account.pinned_statuses.not_local_only, Status) if show_pinned_statuses? @pinned_statuses = cache_collection(@account.pinned_statuses.not_local_only, Status) if show_pinned_statuses?
@statuses = filtered_status_page @statuses = cached_filtered_status_page
@statuses = cache_collection(@statuses, Status)
@rss_url = rss_url @rss_url = rss_url
unless @statuses.empty? unless @statuses.empty?
@ -143,8 +142,13 @@ class AccountsController < ApplicationController
request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize) request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
end end
def filtered_status_page def cached_filtered_status_page
filtered_statuses.paginate_by_id(PAGE_SIZE, params_slice(:max_id, :min_id, :since_id)) cache_collection_paginated_by_id(
filtered_statuses,
Status,
PAGE_SIZE,
params_slice(:max_id, :min_id, :since_id)
)
end end
def params_slice(*keys) def params_slice(*keys)

View File

@ -50,8 +50,12 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
return unless page_requested? return unless page_requested?
@statuses = @account.statuses.permitted_for(@account, signed_request_account) @statuses = @account.statuses.permitted_for(@account, signed_request_account)
@statuses = @statuses.paginate_by_id(LIMIT, params_slice(:max_id, :min_id, :since_id)) @statuses = cache_collection_paginated_by_id(
@statuses = cache_collection(@statuses, Status) @statuses,
Status,
LIMIT,
params_slice(:max_id, :min_id, :since_id)
)
end end
def page_requested? def page_requested?

View File

@ -22,10 +22,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
end end
def cached_account_statuses def cached_account_statuses
cache_collection account_statuses, Status
end
def account_statuses
statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
statuses.merge!(only_media_scope) if truthy_param?(:only_media) statuses.merge!(only_media_scope) if truthy_param?(:only_media)
@ -33,7 +29,12 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs) statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
statuses.merge!(hashtag_scope) if params[:tagged].present? statuses.merge!(hashtag_scope) if params[:tagged].present?
statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id)) cache_collection_paginated_by_id(
statuses,
Status,
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
end end
def permitted_account_statuses def permitted_account_statuses
@ -41,17 +42,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
end end
def only_media_scope def only_media_scope
Status.where(id: account_media_status_ids) Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)
end
def account_media_status_ids
# `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
# Also, Avoid getting slow by not narrowing down by `statuses.account_id`.
# When narrowing down by `statuses.account_id`, `index_statuses_20180106` will be used
# and the table will be joined by `Merge Semi Join`, so the query will be slow.
@account.statuses.joins(:media_attachments).merge(@account.media_attachments).permitted_for(@account, current_account)
.paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
.reorder(id: :desc).distinct(:id).pluck(:id)
end end
def pinned_scope def pinned_scope

View File

@ -17,14 +17,11 @@ class Api::V1::BookmarksController < Api::BaseController
end end
def cached_bookmarks def cached_bookmarks
cache_collection( cache_collection(results.map(&:status), Status)
Status.reorder(nil).joins(:bookmarks).merge(results),
Status
)
end end
def results def results
@_results ||= account_bookmarks.paginate_by_id( @_results ||= account_bookmarks.eager_load(:status).paginate_by_id(
limit_param(DEFAULT_STATUSES_LIMIT), limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id) params_slice(:max_id, :since_id, :min_id)
) )

View File

@ -17,14 +17,11 @@ class Api::V1::FavouritesController < Api::BaseController
end end
def cached_favourites def cached_favourites
cache_collection( cache_collection(results.map(&:status), Status)
Status.reorder(nil).joins(:favourites).merge(results),
Status
)
end end
def results def results
@_results ||= account_favourites.paginate_by_id( @_results ||= account_favourites.eager_load(:status).paginate_by_id(
limit_param(DEFAULT_STATUSES_LIMIT), limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id) params_slice(:max_id, :since_id, :min_id)
) )

View File

@ -40,11 +40,9 @@ class Api::V1::NotificationsController < Api::BaseController
private private
def load_notifications def load_notifications
cache_collection paginated_notifications, Notification cache_collection_paginated_by_id(
end browserable_account_notifications,
Notification,
def paginated_notifications
browserable_account_notifications.paginate_by_id(
limit_param(DEFAULT_NOTIFICATIONS_LIMIT), limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
params_slice(:max_id, :since_id, :min_id) params_slice(:max_id, :since_id, :min_id)
) )

View File

@ -16,25 +16,25 @@ class Api::V1::Timelines::PublicController < Api::BaseController
end end
def load_statuses def load_statuses
cached_public_statuses cached_public_statuses_page
end end
def cached_public_statuses def cached_public_statuses_page
cache_collection public_statuses, Status cache_collection_paginated_by_id(
end public_statuses,
Status,
def public_statuses
statuses = public_timeline_statuses.paginate_by_id(
limit_param(DEFAULT_STATUSES_LIMIT), limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id) params_slice(:max_id, :since_id, :min_id)
) )
end
def public_statuses
statuses = public_timeline_statuses
statuses = statuses.not_local_only unless truthy_param?(:local) || truthy_param?(:allow_local_only) statuses = statuses.not_local_only unless truthy_param?(:local) || truthy_param?(:allow_local_only)
if truthy_param?(:only_media) if truthy_param?(:only_media)
# `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids. statuses.joins(:media_attachments).group(:id)
status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id)
statuses.where(id: status_ids)
else else
statuses statuses
end end

View File

@ -20,25 +20,18 @@ class Api::V1::Timelines::TagController < Api::BaseController
end end
def cached_tagged_statuses def cached_tagged_statuses
cache_collection tagged_statuses, Status
end
def tagged_statuses
if @tag.nil? if @tag.nil?
[] []
else else
statuses = tag_timeline_statuses.paginate_by_id( statuses = tag_timeline_statuses
statuses = statuses.joins(:media_attachments) if truthy_param?(:only_media)
cache_collection_paginated_by_id(
statuses,
Status,
limit_param(DEFAULT_STATUSES_LIMIT), limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id) params_slice(:max_id, :since_id, :min_id)
) )
if truthy_param?(:only_media)
# `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id)
statuses.where(id: status_ids)
else
statuses
end
end end
end end

View File

@ -39,6 +39,22 @@ class Auth::SessionsController < Devise::SessionsController
store_location_for(:user, tmp_stored_location) if continue_after? store_location_for(:user, tmp_stored_location) if continue_after?
end end
def webauthn_options
user = find_user
if user.webauthn_enabled?
options_for_get = WebAuthn::Credential.options_for_get(
allow: user.webauthn_credentials.pluck(:external_id)
)
session[:webauthn_challenge] = options_for_get.challenge
render json: options_for_get, status: :ok
else
render json: { error: t('webauthn_credentials.not_enabled') }, status: :unauthorized
end
end
protected protected
def find_user def find_user
@ -53,7 +69,7 @@ class Auth::SessionsController < Devise::SessionsController
end end
def user_params def user_params
params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt) params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt, credential: {})
end end
def after_sign_in_path_for(resource) def after_sign_in_path_for(resource)

View File

@ -47,4 +47,8 @@ module CacheConcern
raw.map { |item| cached_keys_with_value[item.id] || uncached[item.id] }.compact raw.map { |item| cached_keys_with_value[item.id] || uncached[item.id] }.compact
end end
def cache_collection_paginated_by_id(raw, klass, limit, options)
cache_collection raw.cache_ids.paginate_by_id(limit, options), klass
end
end end

View File

@ -7,6 +7,44 @@ module SignatureVerification
include DomainControlHelper include DomainControlHelper
EXPIRATION_WINDOW_LIMIT = 12.hours
CLOCK_SKEW_MARGIN = 1.hour
class SignatureVerificationError < StandardError; end
class SignatureParamsParser < Parslet::Parser
rule(:token) { match("[0-9a-zA-Z!#$%&'*+.^_`|~-]").repeat(1).as(:token) }
rule(:quoted_string) { str('"') >> (qdtext | quoted_pair).repeat.as(:quoted_string) >> str('"') }
# qdtext and quoted_pair are not exactly according to spec but meh
rule(:qdtext) { match('[^\\\\"]') }
rule(:quoted_pair) { str('\\') >> any }
rule(:bws) { match('\s').repeat }
rule(:param) { (token.as(:key) >> bws >> str('=') >> bws >> (token | quoted_string).as(:value)).as(:param) }
rule(:comma) { bws >> str(',') >> bws }
# Old versions of node-http-signature add an incorrect "Signature " prefix to the header
rule(:buggy_prefix) { str('Signature ') }
rule(:params) { buggy_prefix.maybe >> (param >> (comma >> param).repeat).as(:params) }
root(:params)
end
class SignatureParamsTransformer < Parslet::Transform
rule(params: subtree(:p)) do
(p.is_a?(Array) ? p : [p]).each_with_object({}) { |(key, val), h| h[key] = val }
end
rule(param: { key: simple(:key), value: simple(:val) }) do
[key, val]
end
rule(quoted_string: simple(:string)) do
string.to_s
end
rule(token: simple(:string)) do
string.to_s
end
end
def require_signature! def require_signature!
render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
end end
@ -24,72 +62,40 @@ module SignatureVerification
end end
def signature_key_id def signature_key_id
raw_signature = request.headers['Signature']
signature_params = {}
raw_signature.split(',').each do |part|
parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
next if parsed_parts.nil? || parsed_parts.size != 3
signature_params[parsed_parts[1]] = parsed_parts[2]
end
signature_params['keyId'] signature_params['keyId']
rescue SignatureVerificationError
nil
end end
def signed_request_account def signed_request_account
return @signed_request_account if defined?(@signed_request_account) return @signed_request_account if defined?(@signed_request_account)
unless signed_request? raise SignatureVerificationError, 'Request not signed' unless signed_request?
@signature_verification_failure_reason = 'Request not signed' raise SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
@signed_request_account = nil raise SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
return raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
end
if request.headers['Date'].present? && !matches_time_window? verify_signature_strength!
@signature_verification_failure_reason = 'Signed request date outside acceptable time window'
@signed_request_account = nil
return
end
raw_signature = request.headers['Signature']
signature_params = {}
raw_signature.split(',').each do |part|
parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
next if parsed_parts.nil? || parsed_parts.size != 3
signature_params[parsed_parts[1]] = parsed_parts[2]
end
if incompatible_signature?(signature_params)
@signature_verification_failure_reason = 'Incompatible request signature'
@signed_request_account = nil
return
end
account = account_from_key_id(signature_params['keyId']) account = account_from_key_id(signature_params['keyId'])
if account.nil? raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil?
@signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
@signed_request_account = nil
return
end
signature = Base64.decode64(signature_params['signature']) signature = Base64.decode64(signature_params['signature'])
compare_signed_string = build_signed_string(signature_params['headers']) compare_signed_string = build_signed_string
return account unless verify_signature(account, signature, compare_signed_string).nil? return account unless verify_signature(account, signature, compare_signed_string).nil?
account = stoplight_wrap_request { account.possibly_stale? ? account.refresh! : account_refresh_key(account) } account = stoplight_wrap_request { account.possibly_stale? ? account.refresh! : account_refresh_key(account) }
if account.nil? raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil?
@signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
@signed_request_account = nil
return
end
return account unless verify_signature(account, signature, compare_signed_string).nil? return account unless verify_signature(account, signature, compare_signed_string).nil?
@signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}" @signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)"
@signed_request_account = nil
rescue SignatureVerificationError => e
@signature_verification_failure_reason = e.message
@signed_request_account = nil @signed_request_account = nil
end end
@ -99,6 +105,31 @@ module SignatureVerification
private private
def signature_params
@signature_params ||= begin
raw_signature = request.headers['Signature']
tree = SignatureParamsParser.new.parse(raw_signature)
SignatureParamsTransformer.new.apply(tree)
end
rescue Parslet::ParseFailed
raise SignatureVerificationError, 'Error parsing signature parameters'
end
def signature_algorithm
signature_params.fetch('algorithm', 'hs2019')
end
def signed_headers
signature_params.fetch('headers', signature_algorithm == 'hs2019' ? '(created)' : 'date').downcase.split(' ')
end
def verify_signature_strength!
raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest')
raise SignatureVerificationError, 'Mastodon requires the Host header to be signed' unless signed_headers.include?('host')
raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
end
def verify_signature(account, signature, compare_signed_string) def verify_signature(account, signature, compare_signed_string)
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
@signed_request_account = account @signed_request_account = account
@ -108,12 +139,20 @@ module SignatureVerification
nil nil
end end
def build_signed_string(signed_headers) def build_signed_string
signed_headers = 'date' if signed_headers.blank? signed_headers.map do |signed_header|
signed_headers.downcase.split(' ').map do |signed_header|
if signed_header == Request::REQUEST_TARGET if signed_header == Request::REQUEST_TARGET
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
elsif signed_header == '(created)'
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
"(created): #{signature_params['created']}"
elsif signed_header == '(expires)'
raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
"(expires): #{signature_params['expires']}"
elsif signed_header == 'digest' elsif signed_header == 'digest'
"digest: #{body_digest}" "digest: #{body_digest}"
else else
@ -123,13 +162,28 @@ module SignatureVerification
end end
def matches_time_window? def matches_time_window?
created_time = nil
expires_time = nil
begin begin
time_sent = Time.httpdate(request.headers['Date']) if signature_algorithm == 'hs2019' && signature_params['created'].present?
created_time = Time.at(signature_params['created'].to_i).utc
elsif request.headers['Date'].present?
created_time = Time.httpdate(request.headers['Date']).utc
end
expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
rescue ArgumentError rescue ArgumentError
return false return false
end end
(Time.now.utc - time_sent).abs <= 12.hours expires_time ||= created_time + 5.minutes unless created_time.nil?
expires_time = [expires_time, created_time + EXPIRATION_WINDOW_LIMIT].min unless created_time.nil?
return false if created_time.present? && created_time > Time.now.utc + CLOCK_SKEW_MARGIN
return false if expires_time.present? && Time.now.utc > expires_time + CLOCK_SKEW_MARGIN
true
end end
def body_digest def body_digest
@ -140,9 +194,8 @@ module SignatureVerification
name.split(/-/).map(&:capitalize).join('-') name.split(/-/).map(&:capitalize).join('-')
end end
def incompatible_signature?(signature_params) def missing_required_signature_parameters?
signature_params['keyId'].blank? || signature_params['keyId'].blank? || signature_params['signature'].blank?
signature_params['signature'].blank?
end end
def account_from_key_id(key_id) def account_from_key_id(key_id)

View File

@ -8,7 +8,23 @@ module TwoFactorAuthenticationConcern
end end
def two_factor_enabled? def two_factor_enabled?
find_user&.otp_required_for_login? find_user&.two_factor_enabled?
end
def valid_webauthn_credential?(user, webauthn_credential)
user_credential = user.webauthn_credentials.find_by!(external_id: webauthn_credential.id)
begin
webauthn_credential.verify(
session[:webauthn_challenge],
public_key: user_credential.public_key,
sign_count: user_credential.sign_count
)
user_credential.update!(sign_count: webauthn_credential.sign_count)
rescue WebAuthn::Error
false
end
end end
def valid_otp_attempt?(user) def valid_otp_attempt?(user)
@ -21,14 +37,29 @@ module TwoFactorAuthenticationConcern
def authenticate_with_two_factor def authenticate_with_two_factor
user = self.resource = find_user user = self.resource = find_user
if user_params[:otp_attempt].present? && session[:attempt_user_id] if user.webauthn_enabled? && user_params[:credential].present? && session[:attempt_user_id]
authenticate_with_two_factor_attempt(user) authenticate_with_two_factor_via_webauthn(user)
elsif user_params[:otp_attempt].present? && session[:attempt_user_id]
authenticate_with_two_factor_via_otp(user)
elsif user.present? && user.external_or_valid_password?(user_params[:password]) elsif user.present? && user.external_or_valid_password?(user_params[:password])
prompt_for_two_factor(user) prompt_for_two_factor(user)
end end
end end
def authenticate_with_two_factor_attempt(user) def authenticate_with_two_factor_via_webauthn(user)
webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential])
if valid_webauthn_credential?(user, webauthn_credential)
session.delete(:attempt_user_id)
remember_me(user)
sign_in(user)
render json: { redirect_path: root_path }, status: :ok
else
render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity
end
end
def authenticate_with_two_factor_via_otp(user)
if valid_otp_attempt?(user) if valid_otp_attempt?(user)
session.delete(:attempt_user_id) session.delete(:attempt_user_id)
remember_me(user) remember_me(user)
@ -44,6 +75,12 @@ module TwoFactorAuthenticationConcern
session[:attempt_user_id] = user.id session[:attempt_user_id] = user.id
use_pack 'auth' use_pack 'auth'
@body_classes = 'lighter' @body_classes = 'lighter'
@webauthn_enabled = user.webauthn_enabled?
@scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank?
'webauthn'
else
'totp'
end
render :two_factor render :two_factor
end end
end end

View File

@ -18,18 +18,21 @@ module Settings
end end
def create def create
if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt], otp_secret: session[:new_otp_secret])
flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success') flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success')
current_user.otp_required_for_login = true current_user.otp_required_for_login = true
current_user.otp_secret = session[:new_otp_secret]
@recovery_codes = current_user.generate_otp_backup_codes! @recovery_codes = current_user.generate_otp_backup_codes!
current_user.save! current_user.save!
UserMailer.two_factor_enabled(current_user).deliver_later! UserMailer.two_factor_enabled(current_user).deliver_later!
session.delete(:new_otp_secret)
render 'settings/two_factor_authentication/recovery_codes/index' render 'settings/two_factor_authentication/recovery_codes/index'
else else
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code') flash.now[:alert] = I18n.t('otp_authentication.wrong_code')
prepare_two_factor_form prepare_two_factor_form
render :new render :new
end end
@ -43,12 +46,15 @@ module Settings
def prepare_two_factor_form def prepare_two_factor_form
@confirmation = Form::TwoFactorConfirmation.new @confirmation = Form::TwoFactorConfirmation.new
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Rails.configuration.x.local_domain) @new_otp_secret = session[:new_otp_secret]
@provision_url = current_user.otp_provisioning_uri(current_user.email,
otp_secret: @new_otp_secret,
issuer: Rails.configuration.x.local_domain)
@qrcode = RQRCode::QRCode.new(@provision_url) @qrcode = RQRCode::QRCode.new(@provision_url)
end end
def ensure_otp_secret def ensure_otp_secret
redirect_to settings_two_factor_authentication_path unless current_user.otp_secret redirect_to settings_otp_authentication_path if session[:new_otp_secret].blank?
end end
end end
end end

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
module Settings
module TwoFactorAuthentication
class OtpAuthenticationController < BaseController
include ChallengableConcern
layout 'admin'
before_action :authenticate_user!
before_action :verify_otp_not_enabled, only: [:show]
before_action :require_challenge!, only: [:create]
skip_before_action :require_functional!
def show
@confirmation = Form::TwoFactorConfirmation.new
end
def create
session[:new_otp_secret] = User.generate_otp_secret(32)
redirect_to new_settings_two_factor_authentication_confirmation_path
end
private
def confirmation_params
params.require(:form_two_factor_confirmation).permit(:otp_attempt)
end
def verify_otp_not_enabled
redirect_to settings_two_factor_authentication_methods_path if current_user.otp_enabled?
end
def acceptable_code?
current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) ||
current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt])
end
end
end
end

View File

@ -0,0 +1,103 @@
# frozen_string_literal: true
module Settings
module TwoFactorAuthentication
class WebauthnCredentialsController < BaseController
layout 'admin'
before_action :authenticate_user!
before_action :require_otp_enabled
before_action :require_webauthn_enabled, only: [:index, :destroy]
def new; end
def index; end
def options
current_user.update(webauthn_id: WebAuthn.generate_user_id) unless current_user.webauthn_id
options_for_create = WebAuthn::Credential.options_for_create(
user: {
name: current_user.account.username,
display_name: current_user.account.username,
id: current_user.webauthn_id,
},
exclude: current_user.webauthn_credentials.pluck(:external_id)
)
session[:webauthn_challenge] = options_for_create.challenge
render json: options_for_create, status: :ok
end
def create
webauthn_credential = WebAuthn::Credential.from_create(params[:credential])
if webauthn_credential.verify(session[:webauthn_challenge])
user_credential = current_user.webauthn_credentials.build(
external_id: webauthn_credential.id,
public_key: webauthn_credential.public_key,
nickname: params[:nickname],
sign_count: webauthn_credential.sign_count
)
if user_credential.save
flash[:success] = I18n.t('webauthn_credentials.create.success')
status = :ok
if current_user.webauthn_credentials.size == 1
UserMailer.webauthn_enabled(current_user).deliver_later!
else
UserMailer.webauthn_credential_added(current_user, user_credential).deliver_later!
end
else
flash[:error] = I18n.t('webauthn_credentials.create.error')
status = :internal_server_error
end
else
flash[:error] = t('webauthn_credentials.create.error')
status = :unauthorized
end
render json: { redirect_path: settings_two_factor_authentication_methods_path }, status: status
end
def destroy
credential = current_user.webauthn_credentials.find_by(id: params[:id])
if credential
credential.destroy
if credential.destroyed?
flash[:success] = I18n.t('webauthn_credentials.destroy.success')
if current_user.webauthn_credentials.empty?
UserMailer.webauthn_disabled(current_user).deliver_later!
else
UserMailer.webauthn_credential_deleted(current_user, credential).deliver_later!
end
else
flash[:error] = I18n.t('webauthn_credentials.destroy.error')
end
else
flash[:error] = I18n.t('webauthn_credentials.destroy.error')
end
redirect_to settings_two_factor_authentication_methods_path
end
private
def require_otp_enabled
unless current_user.otp_enabled?
flash[:error] = t('webauthn_credentials.otp_required')
redirect_to settings_two_factor_authentication_methods_path
end
end
def require_webauthn_enabled
unless current_user.webauthn_enabled?
flash[:error] = t('webauthn_credentials.not_enabled')
redirect_to settings_two_factor_authentication_methods_path
end
end
end
end
end

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
module Settings
class TwoFactorAuthenticationMethodsController < BaseController
include ChallengableConcern
layout 'admin'
before_action :authenticate_user!
before_action :require_challenge!, only: :disable
before_action :require_otp_enabled
skip_before_action :require_functional!
def index; end
def disable
current_user.disable_two_factor!
UserMailer.two_factor_disabled(current_user).deliver_later!
redirect_to settings_otp_authentication_path, flash: { notice: I18n.t('two_factor_authentication.disabled_success') }
end
private
def require_otp_enabled
redirect_to settings_otp_authentication_path unless current_user.otp_enabled?
end
end
end

View File

@ -1,53 +0,0 @@
# frozen_string_literal: true
module Settings
class TwoFactorAuthenticationsController < BaseController
include ChallengableConcern
layout 'admin'
before_action :authenticate_user!
before_action :verify_otp_required, only: [:create]
before_action :require_challenge!, only: [:create]
skip_before_action :require_functional!
def show
@confirmation = Form::TwoFactorConfirmation.new
end
def create
current_user.otp_secret = User.generate_otp_secret(32)
current_user.save!
redirect_to new_settings_two_factor_authentication_confirmation_path
end
def destroy
if acceptable_code?
current_user.otp_required_for_login = false
current_user.save!
UserMailer.two_factor_disabled(current_user).deliver_later!
redirect_to settings_two_factor_authentication_path
else
flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
@confirmation = Form::TwoFactorConfirmation.new
render :show
end
end
private
def confirmation_params
params.require(:form_two_factor_confirmation).permit(:otp_attempt)
end
def verify_otp_required
redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login?
end
def acceptable_code?
current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) ||
current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt])
end
end
end

View File

@ -205,7 +205,7 @@ export default class Dropdown extends React.PureComponent {
handleClose = () => { handleClose = () => {
if (this.activeElement) { if (this.activeElement) {
this.activeElement.focus(); this.activeElement.focus({ preventScroll: true });
this.activeElement = null; this.activeElement = null;
} }
this.props.onClose(this.state.id); this.props.onClose(this.state.id);

View File

@ -54,8 +54,6 @@ export default class GIFV extends React.PureComponent {
<video <video
src={src} src={src}
width={width}
height={height}
role='button' role='button'
tabIndex='0' tabIndex='0'
aria-label={alt} aria-label={alt}

View File

@ -7,6 +7,7 @@ import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { me, isStaff } from '../initial_state'; import { me, isStaff } from '../initial_state';
import classNames from 'classnames';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -20,7 +21,7 @@ const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' }, more: { id: 'status.more', defaultMessage: 'More' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' }, reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
@ -329,7 +330,7 @@ class StatusActionBar extends ImmutablePureComponent {
return ( return (
<div className='status__action-bar'> <div className='status__action-bar'>
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div> <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
<IconButton className='status__action-bar-button' disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /> <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{shareButton} {shareButton}

View File

@ -179,7 +179,7 @@ class PrivacyDropdown extends React.PureComponent {
} else { } else {
const { top } = target.getBoundingClientRect(); const { top } = target.getBoundingClientRect();
if (this.state.open && this.activeElement) { if (this.state.open && this.activeElement) {
this.activeElement.focus(); this.activeElement.focus({ preventScroll: true });
} }
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
this.setState({ open: !this.state.open }); this.setState({ open: !this.state.open });
@ -220,7 +220,7 @@ class PrivacyDropdown extends React.PureComponent {
handleClose = () => { handleClose = () => {
if (this.state.open && this.activeElement) { if (this.state.open && this.activeElement) {
this.activeElement.focus(); this.activeElement.focus({ preventScroll: true });
} }
this.setState({ open: false }); this.setState({ open: false });
} }

View File

@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import { me, isStaff } from '../../../initial_state'; import { me, isStaff } from '../../../initial_state';
import classNames from 'classnames';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -14,7 +15,7 @@ const messages = defineMessages({
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' }, reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
@ -273,7 +274,7 @@ class ActionBar extends React.PureComponent {
return ( return (
<div className='detailed-status__action-bar'> <div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div> <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div> <div className='detailed-status__button' ><IconButton className={classNames({ reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div> <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{shareButton} {shareButton}
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div> <div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Споделяне", "status.reblog": "Споделяне",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} сподели", "status.reblogged_by": "{name} сподели",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Toud spilhennet", "status.pinned": "Toud spilhennet",
"status.read_more": "Lenn muioc'h", "status.read_more": "Lenn muioc'h",
"status.reblog": "Skignañ", "status.reblog": "Skignañ",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -421,7 +421,7 @@
"id": "status.reblog" "id": "status.reblog"
}, },
{ {
"defaultMessage": "Boost to original audience", "defaultMessage": "Boost with original visibility",
"id": "status.reblog_private" "id": "status.reblog_private"
}, },
{ {
@ -2421,7 +2421,7 @@
"id": "status.reblog" "id": "status.reblog"
}, },
{ {
"defaultMessage": "Boost to original audience", "defaultMessage": "Boost with original visibility",
"id": "status.reblog_private" "id": "status.reblog_private"
}, },
{ {

View File

@ -394,7 +394,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "הדהוד", "status.reblog": "הדהוד",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "הודהד על ידי {name}", "status.reblogged_by": "הודהד על ידי {name}",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "बूस्ट", "status.reblog": "बूस्ट",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Podigni", "status.reblog": "Podigni",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} je podigao", "status.reblogged_by": "{name} je podigao",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Repetar", "status.reblog": "Repetar",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} repetita", "status.reblogged_by": "{name} repetita",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Tijewwiqin yettwasentḍen", "status.pinned": "Tijewwiqin yettwasentḍen",
"status.read_more": "Issin ugar", "status.read_more": "Issin ugar",
"status.reblog": "Bḍu", "status.reblog": "Bḍu",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "Yebḍa-tt {name}", "status.reblogged_by": "Yebḍa-tt {name}",
"status.reblogs.empty": "Ula yiwen ur yebḍi tajewwiqt-agi ar tura. Ticki yebḍa-tt yiwen, ad d-iban da.", "status.reblogs.empty": "Ula yiwen ur yebḍi tajewwiqt-agi ar tura. Ticki yebḍa-tt yiwen, ad d-iban da.",
"status.redraft": "Kkes tɛiwdeḍ tira", "status.redraft": "Kkes tɛiwdeḍ tira",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Podrži", "status.reblog": "Podrži",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} podržao(la)", "status.reblogged_by": "{name} podržao(la)",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -389,7 +389,7 @@
"status.pinned": "Pinned toot", "status.pinned": "Pinned toot",
"status.read_more": "Read more", "status.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_private": "Boost to original audience", "status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted", "status.reblogged_by": "{name} boosted",
"status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.", "status.reblogs.empty": "No one has boosted this toot yet. When someone does, they will show up here.",
"status.redraft": "Delete & re-draft", "status.redraft": "Delete & re-draft",

View File

@ -112,11 +112,10 @@ const sharedCallbacks = {
}, },
disconnected () { disconnected () {
subscriptions.forEach(({ onDisconnect }) => onDisconnect()); subscriptions.forEach(subscription => unsubscribe(subscription));
}, },
reconnected () { reconnected () {
subscriptions.forEach(subscription => subscribe(subscription));
}, },
}; };
@ -252,15 +251,8 @@ const createConnection = (streamingAPIBaseURL, accessToken, channelName, { conne
const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${channelName}?${params.join('&')}`); const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${channelName}?${params.join('&')}`);
let firstConnect = true;
es.onopen = () => { es.onopen = () => {
if (firstConnect) {
firstConnect = false;
connected(); connected();
} else {
reconnected();
}
}; };
KNOWN_EVENT_TYPES.forEach(type => { KNOWN_EVENT_TYPES.forEach(type => {

View File

@ -0,0 +1,118 @@
import axios from 'axios';
import * as WebAuthnJSON from '@github/webauthn-json';
import ready from '../mastodon/ready';
import 'regenerator-runtime/runtime';
function getCSRFToken() {
var CSRFSelector = document.querySelector('meta[name="csrf-token"]');
if (CSRFSelector) {
return CSRFSelector.getAttribute('content');
} else {
return null;
}
}
function hideFlashMessages() {
Array.from(document.getElementsByClassName('flash-message')).forEach(function(flashMessage) {
flashMessage.classList.add('hidden');
});
}
function callback(url, body) {
axios.post(url, JSON.stringify(body), {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-Token': getCSRFToken(),
},
credentials: 'same-origin',
}).then(function(response) {
window.location.replace(response.data.redirect_path);
}).catch(function(error) {
if (error.response.status === 422) {
const errorMessage = document.getElementById('security-key-error-message');
errorMessage.classList.remove('hidden');
console.error(error.response.data.error);
} else {
console.error(error);
}
});
}
ready(() => {
if (!WebAuthnJSON.supported()) {
const unsupported_browser_message = document.getElementById('unsupported-browser-message');
if (unsupported_browser_message) {
unsupported_browser_message.classList.remove('hidden');
document.querySelector('.btn.js-webauthn').disabled = true;
}
}
const webAuthnCredentialRegistrationForm = document.getElementById('new_webauthn_credential');
if (webAuthnCredentialRegistrationForm) {
webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => {
event.preventDefault();
var nickname = event.target.querySelector('input[name="new_webauthn_credential[nickname]"]');
if (nickname.value) {
axios.get('/settings/security_keys/options')
.then((response) => {
const credentialOptions = response.data;
WebAuthnJSON.create({ 'publicKey': credentialOptions }).then((credential) => {
var params = { 'credential': credential, 'nickname': nickname.value };
callback('/settings/security_keys', params);
}).catch((error) => {
const errorMessage = document.getElementById('security-key-error-message');
errorMessage.classList.remove('hidden');
console.error(error);
});
}).catch((error) => {
console.error(error.response.data.error);
});
} else {
nickname.focus();
}
});
}
const webAuthnCredentialAuthenticationForm = document.getElementById('webauthn-form');
if (webAuthnCredentialAuthenticationForm) {
webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => {
event.preventDefault();
axios.get('sessions/security_key_options')
.then((response) => {
const credentialOptions = response.data;
WebAuthnJSON.get({ 'publicKey': credentialOptions }).then((credential) => {
var params = { 'user': { 'credential': credential } };
callback('sign_in', params);
}).catch((error) => {
const errorMessage = document.getElementById('security-key-error-message');
errorMessage.classList.remove('hidden');
console.error(error);
});
}).catch((error) => {
console.error(error.response.data.error);
});
});
const otpAuthenticationForm = document.getElementById('otp-authentication-form');
const linkToOtp = document.getElementById('link-to-otp');
linkToOtp.addEventListener('click', () => {
webAuthnCredentialAuthenticationForm.classList.add('hidden');
otpAuthenticationForm.classList.remove('hidden');
hideFlashMessages();
});
const linkToWebAuthn = document.getElementById('link-to-webauthn');
linkToWebAuthn.addEventListener('click', () => {
otpAuthenticationForm.classList.add('hidden');
webAuthnCredentialAuthenticationForm.classList.remove('hidden');
hideFlashMessages();
});
}
});

File diff suppressed because one or more lines are too long

View File

@ -12,6 +12,10 @@ code {
} }
.simple_form { .simple_form {
&.hidden {
display: none;
}
.input { .input {
margin-bottom: 15px; margin-bottom: 15px;
overflow: hidden; overflow: hidden;
@ -100,6 +104,14 @@ code {
} }
} }
.title {
color: #d9e1e8;
font-size: 20px;
line-height: 28px;
font-weight: 400;
margin-bottom: 30px;
}
.hint { .hint {
color: $darker-text-color; color: $darker-text-color;
@ -142,7 +154,7 @@ code {
} }
} }
.otp-hint { .authentication-hint {
margin-bottom: 25px; margin-bottom: 25px;
} }
@ -592,6 +604,10 @@ code {
color: $error-value-color; color: $error-value-color;
} }
&.hidden {
display: none;
}
a { a {
display: inline-block; display: inline-block;
color: $darker-text-color; color: $darker-text-color;

View File

@ -71,7 +71,15 @@ class ActivityPub::Activity
end end
def object_uri def object_uri
@object_uri ||= value_or_id(@object) @object_uri ||= begin
str = value_or_id(@object)
if str.start_with?('bear:')
Addressable::URI.parse(str).query_values['u']
else
str
end
end
end end
def unsupported_object_type? def unsupported_object_type?
@ -159,20 +167,20 @@ class ActivityPub::Activity
def dereference_object! def dereference_object!
return unless @object.is_a?(String) return unless @object.is_a?(String)
return if invalid_origin?(@object)
object = fetch_resource(@object, true, signed_fetch_account) dereferencer = ActivityPub::Dereferencer.new(@object, permitted_origin: @account.uri, signature_account: signed_fetch_account)
return unless object.present? && object.is_a?(Hash) && supported_context?(object)
@object = object @object = dereferencer.object unless dereferencer.object.nil?
end end
def signed_fetch_account def signed_fetch_account
return Account.find(@options[:delivered_to_account_id]) if @options[:delivered_to_account_id].present?
first_mentioned_local_account || first_local_follower first_mentioned_local_account || first_local_follower
end end
def first_mentioned_local_account def first_mentioned_local_account
audience = (as_array(@json['to']) + as_array(@json['cc'])).uniq audience = (as_array(@json['to']) + as_array(@json['cc'])).map { |x| value_or_id(x) }.uniq
local_usernames = audience.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) } local_usernames = audience.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }
.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) } .map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }

View File

@ -34,12 +34,20 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
private private
def audience_to
as_array(@json['to']).map { |x| value_or_id(x) }
end
def audience_cc
as_array(@json['cc']).map { |x| value_or_id(x) }
end
def visibility_from_audience def visibility_from_audience
if equals_or_includes?(@json['to'], ActivityPub::TagManager::COLLECTIONS[:public]) if audience_to.include?(ActivityPub::TagManager::COLLECTIONS[:public])
:public :public
elsif equals_or_includes?(@json['cc'], ActivityPub::TagManager::COLLECTIONS[:public]) elsif audience_cc.include?(ActivityPub::TagManager::COLLECTIONS[:public])
:unlisted :unlisted
elsif equals_or_includes?(@json['to'], @account.followers_url) elsif audience_to.include?(@account.followers_url)
:private :private
else else
:direct :direct

View File

@ -15,7 +15,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
private private
def create_encrypted_message def create_encrypted_message
return reject_payload! if invalid_origin?(@object['id']) || @options[:delivered_to_account_id].blank? return reject_payload! if invalid_origin?(object_uri) || @options[:delivered_to_account_id].blank?
target_account = Account.find(@options[:delivered_to_account_id]) target_account = Account.find(@options[:delivered_to_account_id])
target_device = target_account.devices.find_by(device_id: @object.dig('to', 'deviceId')) target_device = target_account.devices.find_by(device_id: @object.dig('to', 'deviceId'))
@ -43,7 +43,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
def create_status def create_status
return reject_payload! if unsupported_object_type? || invalid_origin?(@object['id']) || Tombstone.exists?(uri: @object['id']) || !related_to_local_activity? return reject_payload! if unsupported_object_type? || invalid_origin?(object_uri) || tombstone_exists? || !related_to_local_activity?
RedisLock.acquire(lock_options) do |lock| RedisLock.acquire(lock_options) do |lock|
if lock.acquired? if lock.acquired?
@ -65,11 +65,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
def audience_to def audience_to
@object['to'] || @json['to'] as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) }
end end
def audience_cc def audience_cc
@object['cc'] || @json['cc'] as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
end end
def process_status def process_status
@ -90,7 +90,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
fetch_replies(@status) fetch_replies(@status)
check_for_spam check_for_spam
distribute(@status) distribute(@status)
forward_for_reply if @status.distributable? forward_for_reply
end end
def find_existing_status def find_existing_status
@ -102,8 +102,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def process_status_params def process_status_params
@params = begin @params = begin
{ {
uri: @object['id'], uri: object_uri,
url: object_url || @object['id'], url: object_url || object_uri,
account: @account, account: @account,
text: text_from_content || '', text: text_from_content || '',
language: detected_language, language: detected_language,
@ -122,7 +122,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
def process_audience def process_audience
(as_array(audience_to) + as_array(audience_cc)).uniq.each do |audience| (audience_to + audience_cc).uniq.each do |audience|
next if audience == ActivityPub::TagManager::COLLECTIONS[:public] next if audience == ActivityPub::TagManager::COLLECTIONS[:public]
# Unlike with tags, there is no point in resolving accounts we don't already # Unlike with tags, there is no point in resolving accounts we don't already
@ -313,7 +313,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
RedisLock.acquire(poll_lock_options) do |lock| RedisLock.acquire(poll_lock_options) do |lock|
if lock.acquired? if lock.acquired?
already_voted = poll.votes.where(account: @account).exists? already_voted = poll.votes.where(account: @account).exists?
poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: @object['id']) poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: object_uri)
else else
raise Mastodon::RaceConditionError raise Mastodon::RaceConditionError
end end
@ -352,11 +352,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
def visibility_from_audience def visibility_from_audience
if equals_or_includes?(audience_to, ActivityPub::TagManager::COLLECTIONS[:public]) if audience_to.include?(ActivityPub::TagManager::COLLECTIONS[:public])
:public :public
elsif equals_or_includes?(audience_cc, ActivityPub::TagManager::COLLECTIONS[:public]) elsif audience_cc.include?(ActivityPub::TagManager::COLLECTIONS[:public])
:unlisted :unlisted
elsif equals_or_includes?(audience_to, @account.followers_url) elsif audience_to.include?(@account.followers_url)
:private :private
elsif direct_message == false elsif direct_message == false
:limited :limited
@ -367,7 +367,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def audience_includes?(account) def audience_includes?(account)
uri = ActivityPub::TagManager.instance.uri_for(account) uri = ActivityPub::TagManager.instance.uri_for(account)
equals_or_includes?(audience_to, uri) || equals_or_includes?(audience_cc, uri) audience_to.include?(uri) || audience_cc.include?(uri)
end end
def direct_message def direct_message
@ -391,7 +391,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
def text_from_content def text_from_content
return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || @object['id']].join(' ')) if converted_object_type? return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type?
if @object['content'].present? if @object['content'].present?
@object['content'] @object['content']
@ -483,19 +483,23 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def addresses_local_accounts? def addresses_local_accounts?
return true if @options[:delivered_to_account_id] return true if @options[:delivered_to_account_id]
local_usernames = (as_array(audience_to) + as_array(audience_cc)).uniq.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) } local_usernames = (audience_to + audience_cc).uniq.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }
return false if local_usernames.empty? return false if local_usernames.empty?
Account.local.where(username: local_usernames).exists? Account.local.where(username: local_usernames).exists?
end end
def tombstone_exists?
Tombstone.exists?(uri: object_uri)
end
def check_for_spam def check_for_spam
SpamCheck.perform(@status) SpamCheck.perform(@status)
end end
def forward_for_reply def forward_for_reply
return unless @json['signature'].present? && reply_to_local? return unless @status.distributable? && @json['signature'].present? && reply_to_local?
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url]) ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])
end end
@ -513,7 +517,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
def lock_options def lock_options
{ redis: Redis.current, key: "create:#{@object['id']}" } { redis: Redis.current, key: "create:#{object_uri}" }
end end
def poll_lock_options def poll_lock_options

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
class ActivityPub::Dereferencer
include JsonLdHelper
def initialize(uri, permitted_origin: nil, signature_account: nil)
@uri = uri
@permitted_origin = permitted_origin
@signature_account = signature_account
end
def object
@object ||= fetch_object!
end
private
def bear_cap?
@uri.start_with?('bear:')
end
def fetch_object!
if bear_cap?
fetch_with_token!
else
fetch_with_signature!
end
end
def fetch_with_token!
perform_request(bear_cap['u'], headers: { 'Authorization' => "Bearer #{bear_cap['t']}" })
end
def fetch_with_signature!
perform_request(@uri)
end
def bear_cap
@bear_cap ||= Addressable::URI.parse(@uri).query_values
end
def perform_request(uri, headers: nil)
return if invalid_origin?(uri)
req = Request.new(:get, uri)
req.add_headers('Accept' => 'application/activity+json, application/ld+json')
req.add_headers(headers) if headers
req.on_behalf_of(@signature_account) if @signature_account
req.perform do |res|
if res.code == 200
json = body_to_json(res.body_with_limit)
json if json.present? && json['id'] == uri
else
raise Mastodon::UnexpectedResponseError, res unless response_successful?(res) || response_error_unsalvageable?(res)
end
end
end
def invalid_origin?(uri)
return true if unsupported_uri_scheme?(uri)
needle = Addressable::URI.parse(uri).host
haystack = Addressable::URI.parse(@permitted_origin).host
!haystack.casecmp(needle).zero?
end
end

View File

@ -91,6 +91,52 @@ class UserMailer < Devise::Mailer
end end
end end
def webauthn_enabled(user, **)
@resource = user
@instance = Rails.configuration.x.local_domain
return if @resource.disabled?
I18n.with_locale(@resource.locale || I18n.default_locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_enabled.subject')
end
end
def webauthn_disabled(user, **)
@resource = user
@instance = Rails.configuration.x.local_domain
return if @resource.disabled?
I18n.with_locale(@resource.locale || I18n.default_locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_disabled.subject')
end
end
def webauthn_credential_added(user, webauthn_credential)
@resource = user
@instance = Rails.configuration.x.local_domain
@webauthn_credential = webauthn_credential
return if @resource.disabled?
I18n.with_locale(@resource.locale || I18n.default_locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.added.subject')
end
end
def webauthn_credential_deleted(user, webauthn_credential)
@resource = user
@instance = Rails.configuration.x.local_domain
@webauthn_credential = webauthn_credential
return if @resource.disabled?
I18n.with_locale(@resource.locale || I18n.default_locale) do
mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.deleted.subject')
end
end
def welcome(user) def welcome(user)
@resource = user @resource = user
@instance = Rails.configuration.x.local_domain @instance = Rails.configuration.x.local_domain

View File

@ -338,7 +338,7 @@ class MediaAttachment < ApplicationRecord
raise Mastodon::StreamValidationError, 'Video has no video stream' if movie.width.nil? || movie.frame_rate.nil? raise Mastodon::StreamValidationError, 'Video has no video stream' if movie.width.nil? || movie.frame_rate.nil?
raise Mastodon::DimensionsValidationError, "#{movie.width}x#{movie.height} videos are not supported" if movie.width * movie.height > MAX_VIDEO_MATRIX_LIMIT raise Mastodon::DimensionsValidationError, "#{movie.width}x#{movie.height} videos are not supported" if movie.width * movie.height > MAX_VIDEO_MATRIX_LIMIT
raise Mastodon::DimensionsValidationError, "#{movie.frame_rate.to_i}fps videos are not supported" if movie.frame_rate > MAX_VIDEO_FRAME_RATE raise Mastodon::DimensionsValidationError, "#{movie.frame_rate.floor}fps videos are not supported" if movie.frame_rate.floor > MAX_VIDEO_FRAME_RATE
end end
def set_meta def set_meta

View File

@ -40,6 +40,7 @@
# approved :boolean default(TRUE), not null # approved :boolean default(TRUE), not null
# sign_in_token :string # sign_in_token :string
# sign_in_token_sent_at :datetime # sign_in_token_sent_at :datetime
# webauthn_id :string
# #
class User < ApplicationRecord class User < ApplicationRecord
@ -77,6 +78,7 @@ class User < ApplicationRecord
has_many :backups, inverse_of: :user has_many :backups, inverse_of: :user
has_many :invites, inverse_of: :user has_many :invites, inverse_of: :user
has_many :markers, inverse_of: :user, dependent: :destroy has_many :markers, inverse_of: :user, dependent: :destroy
has_many :webauthn_credentials, dependent: :destroy
has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? } accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
@ -198,9 +200,25 @@ class User < ApplicationRecord
prepare_returning_user! prepare_returning_user!
end end
def otp_enabled?
otp_required_for_login
end
def webauthn_enabled?
webauthn_credentials.any?
end
def two_factor_enabled?
otp_required_for_login? || webauthn_credentials.any?
end
def disable_two_factor! def disable_two_factor!
self.otp_required_for_login = false self.otp_required_for_login = false
self.otp_secret = nil
otp_backup_codes&.clear otp_backup_codes&.clear
webauthn_credentials.destroy_all if webauthn_enabled?
save! save!
end end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: webauthn_credentials
#
# id :bigint(8) not null, primary key
# external_id :string not null
# public_key :string not null
# nickname :string not null
# sign_count :bigint(8) default(0), not null
# user_id :bigint(8)
# created_at :datetime not null
# updated_at :datetime not null
#
class WebauthnCredential < ApplicationRecord
validates :external_id, :public_key, :nickname, :sign_count, presence: true
validates :external_id, uniqueness: true
validates :nickname, uniqueness: { scope: :user_id }
validates :sign_count,
numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 }
end

View File

@ -8,8 +8,6 @@ class FanOutOnWriteService < BaseService
deliver_to_self(status) if status.account.local? deliver_to_self(status) if status.account.local?
render_anonymous_payload(status)
if status.direct_visibility? if status.direct_visibility?
deliver_to_mentioned_followers(status) deliver_to_mentioned_followers(status)
deliver_to_direct_timelines(status) deliver_to_direct_timelines(status)
@ -24,6 +22,8 @@ class FanOutOnWriteService < BaseService
return if status.account.silenced? || !status.public_visibility? return if status.account.silenced? || !status.public_visibility?
return if status.reblog? && !Setting.show_reblogs_in_public_timelines return if status.reblog? && !Setting.show_reblogs_in_public_timelines
render_anonymous_payload(status)
deliver_to_hashtags(status) deliver_to_hashtags(status)
return if status.reply? && status.in_reply_to_account_id != status.account_id && !Setting.show_replies_in_public_timelines return if status.reply? && status.in_reply_to_account_id != status.account_id && !Setting.show_replies_in_public_timelines
@ -63,10 +63,12 @@ class FanOutOnWriteService < BaseService
def deliver_to_mentioned_followers(status) def deliver_to_mentioned_followers(status)
Rails.logger.debug "Delivering status #{status.id} to limited followers" Rails.logger.debug "Delivering status #{status.id} to limited followers"
FeedInsertWorker.push_bulk(status.mentions.includes(:account).map(&:account).select { |mentioned_account| mentioned_account.local? && mentioned_account.following?(status.account) }) do |follower| status.mentions.joins(:account).merge(status.account.followers_for_local_distribution).select(:id).reorder(nil).find_in_batches do |followers|
FeedInsertWorker.push_bulk(followers) do |follower|
[status.id, follower.id, :home] [status.id, follower.id, :home]
end end
end end
end
def render_anonymous_payload(status) def render_anonymous_payload(status)
@payload = InlineRenderer.render(status, nil, :status) @payload = InlineRenderer.render(status, nil, :status)

View File

@ -8,7 +8,7 @@ class HashtagQueryService < BaseService
all = tags_for(params[:all]) all = tags_for(params[:all])
none = tags_for(params[:none]) none = tags_for(params[:none])
Status.distinct Status.group(:id)
.as_tag_timeline(tags, account, local) .as_tag_timeline(tags, account, local)
.tagged_with_all(all) .tagged_with_all(all)
.tagged_with_none(none) .tagged_with_none(none)

View File

@ -1,14 +1,9 @@
- content_for :page_title do - content_for :page_title do
= t('auth.login') = t('auth.login')
= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| =javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous'
%p.hint.otp-hint= t('simple_form.hints.sessions.otp')
.fields-group - if @webauthn_enabled
= f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, autofocus: true = render partial: 'auth/sessions/two_factor/webauthn_form', locals: { hidden: @scheme_type != 'webauthn' }
.actions = render partial: 'auth/sessions/two_factor/otp_authentication_form', locals: { hidden: @scheme_type != 'totp' }
= f.button :button, t('auth.login'), type: :submit
- if Setting.site_contact_email.present?
%p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil))

View File

@ -0,0 +1,18 @@
= simple_form_for(resource,
as: resource_name,
url: session_path(resource_name),
html: { method: :post, id: 'otp-authentication-form' }.merge(hidden ? { class: 'hidden' } : {})) do |f|
%p.hint.authentication-hint= t('simple_form.hints.sessions.otp')
.fields-group
= f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, autofocus: true
.actions
= f.button :button, t('auth.login'), type: :submit
- if Setting.site_contact_email.present?
%p.hint.subtle-hint= t('users.otp_lost_help_html', email: mail_to(Setting.site_contact_email, nil))
- if @webauthn_enabled
.form-footer
= link_to(t('auth.link_to_webauth'), '#', id: 'link-to-webauthn')

View File

@ -0,0 +1,17 @@
%p.flash-message.hidden#unsupported-browser-message= t 'webauthn_credentials.not_supported'
%p.flash-message.alert.hidden#security-key-error-message= t 'webauthn_credentials.invalid_credential'
= simple_form_for(resource,
as: resource_name,
url: session_path(resource_name),
html: { method: :post, id: 'webauthn-form' }.merge(hidden ? { class: 'hidden' } : {})) do |f|
%h3.title= t('simple_form.title.sessions.webauthn')
%p.hint= t('simple_form.hints.sessions.webauthn')
.actions
= f.button :button, t('auth.use_security_key'), class: 'js-webauthn', type: :submit
.form-footer
%p= t('auth.dont_have_your_security_key')
= link_to(t('auth.link_to_otp'), '#', id: 'link-to-otp')

View File

@ -2,17 +2,17 @@
= t('settings.two_factor_authentication') = t('settings.two_factor_authentication')
= simple_form_for @confirmation, url: settings_two_factor_authentication_confirmation_path, method: :post do |f| = simple_form_for @confirmation, url: settings_two_factor_authentication_confirmation_path, method: :post do |f|
%p.hint= t('two_factor_authentication.instructions_html') %p.hint= t('otp_authentication.instructions_html')
.qr-wrapper .qr-wrapper
.qr-code!= @qrcode.as_svg(padding: 0, module_size: 4) .qr-code!= @qrcode.as_svg(padding: 0, module_size: 4)
.qr-alternative .qr-alternative
%p.hint= t('two_factor_authentication.manual_instructions') %p.hint= t('otp_authentication.manual_instructions')
%samp.qr-alternative__code= current_user.otp_secret.scan(/.{4}/).join(' ') %samp.qr-alternative__code= @new_otp_secret.scan(/.{4}/).join(' ')
.fields-group .fields-group
= f.input :otp_attempt, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true = f.input :otp_attempt, wrapper: :with_label, hint: t('otp_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
.actions .actions
= f.button :button, t('two_factor_authentication.enable'), type: :submit = f.button :button, t('otp_authentication.enable'), type: :submit

View File

@ -0,0 +1,9 @@
- content_for :page_title do
= t('settings.two_factor_authentication')
.simple_form
%p.hint= t('otp_authentication.description_html')
%hr.spacer/
= link_to t('otp_authentication.setup'), settings_otp_authentication_path, data: { method: :post }, class: 'block-button'

View File

@ -0,0 +1,17 @@
- content_for :page_title do
= t('settings.webauthn_authentication')
.table-wrapper
%table.table
%tbody
- current_user.webauthn_credentials.each do |credential|
%tr
%td= credential.nickname
%td= t('webauthn_credentials.registered_on', date: l(credential.created_at.to_date, format: :with_month_name))
%td
= table_link_to 'trash', t('webauthn_credentials.delete'), settings_webauthn_credential_path(credential.id), method: :delete, data: { confirm: t('webauthn_credentials.delete_confirmation') }
%hr.spacer/
.simple_form
= link_to t('webauthn_credentials.add'), new_settings_webauthn_credential_path, class: 'block-button'

View File

@ -0,0 +1,16 @@
- content_for :page_title do
= t('settings.webauthn_authentication')
= simple_form_for(:new_webauthn_credential, url: settings_webauthn_credentials_path, html: { id: :new_webauthn_credential }) do |f|
%p.flash-message.hidden#unsupported-browser-message= t 'webauthn_credentials.not_supported'
%p.flash-message.alert.hidden#security-key-error-message= t 'webauthn_credentials.invalid_credential'
%p.hint= t('webauthn_credentials.description_html')
.fields_group
= f.input :nickname, wrapper: :with_block_label, hint: t('webauthn_credentials.nickname_hint'), input_html: { :autocomplete => 'off' }, required: true
.actions
= f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit
= javascript_pack_tag 'two_factor_authentication', integrity: true, crossorigin: 'anonymous'

View File

@ -0,0 +1,41 @@
- content_for :page_title do
= t('settings.two_factor_authentication')
- content_for :heading_actions do
= link_to t('two_factor_authentication.disable'), disable_settings_two_factor_authentication_methods_path, class: 'button button--destructive', method: :post
%p.hint
%span.positive-hint
= fa_icon 'check'
= ' '
= t 'two_factor_authentication.enabled'
.table-wrapper
%table.table
%thead
%tr
%th= t('two_factor_authentication.methods')
%th
%tbody
%tr
%td= t('two_factor_authentication.otp')
%td
= table_link_to 'pencil', t('two_factor_authentication.edit'), settings_otp_authentication_path, method: :post
%tr
%td= t('two_factor_authentication.webauthn')
- if current_user.webauthn_enabled?
%td
= table_link_to 'pencil', t('two_factor_authentication.edit'), settings_webauthn_credentials_path, method: :get
- else
%td
= table_link_to 'key', t('two_factor_authentication.add'), new_settings_webauthn_credential_path, method: :get
%hr.spacer/
%h3= t('two_factor_authentication.recovery_codes')
%p.muted-hint= t('two_factor_authentication.lost_recovery_codes')
%hr.spacer/
.simple_form
= link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button'

View File

@ -1,36 +0,0 @@
- content_for :page_title do
= t('settings.two_factor_authentication')
- if current_user.otp_required_for_login
%p.hint
%span.positive-hint
= fa_icon 'check'
= ' '
= t 'two_factor_authentication.enabled'
%hr.spacer/
= simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f|
.fields-group
= f.input :otp_attempt, wrapper: :with_block_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
.actions
= f.button :button, t('two_factor_authentication.disable'), type: :submit, class: 'negative'
%hr.spacer/
%h3= t('two_factor_authentication.recovery_codes')
%p.muted-hint= t('two_factor_authentication.lost_recovery_codes')
%hr.spacer/
.simple_form
= link_to t('two_factor_authentication.generate_recovery_codes'), settings_two_factor_authentication_recovery_codes_path, data: { method: :post }, class: 'block-button'
- else
.simple_form
%p.hint= t('two_factor_authentication.description_html')
%hr.spacer/
= link_to t('two_factor_authentication.setup'), settings_two_factor_authentication_path, data: { method: :post }, class: 'block-button'

View File

@ -0,0 +1,44 @@
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.hero
.email-row
.col-6
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.text-center.padded
%table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td
= image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
%h1= t 'devise.mailer.webauthn_credential.added.title'
%p.lead= "#{t 'devise.mailer.webauthn_credential.added.explanation' }:"
%p.lead= @webauthn_credential.nickname
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.content-start
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.button-cell
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.button-primary
= link_to edit_user_registration_url do
%span= t('settings.account_settings')

View File

@ -0,0 +1,7 @@
<%= t 'devise.mailer.two_factor_enabled.title' %>
===
<%= t 'devise.mailer.two_factor_enabled.explanation' %>
=> <%= edit_user_registration_url %>

View File

@ -0,0 +1,44 @@
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.hero
.email-row
.col-6
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.text-center.padded
%table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td
= image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
%h1= t 'devise.mailer.webauthn_credential.deleted.title'
%p.lead= "#{t 'devise.mailer.webauthn_credential.deleted.explanation' }:"
%p.lead= @webauthn_credential.nickname
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.content-start
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.button-cell
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.button-primary
= link_to edit_user_registration_url do
%span= t('settings.account_settings')

View File

@ -0,0 +1,7 @@
<%= t 'devise.mailer.webauthn_credential.deleted.title' %>
===
<%= t 'devise.mailer.webauthn_credential.deleted.explanation' %>
=> <%= edit_user_registration_url %>

View File

@ -0,0 +1,43 @@
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.hero
.email-row
.col-6
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.text-center.padded
%table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td
= image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
%h1= t 'devise.mailer.webauthn_disabled.title'
%p.lead= t 'devise.mailer.webauthn_disabled.explanation'
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.content-start
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.button-cell
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.button-primary
= link_to edit_user_registration_url do
%span= t('settings.account_settings')

View File

@ -0,0 +1,7 @@
<%= t 'devise.mailer.webauthn_disabled.title' %>
===
<%= t 'devise.mailer.webauthn_disabled.explanation' %>
=> <%= edit_user_registration_url %>

View File

@ -0,0 +1,43 @@
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.hero
.email-row
.col-6
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.text-center.padded
%table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td
= image_tag full_pack_url('media/images/mailer/icon_lock_open.png'), alt: ''
%h1= t 'devise.mailer.webauthn_enabled.title'
%p.lead= t 'devise.mailer.webauthn_enabled.explanation'
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.content-start
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.button-cell
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.button-primary
= link_to edit_user_registration_url do
%span= t('settings.account_settings')

View File

@ -0,0 +1,7 @@
<%= t 'devise.mailer.webauthn_credentia.added.title' %>
===
<%= t 'devise.mailer.webauthn_credentia.added.explanation' %>
=> <%= edit_user_registration_url %>

2
bin/heroku-web Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash
if [ "$RUN_STREAMING" != "true" ]; then BIND=0.0.0.0 bundle exec puma -C config/puma.rb; else BIND=0.0.0.0 node ./streaming; fi

View File

@ -0,0 +1,24 @@
WebAuthn.configure do |config|
# This value needs to match `window.location.origin` evaluated by
# the User Agent during registration and authentication ceremonies.
config.origin = "#{Rails.configuration.x.use_https ? 'https' : 'http' }://#{Rails.configuration.x.web_domain}"
# Relying Party name for display purposes
config.rp_name = "Mastodon"
# Optionally configure a client timeout hint, in milliseconds.
# This hint specifies how long the browser should wait for an
# attestation or an assertion response.
# This hint may be overridden by the browser.
# https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-timeout
config.credential_options_timeout = 120_000
# You can optionally specify a different Relying Party ID
# (https://www.w3.org/TR/webauthn/#relying-party-identifier)
# if it differs from the default one.
#
# In this case the default would be "auth.example.com", but you can set it to
# the suffix "example.com"
#
# config.rp_id = "example.com"
end

View File

@ -60,6 +60,23 @@ en:
title: 2FA recovery codes changed title: 2FA recovery codes changed
unlock_instructions: unlock_instructions:
subject: 'Mastodon: Unlock instructions' subject: 'Mastodon: Unlock instructions'
webauthn_credential:
added:
explanation: The following security key has been added to your account
subject: 'Mastodon: New security key'
title: A new security key has been added
deleted:
explanation: The following security key has been deleted from your account
subject: 'Mastodon: Security key deleted'
title: One of you security keys has been deleted
webauthn_disabled:
explanation: Authentication with security keys has been disabled for your account. Login is now possible using only the token generated by the paired TOTP app.
subject: 'Mastodon: Authentication with security keys disabled'
title: Security keys disabled
webauthn_enabled:
explanation: Security key authentication has been enabled for your account. Your security key can now be used for login.
subject: 'Mastodon: Security key authentication enabled'
title: Security keys enabled
omniauth_callbacks: omniauth_callbacks:
failure: Could not authenticate you from %{kind} because "%{reason}". failure: Could not authenticate you from %{kind} because "%{reason}".
success: Successfully authenticated from %{kind} account. success: Successfully authenticated from %{kind} account.

View File

@ -681,8 +681,11 @@ en:
prefix_sign_up: Sign up on Mastodon today! prefix_sign_up: Sign up on Mastodon today!
suffix: With an account, you will be able to follow people, post updates and exchange messages with users from any Mastodon server and more! suffix: With an account, you will be able to follow people, post updates and exchange messages with users from any Mastodon server and more!
didnt_get_confirmation: Didn't receive confirmation instructions? didnt_get_confirmation: Didn't receive confirmation instructions?
dont_have_your_security_key: Don't have your security key?
forgot_password: Forgot your password? forgot_password: Forgot your password?
invalid_reset_password_token: Password reset token is invalid or expired. Please request a new one. invalid_reset_password_token: Password reset token is invalid or expired. Please request a new one.
link_to_otp: Enter a two-factor code from your phone or a recovery code
link_to_webauth: Use your security key device
login: Log in login: Log in
logout: Logout logout: Logout
migrate_account: Move to a different account migrate_account: Move to a different account
@ -708,6 +711,7 @@ en:
pending: Your application is pending review by our staff. This may take some time. You will receive an e-mail if your application is approved. pending: Your application is pending review by our staff. This may take some time. You will receive an e-mail if your application is approved.
redirecting_to: Your account is inactive because it is currently redirecting to %{acct}. redirecting_to: Your account is inactive because it is currently redirecting to %{acct}.
trouble_logging_in: Trouble logging in? trouble_logging_in: Trouble logging in?
use_security_key: Use security key
authorize_follow: authorize_follow:
already_following: You are already following this account already_following: You are already following this account
already_requested: You have already sent a follow request to that account already_requested: You have already sent a follow request to that account
@ -732,6 +736,7 @@ en:
date: date:
formats: formats:
default: "%b %d, %Y" default: "%b %d, %Y"
with_month_name: "%B %d, %Y"
datetime: datetime:
distance_in_words: distance_in_words:
about_x_hours: "%{count}h" about_x_hours: "%{count}h"
@ -993,6 +998,14 @@ en:
thousand: K thousand: K
trillion: T trillion: T
unit: '' unit: ''
otp_authentication:
code_hint: Enter the code generated by your authenticator app to confirm
description_html: If you enable <strong>two-factor authentication</strong> using an authenticator app, logging in will require you to be in possession of your phone, which will generate tokens for you to enter.
enable: Enable
instructions_html: "<strong>Scan this QR code into Google Authenticator or a similiar TOTP app on your phone</strong>. From now on, that app will generate tokens that you will have to enter when logging in."
manual_instructions: 'If you can''t scan the QR code and need to enter it manually, here is the plain-text secret:'
setup: Set up
wrong_code: The entered code was invalid! Are server time and device time correct?
pagination: pagination:
newer: Newer newer: Newer
next: Next next: Next
@ -1117,6 +1130,7 @@ en:
profile: Profile profile: Profile
relationships: Follows and followers relationships: Follows and followers
two_factor_authentication: Two-factor Auth two_factor_authentication: Two-factor Auth
webauthn_authentication: Security keys
spam_check: spam_check:
spam_detected: This is an automated report. Spam has been detected. spam_detected: This is an automated report. Spam has been detected.
statuses: statuses:
@ -1263,21 +1277,20 @@ en:
default: "%b %d, %Y, %H:%M" default: "%b %d, %Y, %H:%M"
month: "%b %Y" month: "%b %Y"
two_factor_authentication: two_factor_authentication:
code_hint: Enter the code generated by your authenticator app to confirm add: Add
description_html: If you enable <strong>two-factor authentication</strong>, logging in will require you to be in possession of your phone, which will generate tokens for you to enter. disable: Disable 2FA
disable: Disable disabled_success: Two-factor authentication successfully disabled
enable: Enable edit: Edit
enabled: Two-factor authentication is enabled enabled: Two-factor authentication is enabled
enabled_success: Two-factor authentication successfully enabled enabled_success: Two-factor authentication successfully enabled
generate_recovery_codes: Generate recovery codes generate_recovery_codes: Generate recovery codes
instructions_html: "<strong>Scan this QR code into Google Authenticator or a similiar TOTP app on your phone</strong>. From now on, that app will generate tokens that you will have to enter when logging in."
lost_recovery_codes: Recovery codes allow you to regain access to your account if you lose your phone. If you've lost your recovery codes, you can regenerate them here. Your old recovery codes will be invalidated. lost_recovery_codes: Recovery codes allow you to regain access to your account if you lose your phone. If you've lost your recovery codes, you can regenerate them here. Your old recovery codes will be invalidated.
manual_instructions: 'If you can''t scan the QR code and need to enter it manually, here is the plain-text secret:' methods: Two-factor methods
otp: Authenticator app
recovery_codes: Backup recovery codes recovery_codes: Backup recovery codes
recovery_codes_regenerated: Recovery codes successfully regenerated recovery_codes_regenerated: Recovery codes successfully regenerated
recovery_instructions_html: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. <strong>Keep the recovery codes safe</strong>. For example, you may print them and store them with other important documents. recovery_instructions_html: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. <strong>Keep the recovery codes safe</strong>. For example, you may print them and store them with other important documents.
setup: Set up webauthn: Security keys
wrong_code: The entered code was invalid! Are server time and device time correct?
user_mailer: user_mailer:
backup_ready: backup_ready:
explanation: You requested a full backup of your Mastodon account. It's now ready for download! explanation: You requested a full backup of your Mastodon account. It's now ready for download!
@ -1339,3 +1352,20 @@ en:
verification: verification:
explanation_html: 'You can <strong>verify yourself as the owner of the links in your profile metadata</strong>. For that, the linked website must contain a link back to your Mastodon profile. The link back <strong>must</strong> have a <code>rel="me"</code> attribute. The text content of the link does not matter. Here is an example:' explanation_html: 'You can <strong>verify yourself as the owner of the links in your profile metadata</strong>. For that, the linked website must contain a link back to your Mastodon profile. The link back <strong>must</strong> have a <code>rel="me"</code> attribute. The text content of the link does not matter. Here is an example:'
verification: Verification verification: Verification
webauthn_credentials:
add: Add new security key
create:
error: There was a problem adding your security key. Please try again.
success: Your security key was successfully added.
delete: Delete
delete_confirmation: Are you sure you want to delete this security key?
description_html: If you enable <strong>security key authentication</strong>, logging in will require you to use one of your security keys.
destroy:
error: There was a problem deleting you security key. Please try again.
success: Your security key was successfully deleted.
invalid_credential: Invalid security key
nickname_hint: Enter the nickname of your new security key
not_enabled: You haven't enabled WebAuthn yet
not_supported: This browser doesn't support security keys
otp_required: To use security keys please enable two-factor authentication first.
registered_on: Registered on %{date}

View File

@ -67,6 +67,7 @@ en:
text: This will help us review your application text: This will help us review your application
sessions: sessions:
otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:' otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:'
webauthn: If it's an USB key be sure to insert it and, if necessary, tap it.
tag: tag:
name: You can only change the casing of the letters, for example, to make it more readable name: You can only change the casing of the letters, for example, to make it more readable
user: user:
@ -188,4 +189,7 @@ en:
required: required:
mark: "*" mark: "*"
text: required text: required
title:
sessions:
webauthn: Use one of your security keys to sign in
'yes': 'Yes' 'yes': 'Yes'

View File

@ -27,7 +27,7 @@ SimpleNavigation::Configuration.run do |navigation|
n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s| n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s|
s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases} s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases}
s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_url, highlights_on: %r{/settings/two_factor_authentication} s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_methods_url, highlights_on: %r{/settings/two_factor_authentication|/settings/security_keys}
s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url
end end

View File

@ -45,6 +45,7 @@ Rails.application.routes.draw do
namespace :auth do namespace :auth do
resource :setup, only: [:show, :update], controller: :setup resource :setup, only: [:show, :update], controller: :setup
resource :challenge, only: [:create], controller: :challenges resource :challenge, only: [:create], controller: :challenges
get 'sessions/security_key_options', to: 'sessions#webauthn_options'
end end
end end
@ -124,7 +125,22 @@ Rails.application.routes.draw do
resources :domain_blocks, only: :index, controller: :blocked_domains resources :domain_blocks, only: :index, controller: :blocked_domains
end end
resource :two_factor_authentication, only: [:show, :create, :destroy] resources :two_factor_authentication_methods, only: [:index] do
collection do
post :disable
end
end
resource :otp_authentication, only: [:show, :create], controller: 'two_factor_authentication/otp_authentication'
resources :webauthn_credentials, only: [:index, :new, :create, :destroy],
path: 'security_keys',
controller: 'two_factor_authentication/webauthn_credentials' do
collection do
get :options
end
end
namespace :two_factor_authentication do namespace :two_factor_authentication do
resources :recovery_codes, only: [:create] resources :recovery_codes, only: [:create]

View File

@ -87,7 +87,7 @@ module.exports = merge(sharedConfig, {
'**/*.woff', '**/*.woff',
], ],
ServiceWorker: { ServiceWorker: {
entry: `imports-loader?ATTACHMENT_HOST=>${encodeURIComponent(JSON.stringify(attachmentHost))}!${encodeURI(path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'))}`, entry: `imports-loader?additionalCode=${encodeURIComponent(`var ATTACHMENT_HOST=${JSON.stringify(attachmentHost)};`)}!${encodeURI(path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'))}`,
cacheName: 'mastodon', cacheName: 'mastodon',
output: '../assets/sw.js', output: '../assets/sw.js',
publicPath: '/sw.js', publicPath: '/sw.js',

View File

@ -0,0 +1,16 @@
class CreateWebauthnCredentials < ActiveRecord::Migration[5.2]
def change
create_table :webauthn_credentials do |t|
t.string :external_id, null: false
t.string :public_key, null: false
t.string :nickname, null: false
t.bigint :sign_count, null: false, default: 0
t.index :external_id, unique: true
t.references :user, foreign_key: true
t.timestamps
end
end
end

View File

@ -0,0 +1,5 @@
class AddWebauthnIdToUsers < ActiveRecord::Migration[5.2]
def change
add_column :users, :webauthn_id, :string
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_06_28_133322) do ActiveRecord::Schema.define(version: 2020_06_30_190544) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -883,6 +883,7 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
t.boolean "approved", default: true, null: false t.boolean "approved", default: true, null: false
t.string "sign_in_token" t.string "sign_in_token"
t.datetime "sign_in_token_sent_at" t.datetime "sign_in_token_sent_at"
t.string "webauthn_id"
t.index ["account_id"], name: "index_users_on_account_id" t.index ["account_id"], name: "index_users_on_account_id"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id" t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id"
@ -912,6 +913,18 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
t.index ["user_id"], name: "index_web_settings_on_user_id", unique: true t.index ["user_id"], name: "index_web_settings_on_user_id", unique: true
end end
create_table "webauthn_credentials", force: :cascade do |t|
t.string "external_id", null: false
t.string "public_key", null: false
t.string "nickname", null: false
t.bigint "sign_count", default: 0, null: false
t.bigint "user_id"
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 ["user_id"], name: "index_webauthn_credentials_on_user_id"
end
add_foreign_key "account_aliases", "accounts", on_delete: :cascade add_foreign_key "account_aliases", "accounts", on_delete: :cascade
add_foreign_key "account_conversations", "accounts", on_delete: :cascade add_foreign_key "account_conversations", "accounts", on_delete: :cascade
add_foreign_key "account_conversations", "conversations", on_delete: :cascade add_foreign_key "account_conversations", "conversations", on_delete: :cascade
@ -1010,4 +1023,5 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
add_foreign_key "web_push_subscriptions", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade add_foreign_key "web_push_subscriptions", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade
add_foreign_key "web_push_subscriptions", "users", on_delete: :cascade add_foreign_key "web_push_subscriptions", "users", on_delete: :cascade
add_foreign_key "web_settings", "users", name: "fk_11910667b2", on_delete: :cascade add_foreign_key "web_settings", "users", name: "fk_11910667b2", on_delete: :cascade
add_foreign_key "webauthn_credentials", "users"
end end

View File

@ -31,7 +31,7 @@ module Mastodon
processed, aggregate = parallelize_with_progress(MediaAttachment.cached.where.not(remote_url: '').where('created_at < ?', time_ago)) do |media_attachment| processed, aggregate = parallelize_with_progress(MediaAttachment.cached.where.not(remote_url: '').where('created_at < ?', time_ago)) do |media_attachment|
next if media_attachment.file.blank? next if media_attachment.file.blank?
size = media_attachment.file_file_size + (media_attachment.thumbnail_file_size || 0) size = (media_attachment.file_file_size || 0) + (media_attachment.thumbnail_file_size || 0)
unless options[:dry_run] unless options[:dry_run]
media_attachment.file.destroy media_attachment.file.destroy

View File

@ -64,17 +64,18 @@
"@babel/plugin-proposal-class-properties": "^7.8.3", "@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-decorators": "^7.10.5", "@babel/plugin-proposal-decorators": "^7.10.5",
"@babel/plugin-transform-react-inline-elements": "^7.10.4", "@babel/plugin-transform-react-inline-elements": "^7.10.4",
"@babel/plugin-transform-runtime": "^7.10.5", "@babel/plugin-transform-runtime": "^7.11.0",
"@babel/preset-env": "^7.11.0", "@babel/preset-env": "^7.11.0",
"@babel/preset-react": "^7.10.4", "@babel/preset-react": "^7.10.4",
"@babel/runtime": "^7.8.4", "@babel/runtime": "^7.11.2",
"@clusterws/cws": "^2.0.0", "@clusterws/cws": "^3.0.0",
"@gamestdio/websocket": "^0.3.2", "@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^0.4.2",
"@rails/ujs": "^6.0.3", "@rails/ujs": "^6.0.3",
"array-includes": "^3.1.1", "array-includes": "^3.1.1",
"atrament": "0.2.4", "atrament": "0.2.4",
"arrow-key-navigation": "^1.2.0", "arrow-key-navigation": "^1.2.0",
"autoprefixer": "^9.8.5", "autoprefixer": "^9.8.6",
"axios": "^0.19.2", "axios": "^0.19.2",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"babel-plugin-lodash": "^3.3.4", "babel-plugin-lodash": "^3.3.4",
@ -84,10 +85,10 @@
"babel-runtime": "^6.26.0", "babel-runtime": "^6.26.0",
"blurhash": "^1.1.3", "blurhash": "^1.1.3",
"classnames": "^2.2.5", "classnames": "^2.2.5",
"compression-webpack-plugin": "^4.0.0", "compression-webpack-plugin": "^5.0.1",
"copy-webpack-plugin": "^6.0.2", "copy-webpack-plugin": "^6.0.3",
"cross-env": "^7.0.2", "cross-env": "^7.0.2",
"css-loader": "^3.6.0", "css-loader": "^4.2.2",
"cssnano": "^4.1.10", "cssnano": "^4.1.10",
"detect-passive-events": "^1.0.2", "detect-passive-events": "^1.0.2",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
@ -103,8 +104,8 @@
"history": "^4.10.1", "history": "^4.10.1",
"http-link-header": "^1.0.2", "http-link-header": "^1.0.2",
"immutable": "^3.8.2", "immutable": "^3.8.2",
"imports-loader": "^0.8.0", "imports-loader": "^1.1.0",
"intersection-observer": "^0.10.0", "intersection-observer": "^0.11.0",
"intl": "^1.2.5", "intl": "^1.2.5",
"intl-messageformat": "^2.2.0", "intl-messageformat": "^2.2.0",
"intl-relativeformat": "^6.4.3", "intl-relativeformat": "^6.4.3",
@ -150,12 +151,13 @@
"redux": "^4.0.5", "redux": "^4.0.5",
"redux-immutable": "^4.0.0", "redux-immutable": "^4.0.0",
"redux-thunk": "^2.2.0", "redux-thunk": "^2.2.0",
"regenerator-runtime": "^0.13.7",
"rellax": "^1.12.1", "rellax": "^1.12.1",
"requestidlecallback": "^0.3.0", "requestidlecallback": "^0.3.0",
"reselect": "^4.0.0", "reselect": "^4.0.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"sass": "^1.26.10", "sass": "^1.26.10",
"sass-loader": "^8.0.2", "sass-loader": "^9.0.3",
"stacktrace-js": "^2.0.2", "stacktrace-js": "^2.0.2",
"stringz": "^2.1.0", "stringz": "^2.1.0",
"substring-trie": "^1.0.2", "substring-trie": "^1.0.2",
@ -164,7 +166,7 @@
"throng": "^4.0.0", "throng": "^4.0.0",
"tiny-queue": "^0.2.1", "tiny-queue": "^0.2.1",
"uuid": "^8.2.0", "uuid": "^8.2.0",
"webpack": "^4.44.0", "webpack": "^4.44.1",
"webpack-assets-manifest": "^3.1.1", "webpack-assets-manifest": "^3.1.1",
"webpack-bundle-analyzer": "^3.8.0", "webpack-bundle-analyzer": "^3.8.0",
"webpack-cli": "^3.3.12", "webpack-cli": "^3.3.12",
@ -175,13 +177,13 @@
"@testing-library/jest-dom": "^5.11.2", "@testing-library/jest-dom": "^5.11.2",
"@testing-library/react": "^10.4.7", "@testing-library/react": "^10.4.7",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-jest": "^26.1.0", "babel-jest": "^26.3.0",
"eslint": "^7.6.0", "eslint": "^7.6.0",
"eslint-plugin-import": "~2.22.0", "eslint-plugin-import": "~2.22.0",
"eslint-plugin-jsx-a11y": "~6.3.1", "eslint-plugin-jsx-a11y": "~6.3.1",
"eslint-plugin-promise": "~4.2.1", "eslint-plugin-promise": "~4.2.1",
"eslint-plugin-react": "~7.20.4", "eslint-plugin-react": "~7.20.4",
"jest": "^26.2.2", "jest": "^26.4.2",
"raf": "^3.4.1", "raf": "^3.4.1",
"react-intl-translations-manager": "^5.0.3", "react-intl-translations-manager": "^5.0.3",
"react-test-renderer": "^16.13.1", "react-test-renderer": "^16.13.1",

View File

@ -1,20 +1,51 @@
require 'rails_helper' require 'rails_helper'
require 'webauthn/fake_client'
describe Admin::TwoFactorAuthenticationsController do describe Admin::TwoFactorAuthenticationsController do
render_views render_views
let(:user) { Fabricate(:user, otp_required_for_login: true) } let(:user) { Fabricate(:user) }
before do before do
sign_in Fabricate(:user, admin: true), scope: :user sign_in Fabricate(:user, admin: true), scope: :user
end end
describe 'DELETE #destroy' do describe 'DELETE #destroy' do
context 'when user has OTP enabled' do
before do
user.update(otp_required_for_login: true)
end
it 'redirects to admin accounts page' do it 'redirects to admin accounts page' do
delete :destroy, params: { user_id: user.id } delete :destroy, params: { user_id: user.id }
user.reload user.reload
expect(user.otp_required_for_login).to eq false expect(user.otp_enabled?).to eq false
expect(response).to redirect_to(admin_accounts_path)
end
end
context 'when user has OTP and WebAuthn enabled' do
let(:fake_client) { WebAuthn::FakeClient.new('http://test.host') }
before do
user.update(otp_required_for_login: true, webauthn_id: WebAuthn.generate_user_id)
public_key_credential = WebAuthn::Credential.from_create(fake_client.create)
Fabricate(:webauthn_credential,
user_id: user.id,
external_id: public_key_credential.id,
public_key: public_key_credential.public_key,
nickname: 'Security Key')
end
it 'redirects to admin accounts page' do
delete :destroy, params: { user_id: user.id }
user.reload
expect(user.otp_enabled?).to eq false
expect(user.webauthn_enabled?).to eq false
expect(response).to redirect_to(admin_accounts_path) expect(response).to redirect_to(admin_accounts_path)
end end
end end
end end
end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
require 'webauthn/fake_client'
RSpec.describe Auth::SessionsController, type: :controller do RSpec.describe Auth::SessionsController, type: :controller do
render_views render_views
@ -183,6 +184,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
end end
context 'using two-factor authentication' do context 'using two-factor authentication' do
context 'with OTP enabled as second factor' do
let!(:user) do let!(:user) do
Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32)) Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
end end
@ -200,6 +202,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
it 'renders two factor authentication page' do it 'renders two factor authentication page' do
expect(controller).to render_template("two_factor") expect(controller).to render_template("two_factor")
expect(controller).to render_template(partial: "_otp_authentication_form")
end end
end end
@ -210,6 +213,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
it 'renders two factor authentication page' do it 'renders two factor authentication page' do
expect(controller).to render_template("two_factor") expect(controller).to render_template("two_factor")
expect(controller).to render_template(partial: "_otp_authentication_form")
end end
end end
@ -271,6 +275,83 @@ RSpec.describe Auth::SessionsController, type: :controller do
end end
end end
context 'with WebAuthn and OTP enabled as second factor' do
let!(:user) do
Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
end
let!(:recovery_codes) do
codes = user.generate_otp_backup_codes!
user.save
return codes
end
let!(:webauthn_credential) do
user.update(webauthn_id: WebAuthn.generate_user_id)
public_key_credential = WebAuthn::Credential.from_create(fake_client.create)
user.webauthn_credentials.create(
nickname: 'SecurityKeyNickname',
external_id: public_key_credential.id,
public_key: public_key_credential.public_key,
sign_count: '1000'
)
user.webauthn_credentials.take
end
let(:domain) { "#{Rails.configuration.x.use_https ? 'https' : 'http' }://#{Rails.configuration.x.web_domain}" }
let(:fake_client) { WebAuthn::FakeClient.new(domain) }
let(:challenge) { WebAuthn::Credential.options_for_get.challenge }
let(:sign_count) { 1234 }
let(:fake_credential) { fake_client.get(challenge: challenge, sign_count: sign_count) }
context 'using email and password' do
before do
post :create, params: { user: { email: user.email, password: user.password } }
end
it 'renders webauthn authentication page' do
expect(controller).to render_template("two_factor")
expect(controller).to render_template(partial: "_webauthn_form")
end
end
context 'using upcase email and password' do
before do
post :create, params: { user: { email: user.email.upcase, password: user.password } }
end
it 'renders webauthn authentication page' do
expect(controller).to render_template("two_factor")
expect(controller).to render_template(partial: "_webauthn_form")
end
end
context 'using a valid webauthn credential' do
before do
@controller.session[:webauthn_challenge] = challenge
post :create, params: { user: { credential: fake_credential } }, session: { attempt_user_id: user.id }
end
it 'instructs the browser to redirect to home' do
expect(body_as_json[:redirect_path]).to eq(root_path)
end
it 'logs the user in' do
expect(controller.current_user).to eq user
end
it 'updates the sign count' do
expect(webauthn_credential.reload.sign_count).to eq(sign_count)
end
end
end
end
context 'when 2FA is disabled and IP is unfamiliar' do context 'when 2FA is disabled and IP is unfamiliar' do
let!(:user) { Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', current_sign_in_at: 3.weeks.ago, current_sign_in_ip: '0.0.0.0') } let!(:user) { Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', current_sign_in_at: 3.weeks.ago, current_sign_in_ip: '0.0.0.0') }

View File

@ -5,8 +5,6 @@ require 'rails_helper'
describe Settings::TwoFactorAuthentication::ConfirmationsController do describe Settings::TwoFactorAuthentication::ConfirmationsController do
render_views render_views
let(:user) { Fabricate(:user, email: 'local-part@domain', otp_secret: 'thisisasecretforthespecofnewview') }
let(:user_without_otp_secret) { Fabricate(:user, email: 'local-part@domain') }
shared_examples 'renders :new' do shared_examples 'renders :new' do
it 'renders the new view' do it 'renders the new view' do
@ -20,11 +18,14 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
end end
end end
[true, false].each do |with_otp_secret|
let(:user) { Fabricate(:user, email: 'local-part@domain', otp_secret: with_otp_secret ? 'oldotpsecret' : nil) }
describe 'GET #new' do describe 'GET #new' do
context 'when signed in' do context 'when signed in and a new otp secret has been setted in the session' do
subject do subject do
sign_in user, scope: :user sign_in user, scope: :user
get :new, session: { challenge_passed_at: Time.now.utc } get :new, session: { challenge_passed_at: Time.now.utc, new_otp_secret: 'thisisasecretforthespecofnewview' }
end end
include_examples 'renders :new' include_examples 'renders :new'
@ -35,10 +36,10 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
expect(response).to redirect_to('/auth/sign_in') expect(response).to redirect_to('/auth/sign_in')
end end
it 'redirects if user do not have otp_secret' do it 'redirects if a new otp_secret has not been setted in the session' do
sign_in user_without_otp_secret, scope: :user sign_in user, scope: :user
get :new, session: { challenge_passed_at: Time.now.utc } get :new, session: { challenge_passed_at: Time.now.utc }
expect(response).to redirect_to('/settings/two_factor_authentication') expect(response).to redirect_to('/settings/otp_authentication')
end end
end end
@ -50,7 +51,7 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
describe 'when form_two_factor_confirmation parameter is not provided' do describe 'when form_two_factor_confirmation parameter is not provided' do
it 'raises ActionController::ParameterMissing' do it 'raises ActionController::ParameterMissing' do
post :create, params: {}, session: { challenge_passed_at: Time.now.utc } post :create, params: {}, session: { challenge_passed_at: Time.now.utc, new_otp_secret: 'thisisasecretforthespecofnewview' }
expect(response).to have_http_status(400) expect(response).to have_http_status(400)
end end
end end
@ -62,13 +63,18 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
expect(value).to eq user expect(value).to eq user
otp_backup_codes otp_backup_codes
end end
expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, arg| expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, code, options|
expect(value).to eq user expect(value).to eq user
expect(arg).to eq '123456' expect(code).to eq '123456'
expect(options).to eq({ otp_secret: 'thisisasecretforthespecofnewview' })
true true
end end
post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }, session: { challenge_passed_at: Time.now.utc } expect do
post :create,
params: { form_two_factor_confirmation: { otp_attempt: '123456' } },
session: { challenge_passed_at: Time.now.utc, new_otp_secret: 'thisisasecretforthespecofnewview' }
end.to change { user.reload.otp_secret }.to 'thisisasecretforthespecofnewview'
expect(assigns(:recovery_codes)).to eq otp_backup_codes expect(assigns(:recovery_codes)).to eq otp_backup_codes
expect(flash[:notice]).to eq 'Two-factor authentication successfully enabled' expect(flash[:notice]).to eq 'Two-factor authentication successfully enabled'
@ -79,13 +85,18 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
describe 'when creation fails' do describe 'when creation fails' do
subject do subject do
expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, arg| expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, code, options|
expect(value).to eq user expect(value).to eq user
expect(arg).to eq '123456' expect(code).to eq '123456'
expect(options).to eq({ otp_secret: 'thisisasecretforthespecofnewview' })
false false
end end
post :create, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }, session: { challenge_passed_at: Time.now.utc } expect do
post :create,
params: { form_two_factor_confirmation: { otp_attempt: '123456' } },
session: { challenge_passed_at: Time.now.utc, new_otp_secret: 'thisisasecretforthespecofnewview' }
end.to not_change { user.reload.otp_secret }
end end
it 'renders the new view' do it 'renders the new view' do
@ -105,3 +116,4 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
end end
end end
end end
end

View File

@ -0,0 +1,99 @@
# frozen_string_literal: true
require 'rails_helper'
describe Settings::TwoFactorAuthentication::OtpAuthenticationController do
render_views
let(:user) { Fabricate(:user) }
describe 'GET #show' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
describe 'when user has OTP enabled' do
before do
user.update(otp_required_for_login: true)
end
it 'redirects to two factor authentciation methods list page' do
get :show
expect(response).to redirect_to settings_two_factor_authentication_methods_path
end
end
describe 'when user does not have OTP enabled' do
before do
user.update(otp_required_for_login: false)
end
it 'returns http success' do
get :show
expect(response).to have_http_status(200)
end
end
end
context 'when not signed in' do
it 'redirects' do
get :show
expect(response).to redirect_to new_user_session_path
end
end
end
describe 'POST #create' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
describe 'when user has OTP enabled' do
before do
user.update(otp_required_for_login: true)
end
describe 'when creation succeeds' do
it 'redirects to code confirmation page without updating user secret and setting otp secret in the session' do
expect do
post :create, session: { challenge_passed_at: Time.now.utc }
end.to not_change { user.reload.otp_secret }
.and change { session[:new_otp_secret] }
expect(response).to redirect_to(new_settings_two_factor_authentication_confirmation_path)
end
end
end
describe 'when user does not have OTP enabled' do
before do
user.update(otp_required_for_login: false)
end
describe 'when creation succeeds' do
it 'redirects to code confirmation page without updating user secret and setting otp secret in the session' do
expect do
post :create, session: { challenge_passed_at: Time.now.utc }
end.to not_change { user.reload.otp_secret }
.and change { session[:new_otp_secret] }
expect(response).to redirect_to(new_settings_two_factor_authentication_confirmation_path)
end
end
end
end
context 'when not signed in' do
it 'redirects to login' do
get :show
expect(response).to redirect_to new_user_session_path
end
end
end
end

View File

@ -0,0 +1,374 @@
# frozen_string_literal: true
require 'rails_helper'
require 'webauthn/fake_client'
describe Settings::TwoFactorAuthentication::WebauthnCredentialsController do
render_views
let(:user) { Fabricate(:user) }
let(:domain) { "#{Rails.configuration.x.use_https ? 'https' : 'http' }://#{Rails.configuration.x.web_domain}" }
let(:fake_client) { WebAuthn::FakeClient.new(domain) }
def add_webauthn_credential(user)
Fabricate(:webauthn_credential, user_id: user.id, nickname: 'USB Key')
end
describe 'GET #new' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
context 'when user has otp enabled' do
before do
user.update(otp_required_for_login: true)
end
it 'returns http success' do
get :new
expect(response).to have_http_status(200)
end
end
context 'when user does not have otp enabled' do
before do
user.update(otp_required_for_login: false)
end
it 'requires otp enabled first' do
get :new
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
end
describe 'GET #index' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
context 'when user has otp enabled' do
before do
user.update(otp_required_for_login: true)
end
context 'when user has webauthn enabled' do
before do
user.update(webauthn_id: WebAuthn.generate_user_id)
add_webauthn_credential(user)
end
it 'returns http success' do
get :index
expect(response).to have_http_status(200)
end
end
context 'when user does not has webauthn enabled' do
it 'redirects to 2FA methods list page' do
get :index
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
context 'when user does not have otp enabled' do
before do
user.update(otp_required_for_login: false)
end
it 'requires otp enabled first' do
get :index
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
context 'when not signed in' do
it 'redirects to login' do
delete :index
expect(response).to redirect_to new_user_session_path
end
end
end
describe 'GET /options #options' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
context 'when user has otp enabled' do
before do
user.update(otp_required_for_login: true)
end
context 'when user has webauthn enabled' do
before do
user.update(webauthn_id: WebAuthn.generate_user_id)
add_webauthn_credential(user)
end
it 'returns http success' do
get :options
expect(response).to have_http_status(200)
end
it 'stores the challenge on the session' do
get :options
expect(@controller.session[:webauthn_challenge]).to be_present
end
it 'does not change webauthn_id' do
expect { get :options }.to_not change { user.webauthn_id }
end
it "includes existing credentials in list of excluded credentials" do
get :options
excluded_credentials_ids = JSON.parse(response.body)['excludeCredentials'].map { |credential| credential['id'] }
expect(excluded_credentials_ids).to match_array(user.webauthn_credentials.pluck(:external_id))
end
end
context 'when user does not have webauthn enabled' do
it 'returns http success' do
get :options
expect(response).to have_http_status(200)
end
it 'stores the challenge on the session' do
get :options
expect(@controller.session[:webauthn_challenge]).to be_present
end
it 'sets user webauthn_id' do
get :options
expect(user.reload.webauthn_id).to be_present
end
end
end
context 'when user has not enabled otp' do
before do
user.update(otp_required_for_login: false)
end
it 'requires otp enabled first' do
get :options
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
context 'when not signed in' do
it 'redirects to login' do
get :options
expect(response).to redirect_to new_user_session_path
end
end
end
describe 'POST #create' do
let(:nickname) { 'SecurityKeyNickname' }
let(:challenge) do
WebAuthn::Credential.options_for_create(
user: { id: user.id, name: user.account.username, display_name: user.account.display_name }
).challenge
end
let(:new_webauthn_credential) { fake_client.create(challenge: challenge) }
context 'when signed in' do
before do
sign_in user, scope: :user
end
context 'when user has enabled otp' do
before do
user.update(otp_required_for_login: true)
end
context 'when user has enabled webauthn' do
before do
user.update(webauthn_id: WebAuthn.generate_user_id)
add_webauthn_credential(user)
end
context 'when creation succeeds' do
it 'returns http success' do
@controller.session[:webauthn_challenge] = challenge
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
expect(response).to have_http_status(200)
end
it 'adds a new credential to user credentials' do
@controller.session[:webauthn_challenge] = challenge
expect do
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
end.to change { user.webauthn_credentials.count }.by(1)
end
it 'does not change webauthn_id' do
@controller.session[:webauthn_challenge] = challenge
expect do
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
end.to_not change { user.webauthn_id }
end
end
context 'when the nickname is already used' do
it 'fails' do
@controller.session[:webauthn_challenge] = challenge
post :create, params: { credential: new_webauthn_credential, nickname: 'USB Key' }
expect(response).to have_http_status(500)
expect(flash[:error]).to be_present
end
end
context 'when the credential already exists' do
before do
user2 = Fabricate(:user)
public_key_credential = WebAuthn::Credential.from_create(new_webauthn_credential)
Fabricate(:webauthn_credential,
user_id: user2.id,
external_id: public_key_credential.id,
public_key: public_key_credential.public_key)
end
it 'fails' do
@controller.session[:webauthn_challenge] = challenge
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
expect(response).to have_http_status(500)
expect(flash[:error]).to be_present
end
end
end
context 'when user have not enabled webauthn' do
context 'creation succeeds' do
it 'creates a webauthn credential' do
@controller.session[:webauthn_challenge] = challenge
expect do
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
end.to change { user.webauthn_credentials.count }.by(1)
end
end
end
end
context 'when user has not enabled otp' do
before do
user.update(otp_required_for_login: false)
end
it 'requires otp enabled first' do
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
context 'when not signed in' do
it 'redirects to login' do
post :create, params: { credential: new_webauthn_credential, nickname: nickname }
expect(response).to redirect_to new_user_session_path
end
end
end
describe 'DELETE #destroy' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
context 'when user has otp enabled' do
before do
user.update(otp_required_for_login: true)
end
context 'when user has webauthn enabled' do
before do
user.update(webauthn_id: WebAuthn.generate_user_id)
add_webauthn_credential(user)
end
context 'when deletion succeeds' do
it 'redirects to 2FA methods list and shows flash success' do
delete :destroy, params: { id: user.webauthn_credentials.take.id }
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:success]).to be_present
end
it 'deletes the credential' do
expect do
delete :destroy, params: { id: user.webauthn_credentials.take.id }
end.to change { user.webauthn_credentials.count }.by(-1)
end
end
end
context 'when user does not have webauthn enabled' do
it 'redirects to 2FA methods list and shows flash error' do
delete :destroy, params: { id: '1' }
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
context 'when user does not have otp enabled' do
it 'requires otp enabled first' do
delete :destroy, params: { id: '1' }
expect(response).to redirect_to settings_two_factor_authentication_methods_path
expect(flash[:error]).to be_present
end
end
end
context 'when not signed in' do
it 'redirects to login' do
delete :destroy, params: { id: '1' }
expect(response).to redirect_to new_user_session_path
end
end
end
end

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
require 'rails_helper'
describe Settings::TwoFactorAuthenticationMethodsController do
render_views
let(:user) { Fabricate(:user) }
describe 'GET #index' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
describe 'when user has enabled otp' do
before do
user.update(otp_required_for_login: true)
end
it 'returns http success' do
get :index
expect(response).to have_http_status(200)
end
end
describe 'when user has not enabled otp' do
before do
user.update(otp_required_for_login: false)
end
it 'redirects to enable otp' do
get :index
expect(response).to redirect_to(settings_otp_authentication_path)
end
end
end
context 'when not signed in' do
it 'redirects' do
get :index
expect(response).to redirect_to '/auth/sign_in'
end
end
end
end

View File

@ -1,125 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe Settings::TwoFactorAuthenticationsController do
render_views
let(:user) { Fabricate(:user) }
describe 'GET #show' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
describe 'when user requires otp for login already' do
it 'returns http success' do
user.update(otp_required_for_login: true)
get :show
expect(response).to have_http_status(200)
end
end
describe 'when user does not require otp for login' do
it 'returns http success' do
user.update(otp_required_for_login: false)
get :show
expect(response).to have_http_status(200)
end
end
end
context 'when not signed in' do
it 'redirects' do
get :show
expect(response).to redirect_to '/auth/sign_in'
end
end
end
describe 'POST #create' do
context 'when signed in' do
before do
sign_in user, scope: :user
end
describe 'when user requires otp for login already' do
it 'redirects to show page' do
user.update(otp_required_for_login: true)
post :create
expect(response).to redirect_to(settings_two_factor_authentication_path)
end
end
describe 'when creation succeeds' do
it 'updates user secret' do
before = user.otp_secret
post :create, session: { challenge_passed_at: Time.now.utc }
expect(user.reload.otp_secret).not_to eq(before)
expect(response).to redirect_to(new_settings_two_factor_authentication_confirmation_path)
end
end
end
context 'when not signed in' do
it 'redirects' do
get :show
expect(response).to redirect_to '/auth/sign_in'
end
end
end
describe 'POST #destroy' do
before do
user.update(otp_required_for_login: true)
end
context 'when signed in' do
before do
sign_in user, scope: :user
end
it 'turns off otp requirement with correct code' do
expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, arg|
expect(value).to eq user
expect(arg).to eq '123456'
true
end
post :destroy, params: { form_two_factor_confirmation: { otp_attempt: '123456' } }
expect(response).to redirect_to(settings_two_factor_authentication_path)
user.reload
expect(user.otp_required_for_login).to eq(false)
end
it 'does not turn off otp if code is incorrect' do
expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, arg|
expect(value).to eq user
expect(arg).to eq '057772'
false
end
post :destroy, params: { form_two_factor_confirmation: { otp_attempt: '057772' } }
user.reload
expect(user.otp_required_for_login).to eq(true)
end
it 'raises ActionController::ParameterMissing if code is missing' do
post :destroy
expect(response).to have_http_status(400)
end
end
it 'redirects if not signed in' do
get :show
expect(response).to redirect_to '/auth/sign_in'
end
end
end

Some files were not shown because too many files have changed in this diff Show More