diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2019-03-18 21:00:55 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-03-18 21:00:55 +0100 |
commit | 9c4cbdbafb0324ae259e10865b90ed1ed0255bdd (patch) | |
tree | 9d1d884fb1753f110683d7ff78912cdf868ec635 /app | |
parent | 42c581c45853cf08f2c9c521d59a2194ef2d9c61 (diff) |
Add Keybase integration (#10297)
* create account_identity_proofs table * add endpoint for keybase to check local proofs * add async task to update validity and liveness of proofs from keybase * first pass keybase proof CRUD * second pass keybase proof creation * clean up proof list and add badges * add avatar url to keybase api * Always highlight the “Identity Proofs” navigation item when interacting with proofs. * Update translations. * Add profile URL. * Reorder proofs. * Add proofs to bio. * Update settings/identity_proofs front-end. * Use `link_to`. * Only encode query params if they exist. URLs without params had a trailing `?`. * Only show live proofs. * change valid to active in proof list and update liveness before displaying * minor fixes * add keybase config at well-known path * extremely naive feature flagging off the identity proof UI * fixes for rubocop * make identity proofs page resilient to potential keybase issues * normalize i18n * tweaks for brakeman * remove two unused translations * cleanup and add more localizations * make keybase_contacts an admin setting * fix ExternalProofService my_domain * use Addressable::URI in identity proofs * use active model serializer for keybase proof config * more cleanup of keybase proof config * rename proof is_valid and is_live to proof_valid and proof_live * cleanup * assorted tweaks for more robust communication with keybase * Clean up * Small fixes * Display verified identity identically to verified links * Clean up unused CSS * Add caching for Keybase avatar URLs * Remove keybase_contacts setting
Diffstat (limited to 'app')
19 files changed, 579 insertions, 2 deletions
diff --git a/app/controllers/api/proofs_controller.rb b/app/controllers/api/proofs_controller.rb new file mode 100644 index 000000000..a84ad2014 --- /dev/null +++ b/app/controllers/api/proofs_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::ProofsController < Api::BaseController + before_action :set_account + before_action :set_provider + before_action :check_account_approval + before_action :check_account_suspension + + def index + render json: @account, serializer: @provider.serializer_class + end + + private + + def set_provider + @provider = ProofProvider.find(params[:provider]) || raise(ActiveRecord::RecordNotFound) + end + + def set_account + @account = Account.find_local!(params[:username]) + end + + def check_account_approval + not_found if @account.user_pending? + end + + def check_account_suspension + gone if @account.suspended? + end +end diff --git a/app/controllers/settings/identity_proofs_controller.rb b/app/controllers/settings/identity_proofs_controller.rb new file mode 100644 index 000000000..4a3b89a5e --- /dev/null +++ b/app/controllers/settings/identity_proofs_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Settings::IdentityProofsController < Settings::BaseController + layout 'admin' + + before_action :authenticate_user! + before_action :check_required_params, only: :new + + def index + @proofs = AccountIdentityProof.where(account: current_account).order(provider: :asc, provider_username: :asc) + @proofs.each(&:refresh!) + end + + def new + @proof = current_account.identity_proofs.new( + token: params[:token], + provider: params[:provider], + provider_username: params[:provider_username] + ) + + render layout: 'auth' + end + + def create + @proof = current_account.identity_proofs.where(provider: resource_params[:provider], provider_username: resource_params[:provider_username]).first_or_initialize(resource_params) + @proof.token = resource_params[:token] + + if @proof.save + redirect_to @proof.on_success_path(params[:user_agent]) + else + flash[:alert] = I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize) + redirect_to settings_identity_proofs_path + end + end + + private + + def check_required_params + redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :token].all? { |k| params[k].present? } + end + + def resource_params + params.require(:account_identity_proof).permit(:provider, :provider_username, :token) + end +end diff --git a/app/controllers/well_known/keybase_proof_config_controller.rb b/app/controllers/well_known/keybase_proof_config_controller.rb new file mode 100644 index 000000000..eb41e586f --- /dev/null +++ b/app/controllers/well_known/keybase_proof_config_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module WellKnown + class KeybaseProofConfigController < ActionController::Base + def show + render json: {}, serializer: ProofProvider::Keybase::ConfigSerializer + end + end +end diff --git a/app/javascript/images/logo_transparent_black.svg b/app/javascript/images/logo_transparent_black.svg new file mode 100644 index 000000000..e44bcf5e1 --- /dev/null +++ b/app/javascript/images/logo_transparent_black.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.4144 232.00976"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z" fill="#000"/></svg> diff --git a/app/javascript/images/proof_providers/keybase.png b/app/javascript/images/proof_providers/keybase.png new file mode 100644 index 000000000..7e3ac657f --- /dev/null +++ b/app/javascript/images/proof_providers/keybase.png Binary files differdiff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 6051c1d00..9ef45e425 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -801,3 +801,58 @@ code { } } } + +.connection-prompt { + margin-bottom: 25px; + + .fa-link { + background-color: darken($ui-base-color, 4%); + border-radius: 100%; + font-size: 24px; + padding: 10px; + } + + &__column { + align-items: center; + display: flex; + flex: 1; + flex-direction: column; + flex-shrink: 1; + + &-sep { + flex-grow: 0; + overflow: visible; + position: relative; + z-index: 1; + } + } + + .account__avatar { + margin-bottom: 20px; + } + + &__connection { + background-color: lighten($ui-base-color, 8%); + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); + border-radius: 4px; + padding: 25px 10px; + position: relative; + text-align: center; + + &::after { + background-color: darken($ui-base-color, 4%); + content: ''; + display: block; + height: 100%; + left: 50%; + position: absolute; + width: 1px; + } + } + + &__row { + align-items: center; + display: flex; + flex-direction: row; + } +} diff --git a/app/lib/proof_provider.rb b/app/lib/proof_provider.rb new file mode 100644 index 000000000..102c50f4f --- /dev/null +++ b/app/lib/proof_provider.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module ProofProvider + SUPPORTED_PROVIDERS = %w(keybase).freeze + + def self.find(identifier, proof = nil) + case identifier + when 'keybase' + ProofProvider::Keybase.new(proof) + end + end +end diff --git a/app/lib/proof_provider/keybase.rb b/app/lib/proof_provider/keybase.rb new file mode 100644 index 000000000..96322a265 --- /dev/null +++ b/app/lib/proof_provider/keybase.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class ProofProvider::Keybase + BASE_URL = 'https://keybase.io' + + class Error < StandardError; end + + class ExpectedProofLiveError < Error; end + + class UnexpectedResponseError < Error; end + + def initialize(proof = nil) + @proof = proof + end + + def serializer_class + ProofProvider::Keybase::Serializer + end + + def worker_class + ProofProvider::Keybase::Worker + end + + def validate! + unless @proof.token&.size == 66 + @proof.errors.add(:base, I18n.t('identity_proofs.errors.keybase.invalid_token')) + return + end + + return if @proof.provider_username.blank? + + if verifier.valid? + @proof.verified = true + @proof.live = false + else + @proof.errors.add(:base, I18n.t('identity_proofs.errors.keybase.verification_failed', kb_username: @proof.provider_username)) + end + end + + def refresh! + worker_class.new.perform(@proof) + rescue ProofProvider::Keybase::Error + nil + end + + def on_success_path(user_agent = nil) + verifier.on_success_path(user_agent) + end + + def badge + @badge ||= ProofProvider::Keybase::Badge.new(@proof.account.username, @proof.provider_username, @proof.token) + end + + private + + def verifier + @verifier ||= ProofProvider::Keybase::Verifier.new(@proof.account.username, @proof.provider_username, @proof.token) + end +end diff --git a/app/lib/proof_provider/keybase/badge.rb b/app/lib/proof_provider/keybase/badge.rb new file mode 100644 index 000000000..3aa067ecf --- /dev/null +++ b/app/lib/proof_provider/keybase/badge.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class ProofProvider::Keybase::Badge + include RoutingHelper + + def initialize(local_username, provider_username, token) + @local_username = local_username + @provider_username = provider_username + @token = token + end + + def proof_url + "#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}/sigchain\##{@token}" + end + + def profile_url + "#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}" + end + + def icon_url + "#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}/proof_badge/#{@token}?username=#{@local_username}&domain=#{domain}" + end + + def avatar_url + Rails.cache.fetch("proof_providers/keybase/#{@provider_username}/avatar_url", expires_in: 5.minutes) { remote_avatar_url } || default_avatar_url + end + + private + + def remote_avatar_url + request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/user/pic_url.json", params: { username: @provider_username }) + + request.perform do |res| + json = Oj.load(res.body_with_limit, mode: :strict) + json['pic_url'] if json.is_a?(Hash) + end + rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError + nil + end + + def default_avatar_url + asset_pack_path('media/images/proof_providers/keybase.png') + end + + def domain + Rails.configuration.x.local_domain + end +end diff --git a/app/lib/proof_provider/keybase/config_serializer.rb b/app/lib/proof_provider/keybase/config_serializer.rb new file mode 100644 index 000000000..474ea74e2 --- /dev/null +++ b/app/lib/proof_provider/keybase/config_serializer.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class ProofProvider::Keybase::ConfigSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :version, :domain, :display_name, :username, + :brand_color, :logo, :description, :prefill_url, + :profile_url, :check_url, :check_path, :avatar_path, + :contact + + def version + 1 + end + + def domain + Rails.configuration.x.local_domain + end + + def display_name + Setting.site_title + end + + def logo + { svg_black: full_asset_url(asset_pack_path('media/images/logo_transparent_black.svg')), svg_full: full_asset_url(asset_pack_path('media/images/logo.svg')) } + end + + def brand_color + '#282c37' + end + + def description + Setting.site_short_description.presence || Setting.site_description.presence || I18n.t('about.about_mastodon_html') + end + + def username + { min: 1, max: 30, re: Account::USERNAME_RE.inspect } + end + + def prefill_url + params = { + provider: 'keybase', + token: '%{sig_hash}', + provider_username: '%{kb_username}', + username: '%{username}', + user_agent: '%{kb_ua}', + } + + CGI.unescape(new_settings_identity_proof_url(params)) + end + + def profile_url + CGI.unescape(short_account_url('%{username}')) # rubocop:disable Style/FormatStringToken + end + + def check_url + CGI.unescape(api_proofs_url(username: '%{username}', provider: 'keybase')) + end + + def check_path + ['signatures'] + end + + def avatar_path + ['avatar'] + end + + def contact + [Setting.site_contact_email.presence].compact + end +end diff --git a/app/lib/proof_provider/keybase/serializer.rb b/app/lib/proof_provider/keybase/serializer.rb new file mode 100644 index 000000000..d29283600 --- /dev/null +++ b/app/lib/proof_provider/keybase/serializer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ProofProvider::Keybase::Serializer < ActiveModel::Serializer + include RoutingHelper + + attribute :avatar + + has_many :identity_proofs, key: :signatures + + def avatar + full_asset_url(object.avatar_original_url) + end + + class AccountIdentityProofSerializer < ActiveModel::Serializer + attributes :sig_hash, :kb_username + + def sig_hash + object.token + end + + def kb_username + object.provider_username + end + end +end diff --git a/app/lib/proof_provider/keybase/verifier.rb b/app/lib/proof_provider/keybase/verifier.rb new file mode 100644 index 000000000..86f249dd7 --- /dev/null +++ b/app/lib/proof_provider/keybase/verifier.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class ProofProvider::Keybase::Verifier + def initialize(local_username, provider_username, token) + @local_username = local_username + @provider_username = provider_username + @token = token + end + + def valid? + request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/sig/proof_valid.json", params: query_params) + + request.perform do |res| + json = Oj.load(res.body_with_limit, mode: :strict) + + if json.is_a?(Hash) + json.fetch('proof_valid', false) + else + false + end + end + rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError + false + end + + def on_success_path(user_agent = nil) + url = Addressable::URI.parse("#{ProofProvider::Keybase::BASE_URL}/_/proof_creation_success") + url.query_values = query_params.merge(kb_ua: user_agent || 'unknown') + url.to_s + end + + def status + request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/sig/proof_live.json", params: query_params) + + request.perform do |res| + raise ProofProvider::Keybase::UnexpectedResponseError unless res.code == 200 + + json = Oj.load(res.body_with_limit, mode: :strict) + + raise ProofProvider::Keybase::UnexpectedResponseError unless json.is_a?(Hash) && json.key?('proof_valid') && json.key?('proof_live') + + json + end + rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError + raise ProofProvider::Keybase::UnexpectedResponseError + end + + private + + def query_params + { + domain: domain, + kb_username: @provider_username, + username: @local_username, + sig_hash: @token, + } + end + + def domain + Rails.configuration.x.local_domain + end +end diff --git a/app/lib/proof_provider/keybase/worker.rb b/app/lib/proof_provider/keybase/worker.rb new file mode 100644 index 000000000..2872f59c1 --- /dev/null +++ b/app/lib/proof_provider/keybase/worker.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class ProofProvider::Keybase::Worker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', retry: 20, unique: :until_executed + + sidekiq_retry_in do |count, exception| + # Retry aggressively when the proof is valid but not live in Keybase. + # This is likely because Keybase just hasn't noticed the proof being + # served from here yet. + + if exception.class == ProofProvider::Keybase::ExpectedProofLiveError + case count + when 0..2 then 0.seconds + when 2..6 then 1.second + end + end + end + + def perform(proof_id) + proof = proof_id.is_a?(AccountIdentityProof) ? proof_id : AccountIdentityProof.find(proof_id) + verifier = ProofProvider::Keybase::Verifier.new(proof.account.username, proof.provider_username, proof.token) + status = verifier.status + + # If Keybase thinks the proof is valid, and it exists here in Mastodon, + # then it should be live. Keybase just has to notice that it's here + # and then update its state. That might take a couple seconds. + raise ProofProvider::Keybase::ExpectedProofLiveError if status['proof_valid'] && !status['proof_live'] + + proof.update!(verified: status['proof_valid'], live: status['proof_live']) + end +end diff --git a/app/models/account_identity_proof.rb b/app/models/account_identity_proof.rb new file mode 100644 index 000000000..e7a3f97e5 --- /dev/null +++ b/app/models/account_identity_proof.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: account_identity_proofs +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# provider :string default(""), not null +# provider_username :string default(""), not null +# token :text default(""), not null +# verified :boolean default(FALSE), not null +# live :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class AccountIdentityProof < ApplicationRecord + belongs_to :account + + validates :provider, inclusion: { in: ProofProvider::SUPPORTED_PROVIDERS } + validates :provider_username, format: { with: /\A[a-z0-9_]+\z/i }, length: { minimum: 2, maximum: 15 } + validates :provider_username, uniqueness: { scope: [:account_id, :provider] } + validates :token, format: { with: /\A[a-f0-9]+\z/ }, length: { maximum: 66 } + + validate :validate_with_provider, if: :token_changed? + + scope :active, -> { where(verified: true, live: true) } + + after_create_commit :queue_worker + + delegate :refresh!, :on_success_path, :badge, to: :provider_instance + + private + + def provider_instance + @provider_instance ||= ProofProvider.find(provider, self) + end + + def queue_worker + provider_instance.worker_class.perform_async(id) + end + + def validate_with_provider + provider_instance.validate! + end +end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index a8ba8fef1..70855e054 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -7,6 +7,9 @@ module AccountAssociations # Local users has_one :user, inverse_of: :account, dependent: :destroy + # Identity proofs + has_many :identity_proofs, class_name: 'AccountIdentityProof', dependent: :destroy, inverse_of: :account + # Timelines has_many :stream_entries, inverse_of: :account, dependent: :destroy has_many :statuses, inverse_of: :account, dependent: :destroy diff --git a/app/views/accounts/_bio.html.haml b/app/views/accounts/_bio.html.haml index 2ea34a048..efc26d136 100644 --- a/app/views/accounts/_bio.html.haml +++ b/app/views/accounts/_bio.html.haml @@ -1,7 +1,17 @@ +- proofs = account.identity_proofs.active +- fields = account.fields + .public-account-bio - - unless account.fields.empty? + - unless fields.empty? && proofs.empty? .account__header__fields - - account.fields.each do |field| + - proofs.each do |proof| + %dl + %dt= proof.provider.capitalize + %dd.verified + = link_to fa_icon('check'), proof.badge.proof_url, class: 'verified__mark', title: t('accounts.link_verified_on', date: l(proof.updated_at)) + = link_to proof.provider_username, proof.badge.profile_url + + - fields.each do |field| %dl %dt.emojify{ title: field.name }= Formatter.instance.format_field(account, field.name, custom_emojify: true) %dd{ title: field.value, class: custom_field_classes(field) } @@ -9,6 +19,7 @@ %span.verified__mark{ title: t('accounts.link_verified_on', date: l(field.verified_at)) } = fa_icon 'check' = Formatter.instance.format_field(account, field.value, custom_emojify: true) + = account_badge(account) - if account.note.present? diff --git a/app/views/settings/identity_proofs/_proof.html.haml b/app/views/settings/identity_proofs/_proof.html.haml new file mode 100644 index 000000000..524827ad7 --- /dev/null +++ b/app/views/settings/identity_proofs/_proof.html.haml @@ -0,0 +1,20 @@ +%tr + %td + = link_to proof.badge.profile_url, class: 'name-tag' do + = image_tag proof.badge.avatar_url, width: 15, height: 15, alt: '', class: 'avatar' + %span.username + = proof.provider_username + %span= "(#{proof.provider.capitalize})" + + %td + - if proof.live? + %span.positive-hint + = fa_icon 'check-circle fw' + = t('identity_proofs.active') + - else + %span.negative-hint + = fa_icon 'times-circle fw' + = t('identity_proofs.inactive') + + %td + = table_link_to 'external-link', t('identity_proofs.view_proof'), proof.badge.proof_url if proof.badge.proof_url diff --git a/app/views/settings/identity_proofs/index.html.haml b/app/views/settings/identity_proofs/index.html.haml new file mode 100644 index 000000000..d0ea03ecd --- /dev/null +++ b/app/views/settings/identity_proofs/index.html.haml @@ -0,0 +1,17 @@ +- content_for :page_title do + = t('settings.identity_proofs') + +%p= t('identity_proofs.explanation_html') + +- unless @proofs.empty? + %hr.spacer/ + + .table-wrapper + %table.table + %thead + %tr + %th= t('identity_proofs.identity') + %th= t('identity_proofs.status') + %th + %tbody + = render partial: 'settings/identity_proofs/proof', collection: @proofs, as: :proof diff --git a/app/views/settings/identity_proofs/new.html.haml b/app/views/settings/identity_proofs/new.html.haml new file mode 100644 index 000000000..8ce6e61c9 --- /dev/null +++ b/app/views/settings/identity_proofs/new.html.haml @@ -0,0 +1,31 @@ +- content_for :page_title do + = t('identity_proofs.authorize_connection_prompt') + +.form-container + .oauth-prompt + %h2= t('identity_proofs.authorize_connection_prompt') + + = simple_form_for @proof, url: settings_identity_proofs_url, html: { method: :post } do |f| + = f.input :provider, as: :hidden + = f.input :provider_username, as: :hidden + = f.input :token, as: :hidden + + = hidden_field_tag :user_agent, params[:user_agent] + + .connection-prompt + .connection-prompt__row.connection-prompt__connection + .connection-prompt__column + = image_tag current_account.avatar.url(:original), size: 96, class: 'account__avatar' + + %p= t('identity_proofs.i_am_html', username: content_tag(:strong,current_account.username), service: site_hostname) + + .connection-prompt__column.connection-prompt__column-sep + = fa_icon 'link' + + .connection-prompt__column + = image_tag @proof.badge.avatar_url, size: 96, class: 'account__avatar' + + %p= t('identity_proofs.i_am_html', username: content_tag(:strong, @proof.provider_username), service: @proof.provider.capitalize) + + = f.button :button, t('identity_proofs.authorize'), type: :submit + = link_to t('simple_form.no'), settings_identity_proofs_url, class: 'button negative' |