Merge pull request #8935 from mitchellh/vagrant-cloud-two-factor-login

`vagrant login` 2FA support for Vagrant Cloud
This commit is contained in:
Justin Campbell 2017-08-30 14:18:45 -04:00 committed by GitHub
commit b00a99bfa9
5 changed files with 294 additions and 52 deletions

View File

@ -5,8 +5,15 @@ require "vagrant/util/presence"
module VagrantPlugins
module LoginCommand
class Client
APP = "app".freeze
include Vagrant::Util::Presence
attr_accessor :username_or_email
attr_accessor :password
attr_reader :two_factor_default_delivery_method
attr_reader :two_factor_delivery_methods
# Initializes a login client with the given Vagrant::Environment.
#
# @param [Vagrant::Environment] env
@ -35,29 +42,67 @@ module VagrantPlugins
RestClient.get(url, content_type: :json)
true
end
rescue Errors::Unauthorized
false
end
# Login logs a user in and returns the token for that user. The token
# is _not_ stored unless {#store_token} is called.
#
# @param [String] username_or_email
# @param [String] password
# @param [String] description
# @param [String] code
# @return [String] token The access token, or nil if auth failed.
def login(username_or_email, password, description: nil)
def login(description: nil, code: nil)
@logger.info("Logging in '#{username_or_email}'")
with_error_handling do
url = "#{Vagrant.server_url}/api/v1/authenticate"
request = {
response = post(
"/api/v1/authenticate", {
user: {
login: username_or_email,
password: password
},
token: {
description: description
},
two_factor: {
code: code
}
}
)
response["token"]
end
# Requests a 2FA code
# @param [String] delivery_method
def request_code(delivery_method)
@env.ui.warn("Requesting 2FA code via #{delivery_method.upcase}...")
response = post(
"/api/v1/two-factor/request-code", {
user: {
login: username_or_email,
password: password
},
two_factor: {
delivery_method: delivery_method.downcase
}
}
)
two_factor = response['two_factor']
obfuscated_destination = two_factor['obfuscated_destination']
@env.ui.success("2FA code sent to #{obfuscated_destination}.")
end
# Issues a post to a Vagrant Cloud path with the given payload.
# @param [String] path
# @param [Hash] payload
# @return [Hash] response data
def post(path, payload)
with_error_handling do
url = File.join(Vagrant.server_url, path)
proxy = nil
proxy ||= ENV["HTTPS_PROXY"] || ENV["https_proxy"]
@ -67,7 +112,7 @@ module VagrantPlugins
response = RestClient::Request.execute(
method: :post,
url: url,
payload: JSON.dump(request),
payload: JSON.dump(payload),
proxy: proxy,
headers: {
accept: :json,
@ -76,8 +121,7 @@ module VagrantPlugins
},
)
data = JSON.load(response.to_s)
data["token"]
JSON.load(response.to_s)
end
end
@ -138,14 +182,33 @@ EOH
yield
rescue RestClient::Unauthorized
@logger.debug("Unauthorized!")
false
raise Errors::Unauthorized
rescue RestClient::BadRequest => e
@logger.debug("Bad request:")
@logger.debug(e.message)
@logger.debug(e.backtrace.join("\n"))
parsed_response = JSON.parse(e.response)
errors = parsed_response["errors"].join("\n")
raise Errors::ServerError, errors: errors
rescue RestClient::NotAcceptable => e
@logger.debug("Got unacceptable response:")
@logger.debug(e.message)
@logger.debug(e.backtrace.join("\n"))
parsed_response = JSON.parse(e.response)
if two_factor = parsed_response['two_factor']
store_two_factor_information two_factor
if two_factor_default_delivery_method != APP
request_code two_factor_default_delivery_method
end
raise Errors::TwoFactorRequired
end
begin
errors = JSON.parse(e.response)["errors"].join("\n")
errors = parsed_response["errors"].join("\n")
raise Errors::ServerError, errors: errors
rescue JSON::ParserError; end
@ -158,6 +221,33 @@ EOH
def token_path
@env.data_dir.join("vagrant_login_token")
end
def store_two_factor_information(two_factor)
@two_factor_default_delivery_method =
two_factor['default_delivery_method']
@two_factor_delivery_methods =
two_factor['delivery_methods']
@env.ui.warn "2FA is enabled for your account."
if two_factor_default_delivery_method == APP
@env.ui.info "Enter the code from your authenticator."
else
@env.ui.info "Default method is " \
"'#{two_factor_default_delivery_method}'."
end
other_delivery_methods =
two_factor_delivery_methods - [APP]
if other_delivery_methods.any?
other_delivery_methods_sentence = other_delivery_methods
.map { |word| "'#{word}'" }
.join(' or ')
@env.ui.info "You can also type #{other_delivery_methods_sentence} " \
"to request a new code."
end
end
end
end
end

View File

@ -17,6 +17,10 @@ module VagrantPlugins
options[:check] = c
end
o.on("-d", "--description DESCRIPTION", String, "Description for the Vagrant Cloud token") do |t|
options[:description] = t
end
o.on("-k", "--logout", "Logs you out if you're logged in") do |k|
options[:logout] = k
end
@ -24,6 +28,10 @@ module VagrantPlugins
o.on("-t", "--token TOKEN", String, "Set the Vagrant Cloud token") do |t|
options[:token] = t
end
o.on("-u", "--username USERNAME_OR_EMAIL", String, "Specify your Vagrant Cloud username or email address") do |t|
options[:login] = t
end
end
# Parse the options
@ -31,6 +39,7 @@ module VagrantPlugins
return if !argv
@client = Client.new(@env)
@client.username_or_email = options[:login]
# Determine what task we're actually taking based on flags
if options[:check]
@ -50,28 +59,44 @@ module VagrantPlugins
end
# Ask for the username
login = nil
password = nil
description = nil
while !login
login = @env.ui.ask("Vagrant Cloud Username: ")
if @client.username_or_email
@env.ui.output("Vagrant Cloud username or email: #{@client.username_or_email}")
end
until @client.username_or_email
@client.username_or_email = @env.ui.ask("Vagrant Cloud username or email: ")
end
while !password
password = @env.ui.ask("Password (will be hidden): ", echo: false)
until @client.password
@client.password = @env.ui.ask("Password (will be hidden): ", echo: false)
end
description = options[:description]
if description
@env.ui.output("Token description: #{description}")
else
description_default = "Vagrant login from #{Socket.gethostname}"
while !description
until description
description =
@env.ui.ask("Token description (Defaults to #{description_default.inspect}): ")
end
description = description_default if description.empty?
end
token = @client.login(login, password, description: description)
if !token
@env.ui.error(I18n.t("login_command.invalid_login"))
return 1
code = nil
begin
token = @client.login(description: description, code: code)
rescue Errors::TwoFactorRequired
until code
code = @env.ui.ask("2FA code: ")
if @client.two_factor_delivery_methods.include?(code.downcase)
delivery_method, code = code, nil
@client.request_code delivery_method
end
end
retry
end
@client.store_token(token)

View File

@ -12,6 +12,13 @@ module VagrantPlugins
class ServerUnreachable < Error
error_key(:server_unreachable)
end
class Unauthorized < Error
error_key(:unauthorized)
end
class TwoFactorRequired < Error
end
end
end
end

View File

@ -2,13 +2,16 @@ en:
login_command:
errors:
server_error: |-
The Vagrant Cloud server responded with an not-OK response:
The Vagrant Cloud server responded with a not-OK response:
%{errors}
server_unreachable: |-
The Vagrant Cloud server is not currently accepting connections. Please check
your network connection and try again later.
unauthorized: |-
Invalid username or password. Please try again.
check_logged_in: |-
You are already logged in.
check_not_logged_in: |-

View File

@ -7,7 +7,12 @@ describe VagrantPlugins::LoginCommand::Client do
let(:env) { isolated_environment.create_vagrant_env }
subject { described_class.new(env) }
subject(:client) { described_class.new(env) }
before(:all) do
I18n.load_path << Vagrant.source_root.join("plugins/commands/login/locales/en.yml")
I18n.reload!
end
before do
stub_env("ATLAS_TOKEN" => nil)
@ -38,7 +43,7 @@ describe VagrantPlugins::LoginCommand::Client do
expect(subject.logged_in?).to be(true)
end
it "returns false if the endpoint returns a non-200" do
it "raises an error if the endpoint returns a non-200" do
stub_request(:get, url)
.with(headers: headers)
.to_return(body: JSON.pretty_generate("bad" => true), status: 401)
@ -55,47 +60,159 @@ describe VagrantPlugins::LoginCommand::Client do
end
describe "#login" do
it "returns the access token after successful login" do
request = {
"user" => {
"login" => "foo",
"password" => "bar",
let(:request) {
{
user: {
login: login,
password: password,
},
"token" => {
"description" => "Token description"
token: {
description: description,
},
two_factor: {
code: nil
}
}
}
response = {
"token" => "baz",
}
let(:login) { "foo" }
let(:password) { "bar" }
let(:description) { "Token description" }
headers = {
let(:headers) {
{
"Accept" => "application/json",
"Content-Type" => "application/json",
}
}
let(:response) {
{
token: "baz"
}
}
it "returns the access token after successful login" do
stub_request(:post, "#{Vagrant.server_url}/api/v1/authenticate").
with(body: JSON.dump(request), headers: headers).
to_return(status: 200, body: JSON.dump(response))
expect(subject.login("foo", "bar", description: "Token description"))
.to eq("baz")
client.username_or_email = login
client.password = password
expect(client.login(description: "Token description")).to eq("baz")
end
it "returns nil on bad login" do
context "when 2fa is required" do
let(:response) {
{
two_factor: {
default_delivery_method: default_delivery_method,
delivery_methods: delivery_methods
}
}
}
let(:default_delivery_method) { "app" }
let(:delivery_methods) { ["app"] }
before do
stub_request(:post, "#{Vagrant.server_url}/api/v1/authenticate").
to_return(status: 406, body: JSON.dump(response))
end
it "raises a two-factor required error" do
expect {
client.login
}.to raise_error(VagrantPlugins::LoginCommand::Errors::TwoFactorRequired)
end
context "when the default delivery method is not app" do
let(:default_delivery_method) { "sms" }
let(:delivery_methods) { ["app", "sms"] }
it "requests a code and then raises a two-factor required error" do
expect(client)
.to receive(:request_code)
.with(default_delivery_method)
expect {
client.login
}.to raise_error(VagrantPlugins::LoginCommand::Errors::TwoFactorRequired)
end
end
end
context "on bad login" do
before do
stub_request(:post, "#{Vagrant.server_url}/api/v1/authenticate").
to_return(status: 401, body: "")
expect(subject.login("foo", "bar")).to be(false)
end
it "raises an exception if it can't reach the sever" do
it "raises an error" do
expect {
client.login
}.to raise_error(VagrantPlugins::LoginCommand::Errors::Unauthorized)
end
end
context "if it can't reach the server" do
before do
stub_request(:post, "#{Vagrant.server_url}/api/v1/authenticate").
to_raise(SocketError)
end
expect { subject.login("foo", "bar") }.
to raise_error(VagrantPlugins::LoginCommand::Errors::ServerUnreachable)
it "raises an exception" do
expect {
subject.login
}.to raise_error(VagrantPlugins::LoginCommand::Errors::ServerUnreachable)
end
end
end
describe "#request_code" do
let(:request) {
{
user: {
login: login,
password: password,
},
two_factor: {
delivery_method: delivery_method
}
}
}
let(:login) { "foo" }
let(:password) { "bar" }
let(:delivery_method) { "sms" }
let(:headers) {
{
"Accept" => "application/json",
"Content-Type" => "application/json"
}
}
let(:response) {
{
two_factor: {
obfuscated_destination: "SMS number ending in 1234"
}
}
}
it "displays that the code was sent" do
expect(env.ui)
.to receive(:success)
.with("2FA code sent to SMS number ending in 1234.")
stub_request(:post, "#{Vagrant.server_url}/api/v1/two-factor/request-code").
with(body: JSON.dump(request), headers: headers).
to_return(status: 201, body: JSON.dump(response))
client.username_or_email = login
client.password = password
client.request_code delivery_method
end
end