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 VagrantPlugins
module LoginCommand module LoginCommand
class Client class Client
APP = "app".freeze
include Vagrant::Util::Presence 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. # Initializes a login client with the given Vagrant::Environment.
# #
# @param [Vagrant::Environment] env # @param [Vagrant::Environment] env
@ -35,29 +42,67 @@ module VagrantPlugins
RestClient.get(url, content_type: :json) RestClient.get(url, content_type: :json)
true true
end end
rescue Errors::Unauthorized
false
end end
# Login logs a user in and returns the token for that user. The token # Login logs a user in and returns the token for that user. The token
# is _not_ stored unless {#store_token} is called. # is _not_ stored unless {#store_token} is called.
# #
# @param [String] username_or_email
# @param [String] password
# @param [String] description # @param [String] description
# @param [String] code
# @return [String] token The access token, or nil if auth failed. # @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}'") @logger.info("Logging in '#{username_or_email}'")
with_error_handling do response = post(
url = "#{Vagrant.server_url}/api/v1/authenticate" "/api/v1/authenticate", {
request = {
user: { user: {
login: username_or_email, login: username_or_email,
password: password password: password
}, },
token: { token: {
description: description 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 = nil
proxy ||= ENV["HTTPS_PROXY"] || ENV["https_proxy"] proxy ||= ENV["HTTPS_PROXY"] || ENV["https_proxy"]
@ -67,7 +112,7 @@ module VagrantPlugins
response = RestClient::Request.execute( response = RestClient::Request.execute(
method: :post, method: :post,
url: url, url: url,
payload: JSON.dump(request), payload: JSON.dump(payload),
proxy: proxy, proxy: proxy,
headers: { headers: {
accept: :json, accept: :json,
@ -76,8 +121,7 @@ module VagrantPlugins
}, },
) )
data = JSON.load(response.to_s) JSON.load(response.to_s)
data["token"]
end end
end end
@ -138,14 +182,33 @@ EOH
yield yield
rescue RestClient::Unauthorized rescue RestClient::Unauthorized
@logger.debug("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 rescue RestClient::NotAcceptable => e
@logger.debug("Got unacceptable response:") @logger.debug("Got unacceptable response:")
@logger.debug(e.message) @logger.debug(e.message)
@logger.debug(e.backtrace.join("\n")) @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 begin
errors = JSON.parse(e.response)["errors"].join("\n") errors = parsed_response["errors"].join("\n")
raise Errors::ServerError, errors: errors raise Errors::ServerError, errors: errors
rescue JSON::ParserError; end rescue JSON::ParserError; end
@ -158,6 +221,33 @@ EOH
def token_path def token_path
@env.data_dir.join("vagrant_login_token") @env.data_dir.join("vagrant_login_token")
end 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 end
end end

View File

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

View File

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

View File

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

View File

@ -7,7 +7,12 @@ describe VagrantPlugins::LoginCommand::Client do
let(:env) { isolated_environment.create_vagrant_env } 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 before do
stub_env("ATLAS_TOKEN" => nil) stub_env("ATLAS_TOKEN" => nil)
@ -38,7 +43,7 @@ describe VagrantPlugins::LoginCommand::Client do
expect(subject.logged_in?).to be(true) expect(subject.logged_in?).to be(true)
end 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) stub_request(:get, url)
.with(headers: headers) .with(headers: headers)
.to_return(body: JSON.pretty_generate("bad" => true), status: 401) .to_return(body: JSON.pretty_generate("bad" => true), status: 401)
@ -55,47 +60,159 @@ describe VagrantPlugins::LoginCommand::Client do
end end
describe "#login" do describe "#login" do
it "returns the access token after successful login" do let(:request) {
request = { {
"user" => { user: {
"login" => "foo", login: login,
"password" => "bar", password: password,
}, },
"token" => { token: {
"description" => "Token description" description: description,
},
two_factor: {
code: nil
} }
} }
}
response = { let(:login) { "foo" }
"token" => "baz", let(:password) { "bar" }
} let(:description) { "Token description" }
headers = { let(:headers) {
{
"Accept" => "application/json", "Accept" => "application/json",
"Content-Type" => "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"). stub_request(:post, "#{Vagrant.server_url}/api/v1/authenticate").
with(body: JSON.dump(request), headers: headers). with(body: JSON.dump(request), headers: headers).
to_return(status: 200, body: JSON.dump(response)) to_return(status: 200, body: JSON.dump(response))
expect(subject.login("foo", "bar", description: "Token description")) client.username_or_email = login
.to eq("baz") client.password = password
expect(client.login(description: "Token description")).to eq("baz")
end end
it "returns nil on bad login" do context "when 2fa is required" do
stub_request(:post, "#{Vagrant.server_url}/api/v1/authenticate"). let(:response) {
to_return(status: 401, body: "") {
two_factor: {
default_delivery_method: default_delivery_method,
delivery_methods: delivery_methods
}
}
}
let(:default_delivery_method) { "app" }
let(:delivery_methods) { ["app"] }
expect(subject.login("foo", "bar")).to be(false) 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 end
it "raises an exception if it can't reach the sever" do context "on bad login" do
stub_request(:post, "#{Vagrant.server_url}/api/v1/authenticate"). before do
to_raise(SocketError) stub_request(:post, "#{Vagrant.server_url}/api/v1/authenticate").
to_return(status: 401, body: "")
end
expect { subject.login("foo", "bar") }. it "raises an error" do
to raise_error(VagrantPlugins::LoginCommand::Errors::ServerUnreachable) 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
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
end end