Merge pull request #8935 from mitchellh/vagrant-cloud-two-factor-login
`vagrant login` 2FA support for Vagrant Cloud
This commit is contained in:
commit
b00a99bfa9
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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: |-
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue