View on GitHub

Silence

Techinically.

RubyGems Weekly Report on Jul 22nd

This is a weekly report on my newest progress of the GSoC (Google Summer of Code) project on Adding Multi-factor Authentication to RubyGems. In this week, I added expiry of QR-codes, filter of OTP on API authentication, and OTP support to gem client. Some of them are not committed or included in a pull request yet.

Expiry of QR-code

Since QR-code (or the key, actually) of multi-factor authentication is stored in session and all the extra security relies on it, storing them in session forever (think of a case when you opened page with QR-code and close it without sending request to enable it) is not a good way. Here I add a separate expiry in session.

# app/models/user.rb
def verify_and_enable_mfa!(seed, level, otp, expiry)
  if expiry < Time.now.utc
    errors.add(:base, I18n.t('multifactor_auths.create.qrcode_expired'))
  elsif verify_digit_otp(seed, otp)
    enable_mfa!(seed, level)
  else
    errors.add(:base, I18n.t('multifactor_auths.incorrect_otp'))
  end
end

And expiry interval is set in application constants.

# config/application.rb
MFA_KEY_EXPIRY = 30.minutes

That works well, except that some people may think the period is too long.

Improvements on API

My last post did not mention things about authentication on API part. Actually it’s simpler. First, the way is:

So let’s write a filter first.

# app/controllers/application_controller.rb
def verify_with_otp
  return unless @api_user.mfa_login_and_write?
  otp = request.headers["HTTP_OTP"] || ''
  return if @api_user.otp_verified?(otp)
  render plain: t(:please_send_correct_otp), status: :unauthorized
end

Then, add a before_action to any actions we want to apply MFA to, like pushing a gem.

# app/controllers/api/v1/rubygems_controller.rb
before_action :verify_with_otp, only: %i[create destroy] 

That seems well. But we still need a way to know if current user has enabled multi-factor authentication, for clients. So a new controller for this.

# app/controllers/api/v1/multifactor_auths_controller.rb
class Api::V1::MultifactorAuthsController < Api::BaseController
  before_action :authenticate_with_api_key
  before_action :verify_authenticated_user

  def show
    respond_to do |format|
      format.any(:all) { render plain: @api_user.mfa_level }
      format.json { render json: { mfa_level: @api_user.mfa_level } }
      format.yaml { render yaml: { mfa_level: @api_user.mfa_level } }
    end
  end
end

I did not intend to add a plain type response. But when I went through code in RubyGems, I found a big part of them only uses response text without parsing them. So I just let it go. These may be handled along with Issue #1683.

Work on RubyGems

Take PushCommand as an example. Workflow here should be:

See the changes.

# lib/rubygems/commands/push_command.rb
add_option('--otp CODE', 'Digit code for multifactor authentication') do |value, options|
  options[:otp] = value
end

# Above is part of initialize

def execute
  @host = options[:host]
  sign_in @host
  run_mfa_check
  send_gem get_one_gem_name
end

# Below is part of send_gem

response = rubygems_api_request(*args) do |request|
  request.body = Gem.read_binary name
  request.add_field "Content-Length", request.body.size
  request.add_field "Content-Type",   "application/octet-stream"
  request.add_field "Authorization",  api_key
  request.add_field "OTP", options[:otp] if need_otp?
end

run_mfa_check is in GemcutterUtilities.

# lib/rubygems/gemcutter_utilities.rb

# Require user for extra OTP code if multifactor authentication is enabled.
def run_mfa_check
  return unless need_otp?
  unless options[:otp]
    say 'This command needs an extra OTP code for multifactor authentication.'
    options[:otp] = ask 'Code: '
  end
end

# Fetch user's multifactor authentication settings and return if an extra OTP code is needed.
def need_otp?
  unless @mfa_level
    response = rubygems_api_request(:get, 'api/v1/multifactor_auth') do |request|
      request.add_field 'Authorization', api_key
    end
    # For compatibility to Gemcutters without mfa support
    @mfa_level = case response
                 when Net::HTTPNotFound
                   'no_mfa'
                 else
                   with_response(response) { |resp| resp.body }
                 end
  end
  @mfa_level == 'mfa_login_and_write'
end

If a NotFound returns, we can conclude that the Gemcutter server does not support MFA. This works well for command line manually test. But a problem occurs when testing.

Testing RubyGems should not require a real Gemcutter instance. So it uses a fake remote fetcher for mocking responses. But adding @fetcher.data["#{Gem.host}/api/v1/multifactor_auth"] = ['no_mfa', 200, 'OK'] doesn’t help to suppress errors. After this resolved, I will send a pull request to RubyGems.

P.S. Pushing gems consumes a lot of time unexpectedly because of checking newest RubyGems version. It takes so long that even OTP with drifts expires. I currently think is just my network problem.

MFA Level Change

I turned the Disable button into Update. Now there’s a select element for selecting MFA levels. People maybe confused with that. So I plan to add a link to RubyGems document about multi-factor authentication after prompt text.

What it looks like:

Planned MFA Level Change

Plan

In the following week, I will continue to do work of client.

Also, for the un-merged pull request about API and level change, other issues (if exists) on V2 API, and documents.