diff --git a/plugins/commands/login/client.rb b/plugins/commands/login/client.rb index f9b0b037e..ebfe717b3 100644 --- a/plugins/commands/login/client.rb +++ b/plugins/commands/login/client.rb @@ -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 diff --git a/plugins/commands/login/command.rb b/plugins/commands/login/command.rb index c6700e960..10a8ef13f 100644 --- a/plugins/commands/login/command.rb +++ b/plugins/commands/login/command.rb @@ -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) diff --git a/plugins/commands/login/errors.rb b/plugins/commands/login/errors.rb index 614c37cf6..4d56612bd 100644 --- a/plugins/commands/login/errors.rb +++ b/plugins/commands/login/errors.rb @@ -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 diff --git a/plugins/commands/login/locales/en.yml b/plugins/commands/login/locales/en.yml index d7decafd3..76fa01281 100644 --- a/plugins/commands/login/locales/en.yml +++ b/plugins/commands/login/locales/en.yml @@ -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: |- diff --git a/test/unit/plugins/commands/login/client_test.rb b/test/unit/plugins/commands/login/client_test.rb index 33436dfd9..06a110706 100644 --- a/test/unit/plugins/commands/login/client_test.rb +++ b/test/unit/plugins/commands/login/client_test.rb @@ -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