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 settingmaster
parent
42c581c458
commit
9c4cbdbafb
@ -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 |
@ -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 |
@ -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 |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 12 KiB |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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' |
@ -0,0 +1,16 @@ |
|||||||
|
class CreateAccountIdentityProofs < ActiveRecord::Migration[5.2] |
||||||
|
def change |
||||||
|
create_table :account_identity_proofs do |t| |
||||||
|
t.belongs_to :account, foreign_key: { on_delete: :cascade } |
||||||
|
t.string :provider, null: false, default: '' |
||||||
|
t.string :provider_username, null: false, default: '' |
||||||
|
t.text :token, null: false, default: '' |
||||||
|
t.boolean :verified, null: false, default: false |
||||||
|
t.boolean :live, null: false, default: false |
||||||
|
|
||||||
|
t.timestamps null: false |
||||||
|
end |
||||||
|
|
||||||
|
add_index :account_identity_proofs, [:account_id, :provider, :provider_username], unique: true, name: :index_account_proofs_on_account_and_provider_and_username |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,96 @@ |
|||||||
|
require 'rails_helper' |
||||||
|
|
||||||
|
describe Api::ProofsController do |
||||||
|
let(:alice) { Fabricate(:account, username: 'alice') } |
||||||
|
|
||||||
|
before do |
||||||
|
stub_request(:get, 'https://keybase.io/_/api/1.0/sig/proof_valid.json?domain=cb6e6126.ngrok.io&kb_username=crypto_alice&sig_hash=111111111111111111111111111111111111111111111111111111111111111111&username=alice').to_return(status: 200, body: '{"proof_valid":true,"proof_live":false}') |
||||||
|
stub_request(:get, 'https://keybase.io/_/api/1.0/sig/proof_live.json?domain=cb6e6126.ngrok.io&kb_username=crypto_alice&sig_hash=111111111111111111111111111111111111111111111111111111111111111111&username=alice').to_return(status: 200, body: '{"proof_valid":true,"proof_live":true}') |
||||||
|
stub_request(:get, 'https://keybase.io/_/api/1.0/sig/proof_valid.json?domain=cb6e6126.ngrok.io&kb_username=hidden_alice&sig_hash=222222222222222222222222222222222222222222222222222222222222222222&username=alice').to_return(status: 200, body: '{"proof_valid":true,"proof_live":true}') |
||||||
|
stub_request(:get, 'https://keybase.io/_/api/1.0/sig/proof_live.json?domain=cb6e6126.ngrok.io&kb_username=hidden_alice&sig_hash=222222222222222222222222222222222222222222222222222222222222222222&username=alice').to_return(status: 200, body: '{"proof_valid":true,"proof_live":true}') |
||||||
|
end |
||||||
|
|
||||||
|
describe 'GET #index' do |
||||||
|
describe 'with a non-existent username' do |
||||||
|
it '404s' do |
||||||
|
get :index, params: { username: 'nonexistent', provider: 'keybase' } |
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
describe 'with a user that has no proofs' do |
||||||
|
it 'is an empty list of signatures' do |
||||||
|
get :index, params: { username: alice.username, provider: 'keybase' } |
||||||
|
|
||||||
|
expect(body_as_json[:signatures]).to eq [] |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
describe 'with a user that has a live, valid proof' do |
||||||
|
let(:token1) { '111111111111111111111111111111111111111111111111111111111111111111' } |
||||||
|
let(:kb_name1) { 'crypto_alice' } |
||||||
|
|
||||||
|
before do |
||||||
|
Fabricate(:account_identity_proof, account: alice, verified: true, live: true, token: token1, provider_username: kb_name1) |
||||||
|
end |
||||||
|
|
||||||
|
it 'is a list with that proof in it' do |
||||||
|
get :index, params: { username: alice.username, provider: 'keybase' } |
||||||
|
|
||||||
|
expect(body_as_json[:signatures]).to eq [ |
||||||
|
{ kb_username: kb_name1, sig_hash: token1 }, |
||||||
|
] |
||||||
|
end |
||||||
|
|
||||||
|
describe 'add one that is neither live nor valid' do |
||||||
|
let(:token2) { '222222222222222222222222222222222222222222222222222222222222222222' } |
||||||
|
let(:kb_name2) { 'hidden_alice' } |
||||||
|
|
||||||
|
before do |
||||||
|
Fabricate(:account_identity_proof, account: alice, verified: false, live: false, token: token2, provider_username: kb_name2) |
||||||
|
end |
||||||
|
|
||||||
|
it 'is a list with both proofs' do |
||||||
|
get :index, params: { username: alice.username, provider: 'keybase' } |
||||||
|
|
||||||
|
expect(body_as_json[:signatures]).to eq [ |
||||||
|
{ kb_username: kb_name1, sig_hash: token1 }, |
||||||
|
{ kb_username: kb_name2, sig_hash: token2 }, |
||||||
|
] |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
describe 'a user that has an avatar' do |
||||||
|
let(:alice) { Fabricate(:account, username: 'alice', avatar: attachment_fixture('avatar.gif')) } |
||||||
|
|
||||||
|
context 'and a proof' do |
||||||
|
let(:token1) { '111111111111111111111111111111111111111111111111111111111111111111' } |
||||||
|
let(:kb_name1) { 'crypto_alice' } |
||||||
|
|
||||||
|
before do |
||||||
|
Fabricate(:account_identity_proof, account: alice, verified: true, live: true, token: token1, provider_username: kb_name1) |
||||||
|
get :index, params: { username: alice.username, provider: 'keybase' } |
||||||
|
end |
||||||
|
|
||||||
|
it 'has two keys: signatures and avatar' do |
||||||
|
expect(body_as_json.keys).to match_array [:signatures, :avatar] |
||||||
|
end |
||||||
|
|
||||||
|
it 'has the correct signatures' do |
||||||
|
expect(body_as_json[:signatures]).to eq [ |
||||||
|
{ kb_username: kb_name1, sig_hash: token1 }, |
||||||
|
] |
||||||
|
end |
||||||
|
|
||||||
|
it 'has the correct avatar url' do |
||||||
|
first_part = 'https://cb6e6126.ngrok.io/system/accounts/avatars/' |
||||||
|
last_part = 'original/avatar.gif' |
||||||
|
|
||||||
|
expect(body_as_json[:avatar]).to match /#{Regexp.quote(first_part)}(?:\d{3,5}\/){3}#{Regexp.quote(last_part)}/ |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,112 @@ |
|||||||
|
require 'rails_helper' |
||||||
|
|
||||||
|
describe Settings::IdentityProofsController do |
||||||
|
render_views |
||||||
|
|
||||||
|
let(:user) { Fabricate(:user) } |
||||||
|
let(:valid_token) { '1'*66 } |
||||||
|
let(:kbname) { 'kbuser' } |
||||||
|
let(:provider) { 'keybase' } |
||||||
|
let(:findable_id) { Faker::Number.number(5) } |
||||||
|
let(:unfindable_id) { Faker::Number.number(5) } |
||||||
|
let(:postable_params) do |
||||||
|
{ account_identity_proof: { provider: provider, provider_username: kbname, token: valid_token } } |
||||||
|
end |
||||||
|
|
||||||
|
before do |
||||||
|
allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:status) { { 'proof_valid' => true, 'proof_live' => true } } |
||||||
|
sign_in user, scope: :user |
||||||
|
end |
||||||
|
|
||||||
|
describe 'new proof creation' do |
||||||
|
context 'GET #new with no existing proofs' do |
||||||
|
it 'redirects to :index' do |
||||||
|
get :new |
||||||
|
expect(response).to redirect_to settings_identity_proofs_path |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'POST #create' do |
||||||
|
context 'when saving works' do |
||||||
|
before do |
||||||
|
allow(ProofProvider::Keybase::Worker).to receive(:perform_async) |
||||||
|
allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:valid?) { true } |
||||||
|
allow_any_instance_of(AccountIdentityProof).to receive(:on_success_path) { root_url } |
||||||
|
end |
||||||
|
|
||||||
|
it 'serializes a ProofProvider::Keybase::Worker' do |
||||||
|
expect(ProofProvider::Keybase::Worker).to receive(:perform_async) |
||||||
|
post :create, params: postable_params |
||||||
|
end |
||||||
|
|
||||||
|
it 'delegates redirection to the proof provider' do |
||||||
|
expect_any_instance_of(AccountIdentityProof).to receive(:on_success_path) |
||||||
|
post :create, params: postable_params |
||||||
|
expect(response).to redirect_to root_url |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'when saving fails' do |
||||||
|
before do |
||||||
|
allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:valid?) { false } |
||||||
|
end |
||||||
|
|
||||||
|
it 'redirects to :index' do |
||||||
|
post :create, params: postable_params |
||||||
|
expect(response).to redirect_to settings_identity_proofs_path |
||||||
|
end |
||||||
|
|
||||||
|
it 'flashes a helpful message' do |
||||||
|
post :create, params: postable_params |
||||||
|
expect(flash[:alert]).to eq I18n.t('identity_proofs.errors.failed', provider: 'Keybase') |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'it can also do an update if the provider and username match an existing proof' do |
||||||
|
before do |
||||||
|
allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:valid?) { true } |
||||||
|
allow(ProofProvider::Keybase::Worker).to receive(:perform_async) |
||||||
|
Fabricate(:account_identity_proof, account: user.account, provider: provider, provider_username: kbname) |
||||||
|
allow_any_instance_of(AccountIdentityProof).to receive(:on_success_path) { root_url } |
||||||
|
end |
||||||
|
|
||||||
|
it 'calls update with the new token' do |
||||||
|
expect_any_instance_of(AccountIdentityProof).to receive(:save) do |proof| |
||||||
|
expect(proof.token).to eq valid_token |
||||||
|
end |
||||||
|
|
||||||
|
post :create, params: postable_params |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
describe 'GET #index' do |
||||||
|
context 'with no existing proofs' do |
||||||
|
it 'shows the helpful explanation' do |
||||||
|
get :index |
||||||
|
expect(response.body).to match I18n.t('identity_proofs.explanation_html') |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'with two proofs' do |
||||||
|
before do |
||||||
|
allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:valid?) { true } |
||||||
|
@proof1 = Fabricate(:account_identity_proof, account: user.account) |
||||||
|
@proof2 = Fabricate(:account_identity_proof, account: user.account) |
||||||
|
allow_any_instance_of(AccountIdentityProof).to receive(:badge) { double(avatar_url: '', profile_url: '', proof_url: '') } |
||||||
|
allow_any_instance_of(AccountIdentityProof).to receive(:refresh!) { } |
||||||
|
end |
||||||
|
|
||||||
|
it 'has the first proof username on the page' do |
||||||
|
get :index |
||||||
|
expect(response.body).to match /#{Regexp.quote(@proof1.provider_username)}/ |
||||||
|
end |
||||||
|
|
||||||
|
it 'has the second proof username on the page' do |
||||||
|
get :index |
||||||
|
expect(response.body).to match /#{Regexp.quote(@proof2.provider_username)}/ |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,15 @@ |
|||||||
|
require 'rails_helper' |
||||||
|
|
||||||
|
describe WellKnown::KeybaseProofConfigController, type: :controller do |
||||||
|
render_views |
||||||
|
|
||||||
|
describe 'GET #show' do |
||||||
|
it 'renders json' do |
||||||
|
get :show |
||||||
|
|
||||||
|
expect(response).to have_http_status(200) |
||||||
|
expect(response.content_type).to eq 'application/json' |
||||||
|
expect { JSON.parse(response.body) }.not_to raise_exception |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,8 @@ |
|||||||
|
Fabricator(:account_identity_proof) do |
||||||
|
account |
||||||
|
provider 'keybase' |
||||||
|
provider_username { sequence(:provider_username) { |i| "#{Faker::Lorem.characters(15)}" } } |
||||||
|
token { sequence(:token) { |i| "#{i}#{Faker::Crypto.sha1()*2}"[0..65] } } |
||||||
|
verified false |
||||||
|
live false |
||||||
|
end |
@ -0,0 +1,82 @@ |
|||||||
|
require 'rails_helper' |
||||||
|
|
||||||
|
describe ProofProvider::Keybase::Verifier do |
||||||
|
let(:my_domain) { Rails.configuration.x.local_domain } |
||||||
|
|
||||||
|
let(:keybase_proof) do |
||||||
|
local_proof = AccountIdentityProof.new( |
||||||
|
provider: 'Keybase', |
||||||
|
provider_username: 'cryptoalice', |
||||||
|
token: '11111111111111111111111111' |
||||||
|
) |
||||||
|
|
||||||
|
described_class.new('alice', 'cryptoalice', '11111111111111111111111111') |
||||||
|
end |
||||||
|
|
||||||
|
let(:query_params) do |
||||||
|
"domain=#{my_domain}&kb_username=cryptoalice&sig_hash=11111111111111111111111111&username=alice" |
||||||
|
end |
||||||
|
|
||||||
|
describe '#valid?' do |
||||||
|
let(:base_url) { 'https://keybase.io/_/api/1.0/sig/proof_valid.json' } |
||||||
|
|
||||||
|
context 'when valid' do |
||||||
|
before do |
||||||
|
json_response_body = '{"status":{"code":0,"name":"OK"},"proof_valid":true}' |
||||||
|
stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body) |
||||||
|
end |
||||||
|
|
||||||
|
it 'calls out to keybase and returns true' do |
||||||
|
expect(keybase_proof.valid?).to eq true |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'when invalid' do |
||||||
|
before do |
||||||
|
json_response_body = '{"status":{"code":0,"name":"OK"},"proof_valid":false}' |
||||||
|
stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body) |
||||||
|
end |
||||||
|
|
||||||
|
it 'calls out to keybase and returns false' do |
||||||
|
expect(keybase_proof.valid?).to eq false |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'with an unexpected api response' do |
||||||
|
before do |
||||||
|
json_response_body = '{"status":{"code":100,"desc":"wrong size hex_id","fields":{"sig_hash":"wrong size hex_id"},"name":"INPUT_ERROR"}}' |
||||||
|
stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body) |
||||||
|
end |
||||||
|
|
||||||
|
it 'swallows the error and returns false' do |
||||||
|
expect(keybase_proof.valid?).to eq false |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
describe '#status' do |
||||||
|
let(:base_url) { 'https://keybase.io/_/api/1.0/sig/proof_live.json' } |
||||||
|
|
||||||
|
context 'with a normal response' do |
||||||
|
before do |
||||||
|
json_response_body = '{"status":{"code":0,"name":"OK"},"proof_live":false,"proof_valid":true}' |
||||||
|
stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body) |
||||||
|
end |
||||||
|
|
||||||
|
it 'calls out to keybase and returns the status fields as proof_valid and proof_live' do |
||||||
|
expect(keybase_proof.status).to include({ 'proof_valid' => true, 'proof_live' => false }) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context 'with an unexpected keybase response' do |
||||||
|
before do |
||||||
|
json_response_body = '{"status":{"code":100,"desc":"missing non-optional field sig_hash","fields":{"sig_hash":"missing non-optional field sig_hash"},"name":"INPUT_ERROR"}}' |
||||||
|
stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body) |
||||||
|
end |
||||||
|
|
||||||
|
it 'raises a ProofProvider::Keybase::UnexpectedResponseError' do |
||||||
|
expect { keybase_proof.status }.to raise_error ProofProvider::Keybase::UnexpectedResponseError |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
end |
Loading…
Reference in new issue