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 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
|
||||
|
|
|
@ -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_default = "Vagrant login from #{Socket.gethostname}"
|
||||
while !description
|
||||
description =
|
||||
@env.ui.ask("Token description (Defaults to #{description_default.inspect}): ")
|
||||
description = options[:description]
|
||||
if description
|
||||
@env.ui.output("Token description: #{description}")
|
||||
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
|
||||
description = description_default if description.empty?
|
||||
|
||||
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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: |-
|
||||
|
|
|
@ -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
|
||||
stub_request(:post, "#{Vagrant.server_url}/api/v1/authenticate").
|
||||
to_return(status: 401, body: "")
|
||||
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"] }
|
||||
|
||||
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
|
||||
|
||||
it "raises an exception if it can't reach the sever" do
|
||||
stub_request(:post, "#{Vagrant.server_url}/api/v1/authenticate").
|
||||
to_raise(SocketError)
|
||||
context "on bad login" do
|
||||
before do
|
||||
stub_request(:post, "#{Vagrant.server_url}/api/v1/authenticate").
|
||||
to_return(status: 401, body: "")
|
||||
end
|
||||
|
||||
expect { subject.login("foo", "bar") }.
|
||||
to raise_error(VagrantPlugins::LoginCommand::Errors::ServerUnreachable)
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
|
Loading…
Reference in New Issue