Skip to content

Instantly share code, notes, and snippets.

@ecnelises
Last active June 1, 2022 09:41
Show Gist options
  • Save ecnelises/9654e59877aa336977c1409ef540e2a9 to your computer and use it in GitHub Desktop.
Save ecnelises/9654e59877aa336977c1409ef540e2a9 to your computer and use it in GitHub Desktop.
Summary of Google Summer of Code 2018, multi-factor authentication of RubyGems

Google Summer of Code 2018 - Adding multi-factor authentication to RubyGems

My project of the Google Summer of Code 2018 is adding multi-factor (or so-called two-factor) authentication to gem, the default shipped package management tool of Ruby, and RubyGems.org, the site hosting gems.

Project description

Item Content
Project Adding multi-factor authentication to RubyGems
Organization Ruby
Project link https://summerofcode.withgoogle.com/projects/#5815164901785600
Project reports https://ecnelises.github.io
Mentors André Arko, Aditya Prakash
Student Qiu Chaofan
Proposal Link to PDF
Total changes 49 Commits / 1637++ / 106--

Work

Link Description Status
RubyGems.org PR #1729 Multi-factor authentication in UI part. Merged
RubyGems.org PR #1756 Add expiry (30 minutes) of QR-code. Merged
RubyGems.org PR #1753 Multi-factor authentication in API part. Merged
RubyGems.org PR #1761 Remove check against OTP reuse. Merged
RubyGems PR #2369 Add multi-factor authentication to RubyGems client. Merged
Guides PR #218 Add pages on multi-factor authentication to rubygems guides. Merged

All related PRs are listed here. Some of them have not been merged until first edition of this document. I'll update status as they got changed.

Project goal

Currently, our gem hosting site, RubyGems.org uses an email and password based authentication method, with an API key provided for client requests. The main goal of this project is to add an extra authentication phase to them. Users are expected to use digit code on their authenticator apps (like Google Authenticator) in their mobile phone or other devices, to confirm critical actions. Also, user can set their multi-factor authentication status, like disabling it.

Finished work

In general, my project consists of four parts, from addition to existing package hosting site to client tool and documents.

Multi-factor authentication feature on UI part

Although gem uses standard RESTful style API to communicate with server, we can login and edit profiles on the hosting site. The non-API related code is often called UI part. Main work in this stage includes:

  1. Prepare for new external dependencies for OTP (one-time password, namely the digit code) checking work.
  2. Write basic authentication methods for models.
  3. Add options for user to enable or disable their settings about this feature.
  4. Add extra authentication to login action.
  5. Write unit tests and integration tests.

I found some multi-factor authentication related libraries on GitHub at first, but some of them are no longer maintained and most of them are extension of devise (a famous authentication extension for Ruby on Rails). I went through their code and found core parts of them all relies on a library named rotp. As a result, rotp is added into the project. Also, designed implementation should contain a QR-code for user to scan, so another QR-code related library was also added.

After suitable library is chosen and tested, I wrote some basic authentication methods to User model, which is the base of further work on both UI and API. I recorded implementation details in my progress reports. See 5/25 report.

For what’s next, I designed related pages first. This issue contains related contents. And the final page is like below.

OTP prompt in login

MFA enable prompt

MFA enabling page (part of QR-code and account hidden)

Recovery codes after successfully enabled

MFA level change

Before merged into code base, I wrote thorough unit tests for MFA settings, verification functionality of users, and integration tests for the login and MFA setting workflow.

MFA in UI part has been deployed. (as in Jul. 2018) But people who want to have it a try should set a special cookie entry, mfa_feature to true, to enable it. It's still in test phase, will be turned on by default in the future.

Multi-factor authentication feature in API part

RubyGems API is used by gem client tool and other third-party providers to create, update or delete packages of users. In this stage, I wrote a wrapper for API entries to verify OTP from user.

In my implementation, OTP field will be sent in OTP field in HTTP header. An API action will check Authorization field for API key first to query for current API user. If current user has enabled mfa login and write option and the action (like Rubygems#create) needs extra authentication, the OTP will be verified. Otherwise, server just ignores the field.

OTP here is verified through rotp library, but with a drift in case of unexpected time inconsistency. OTP generated in last interval (30 seconds since UNIX timestamp epoch) or next interval is still valid in verification. In addition, every time when OTP is successfully verified, that timestamp will be recorded and the corresponding OTP cannot be used again.

7/22 report is a good detailed reference for my API implementation.

Action Path Use
rubygems#create POST /api/v1/rubygem Pushing new gem
owners#create POST /api/v1/owner Add owner information to gem
owners#destroy DELETE /api/v1/owner Remove owner information from gem

Work on API part is also tested.

Multi-factor authentication in gem client

gem uses Git-like style of commands. Each command is actually a class at lib/rubygems/commands. Currently, I finished multi-factor authentication on three commands.

  • gem push, release or update gems
  • gem owner, show, add and remove owner information in a gem
  • gem signin, login into rubygems.org (or other self-hosting site)

Code in RubyGems is part of Ruby distribution. Project structure is different from a standard Rails way. I started my work at GemcutterUtilities module and PushCommand class.

Requests

At first, I added a new API endpoint GET /api/v1/multifactor_auth, for client to detect if current user has enabled multi-factor authentication. But this will affect other test cases in the same command and break existing API request order. So later I changed it to two phase of requests.

  • Send API requests as normal.
  • If received 401 Unauthorized and response text says MFA is enabled, ask for OTP and re-send the request.

This works well without adding times of requests. I can focus on adding tests related to my own work, without breaking others.

Acquire OTP

Users are expected to be able to fill their digit code through interactive prompts as below.

$ gem push hello-0.0.1.gem
Pushing gem to https://rubygems.org...
This command needs digit code for multifactor authentication.
Code:   111111
Successfully registered gem: hello (0.0.1)

But sometimes using command line options is necessary for scripts. I added --otp option for commands with MFA. In above case, typing gem push hello-0.0.1.gem --otp 111111 is equivalent. Actually, in my implementation, OTP is stored in options[:otp].

One thing to note out is, gem will ask you to login without terminating current process if you are doing a command but not logged in. Since we have a check against OTP reuse, here the program will delete OTP stored and re-ask it again after login. User should better wait until next interval to prevent verification failure. There's some discussion at PR to delete the check.

Tests

I had a mistake at beginning of writing tests for rubygems. I run individual tests using ruby test_file.rb without add -I option. So result is different from running rake. This confused me for a few days.

Test functionality of gem should require a running instance of rubygems.org in concept. But that would complicate things. So thanks to flexibility of Ruby language, we have a FakeFetcher to provide fake request results to API requests.

I wrote tests for each command, corresponding for two occasions: MFA verification success and failure. But FakeFetcher only supports matching a path to a static Rack style response. I did little change so that it can accept a callable object. Now I can set the response returns 401 first but returns 200 in second attempt.

Documentation and reports

In the first half of project process, I give an article as progress report to my mentors. They are archived at ecnelises.github.io.

Articles

Link Content
May 19th Report About an issue about login sessions and basic preparation.
May 25th Report Workflow & pages design, changes to user model.
Jun 3rd Report Refactor for MFA related controller methods.
Jun 8th Report Multi-factor authentication for login.
Jun 19th Report Feature flags, drifts in OTP check, unit tests.
Jul 22nd Report QR-code expiry, OTP check in API, work on client.
Summary of UI part work Detailed work description for UI part.

Also, I wrote a guide page for how to use multi-factor authentication in RubyGems and RubyGems.org. See this PR for detail.

Future plan

Several parts of my project has not been merged into main branch yet. The first task is to discuss with community members, improve the code, make them into production. Some RFCs may be needed in the process.

Next should be about finishing more issues on the list.

Besides,

  • gem yank command also needs MFA support.
  • Support U2F-based authentication.
  • Use a sign based method in stead of raw API key. This can also bring a session in which MFA is not needed in every action.

Acknowledgements

Great thanks to my mentors, @indirect and @sonalkr132. They gave me a lot of advice on design of the project and careful, patient review on my commits. The project would be much harder without their help!

Also, I should appreciate the kindness from GSoC-CN group. The members showed me their interesting projects and gave me important suggestions in the development process.

@kevinlinxc
Copy link

kevinlinxc commented May 31, 2022

Hi @ecnelises

I think in this gist, you say that you implemented api/v1/multifactor_auth in Rubygems.org but then pivoted away. I see that the endpoint is still in routes, but that the controller is gone from the folder. Does this mean that endpoint doesn't do anything anymore, and can be removed? For context, I'm writing documentation for some missing API endpoints

@ecnelises
Copy link
Author

Ah, thanks for notifying me. The line shouldn't exist at the begining. (rubygems/rubygems.org@abb5383 )

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment