diff --git a/plugins/commands/login/client.rb b/plugins/commands/login/client.rb new file mode 100644 index 000000000..f06772be9 --- /dev/null +++ b/plugins/commands/login/client.rb @@ -0,0 +1,95 @@ +require "rest_client" + +module VagrantPlugins + module LoginCommand + class Client + # Initializes a login client with the given Vagrant::Environment. + # + # @param [Vagrant::Environment] env + def initialize(env) + @env = env + end + + # Removes the token, effectively logging the user out. + def clear_token + token_path.delete if token_path.file? + end + + # Checks if the user is logged in by verifying their authentication + # token. + # + # @return [Boolean] + def logged_in? + token = self.token + return false if !token + + with_error_handling do + url = "#{Vagrant.server_url}/api/v1/authenticate" + + "?access_token=#{token}" + RestClient.get(url, content_type: :json) + true + end + 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] user + # @param [String] pass + # @return [String] token The access token, or nil if auth failed. + def login(user, pass) + with_error_handling do + url = "#{Vagrant.server_url}/api/v1/authenticate" + request = { "user" => { "login" => user, "password" => pass } } + response = RestClient.post( + url, JSON.dump(request), content_type: :json) + data = JSON.load(response.to_s) + data["token"] + end + end + + # Stores the given token locally, removing any previous tokens. + # + # @param [String] token + def store_token(token) + token_path.open("w") do |f| + f.write(token) + end + nil + end + + # Reads the access token if there is one, or returns nil otherwise. + # + # @return [String] + def token + token_path.read + rescue Errno::ENOENT + return nil + end + + protected + + def with_error_handling(&block) + yield + rescue RestClient::Unauthorized + false + rescue RestClient::NotAcceptable => e + begin + errors = JSON.parse(e.response)["errors"] + .map { |h| h["message"] } + .join("\n") + + raise Errors::ServerError, errors: errors + rescue JSON::ParserError; end + + raise "An unexpected error occurred: #{e.inspect}" + rescue SocketError + raise Errors::ServerUnreachable, url: Vagrant.server_url.to_s + end + + def token_path + @env.data_dir.join("vagrant_login_token") + end + end + end +end diff --git a/plugins/commands/login/command.rb b/plugins/commands/login/command.rb new file mode 100644 index 000000000..e16b1afaf --- /dev/null +++ b/plugins/commands/login/command.rb @@ -0,0 +1,83 @@ +module VagrantPlugins + module LoginCommand + class Command < Vagrant.plugin("2", "command") + def self.synopsis + "log in to HashiCorp's Atlas" + end + + def execute + options = {} + + opts = OptionParser.new do |o| + o.banner = "Usage: vagrant login" + o.separator "" + o.on("-c", "--check", "Only checks if you're logged in") do |c| + options[:check] = c + end + + o.on("-k", "--logout", "Logs you out if you're logged in") do |k| + options[:logout] = k + end + end + + # Parse the options + argv = parse_options(opts) + return if !argv + + @client = Client.new(@env) + + # Determine what task we're actually taking based on flags + if options[:check] + return execute_check + elsif options[:logout] + return execute_logout + end + + # Let the user know what is going on. + @env.ui.output(I18n.t("login_command.command_header") + "\n") + + # If it is a private cloud installation, show that + if Vagrant.server_url != Vagrant::DEFAULT_SERVER_URL + @env.ui.output("Atlas URL: #{Vagrant.server_url}") + end + + # Ask for the username + login = nil + password = nil + while !login + login = @env.ui.ask("Atlas Username: ") + end + + while !password + password = @env.ui.ask("Password (will be hidden): ", echo: false) + end + + token = @client.login(login, password) + if !token + @env.ui.error(I18n.t("login_command.invalid_login")) + return 1 + end + + @client.store_token(token) + @env.ui.success(I18n.t("login_command.logged_in")) + 0 + end + + def execute_check + if @client.logged_in? + @env.ui.success(I18n.t("login_command.check_logged_in")) + return 0 + else + @env.ui.error(I18n.t("login_command.check_not_logged_in")) + return 1 + end + end + + def execute_logout + @client.clear_token + @env.ui.success(I18n.t("login_command.logged_out")) + return 0 + end + end + end +end diff --git a/plugins/commands/login/errors.rb b/plugins/commands/login/errors.rb new file mode 100644 index 000000000..614c37cf6 --- /dev/null +++ b/plugins/commands/login/errors.rb @@ -0,0 +1,17 @@ +module VagrantPlugins + module LoginCommand + module Errors + class Error < Vagrant::Errors::VagrantError + error_namespace("login_command.errors") + end + + class ServerError < Error + error_key(:server_error) + end + + class ServerUnreachable < Error + error_key(:server_unreachable) + end + end + end +end diff --git a/plugins/commands/login/locales/en.yml b/plugins/commands/login/locales/en.yml new file mode 100644 index 000000000..51020df1d --- /dev/null +++ b/plugins/commands/login/locales/en.yml @@ -0,0 +1,30 @@ +en: + login_command: + errors: + server_error: |- + The Atlas server responded with an not-OK response: + + %{errors} + server_unreachable: |- + The Atlas server is not currently accepting connections. Please check + your network connection and try again later. + + check_logged_in: |- + You are already logged in. + check_not_logged_in: |- + You are not currently logged in. Please run `vagrant login` and provide + your login information to authenticate. + command_header: |- + In a moment we will ask for your username and password to HashiCorp's + Atlas. After authenticating, we will store an access token locally on + disk. Your login details will be transmitted over a secure connection, and + are never stored on disk locally. + + If you do not have an Atlas account, sign up at + https://atlas.hashicorp.com. + invalid_login: |- + Invalid username or password. Please try again. + logged_in: |- + You are now logged in. + logged_out: |- + You are logged out. diff --git a/plugins/commands/login/middleware/add_authentication.rb b/plugins/commands/login/middleware/add_authentication.rb new file mode 100644 index 000000000..bfb7dd46b --- /dev/null +++ b/plugins/commands/login/middleware/add_authentication.rb @@ -0,0 +1,35 @@ +require "uri" + +require_relative "../client" + +module VagrantPlugins + module LoginCommand + class AddAuthentication + def initialize(app, env) + @app = app + end + + def call(env) + client = Client.new(env[:env]) + token = client.token + + if token && Vagrant.server_url + server_uri = URI.parse(Vagrant.server_url) + + env[:box_urls].map! do |url| + u = URI.parse(url) + if u.host == server_uri.host + u.query ||= "" + u.query += "&" if u.query != "" + u.query += "access_token=#{token}" + end + + u.to_s + end + end + + @app.call(env) + end + end + end +end diff --git a/plugins/commands/login/plugin.rb b/plugins/commands/login/plugin.rb new file mode 100644 index 000000000..efb84a556 --- /dev/null +++ b/plugins/commands/login/plugin.rb @@ -0,0 +1,35 @@ +require "vagrant" + +module VagrantPlugins + module LoginCommand + autoload :Client, File.expand_path("../client", __FILE__) + autoload :Errors, File.expand_path("../errors", __FILE__) + + class Plugin < Vagrant.plugin("2") + name "vagrant-login" + description <<-DESC + Provides the login command and internal API access to Atlas. + DESC + + command(:login) do + require_relative "command" + init! + Command + end + + action_hook(:cloud_authenticated_boxes, :authenticate_box_url) do |hook| + require_relative "middleware/add_authentication" + hook.prepend(AddAuthentication) + end + + protected + + def self.init! + return if defined?(@_init) + I18n.load_path << File.expand_path("../locales/en.yml", __FILE__) + I18n.reload! + @_init = true + end + end + end +end diff --git a/plugins/pushes/atlas/config.rb b/plugins/pushes/atlas/config.rb index 5c4a1a7cf..d4d2da2c5 100644 --- a/plugins/pushes/atlas/config.rb +++ b/plugins/pushes/atlas/config.rb @@ -139,12 +139,8 @@ module VagrantPlugins # @return [String, nil] # the token, or nil if it does not exist def token_from_vagrant_login(env) - if defined?(VagrantPlugins::Login::Client) - client = VagrantPlugins::Login::Client.new(env) - return client.token - end - - nil + client = VagrantPlugins::LoginCommand::Client.new(env) + client.token end end end diff --git a/test/unit/base.rb b/test/unit/base.rb index c7b11b958..e3c68c099 100644 --- a/test/unit/base.rb +++ b/test/unit/base.rb @@ -4,6 +4,7 @@ require "rubygems" # Gems require "checkpoint" require "rspec/autorun" +require "webmock/rspec" # Require Vagrant itself so we can reference the proper # classes to test. diff --git a/test/unit/plugins/commands/login/client_test.rb b/test/unit/plugins/commands/login/client_test.rb new file mode 100644 index 000000000..cad502ccf --- /dev/null +++ b/test/unit/plugins/commands/login/client_test.rb @@ -0,0 +1,109 @@ +require File.expand_path("../../../../base", __FILE__) + +require Vagrant.source_root.join("plugins/commands/login/command") + +describe VagrantPlugins::LoginCommand::Client do + include_context "unit" + + let(:env) { isolated_environment.create_vagrant_env } + + subject { described_class.new(env) } + + describe "#logged_in?" do + it "quickly returns false if no token is set" do + expect(subject).to_not be_logged_in + end + + it "returns true if the endpoint returns 200" do + subject.store_token("foo") + + response = { + "token" => "baz", + } + + headers = { "Content-Type" => "application/json" } + url = "#{Vagrant.server_url}/api/v1/authenticate?access_token=foo" + stub_request(:get, url). + with(headers: headers). + to_return(status: 200, body: JSON.dump(response)) + + expect(subject).to be_logged_in + end + + it "returns false if 401 is returned" do + subject.store_token("foo") + + url = "#{Vagrant.server_url}/api/v1/authenticate?access_token=foo" + stub_request(:get, url). + to_return(status: 401, body: "") + + expect(subject).to_not be_logged_in + end + + it "raises an exception if it can't reach the sever" do + subject.store_token("foo") + + url = "#{Vagrant.server_url}/api/v1/authenticate?access_token=foo" + stub_request(:get, url).to_raise(SocketError) + + expect { subject.logged_in? }. + to raise_error(VagrantPlugins::LoginCommand::Errors::ServerUnreachable) + end + end + + describe "#login" do + it "returns the access token after successful login" do + request = { + "user" => { + "login" => "foo", + "password" => "bar", + }, + } + + response = { + "token" => "baz", + } + + headers = { "Content-Type" => "application/json" } + + 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")).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: "") + + expect(subject.login("foo", "bar")).to be_nil + 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) + + expect { subject.login("foo", "bar") }. + to raise_error(VagrantPlugins::LoginCommand::Errors::ServerUnreachable) + end + end + + describe "#token, #store_token, #clear_token" do + it "returns nil if there is no token" do + expect(subject.token).to be_nil + end + + it "stores the token and can re-access it" do + subject.store_token("foo") + expect(subject.token).to eq("foo") + expect(described_class.new(env).token).to eq("foo") + end + + it "deletes the token" do + subject.store_token("foo") + subject.clear_token + expect(subject.token).to be_nil + end + end +end diff --git a/test/unit/plugins/commands/login/middleware/add_authentication_test.rb b/test/unit/plugins/commands/login/middleware/add_authentication_test.rb new file mode 100644 index 000000000..826e5d46b --- /dev/null +++ b/test/unit/plugins/commands/login/middleware/add_authentication_test.rb @@ -0,0 +1,64 @@ +require File.expand_path("../../../../../base", __FILE__) + +require Vagrant.source_root.join("plugins/commands/login/middleware/add_authentication") + +describe VagrantPlugins::LoginCommand::AddAuthentication do + include_context "unit" + + let(:app) { lambda { |env| } } + let(:env) { { + env: iso_env, + } } + + let(:iso_env) { isolated_environment.create_vagrant_env } + let(:server_url) { "http://foo.com" } + + subject { described_class.new(app, env) } + + before do + allow(Vagrant).to receive(:server_url).and_return(server_url) + end + + describe "#call" do + it "does nothing if we have no server set" do + allow(Vagrant).to receive(:server_url).and_return(nil) + VagrantPlugins::LoginCommand::Client.new(iso_env).store_token("foo") + + original = ["foo", "#{server_url}/bar"] + env[:box_urls] = original.dup + + subject.call(env) + + expect(env[:box_urls]).to eq(original) + end + + it "does nothing if we aren't logged in" do + original = ["foo", "#{server_url}/bar"] + env[:box_urls] = original.dup + + subject.call(env) + + expect(env[:box_urls]).to eq(original) + end + + it "appends the access token to the URL of server URLs" do + token = "foobarbaz" + VagrantPlugins::LoginCommand::Client.new(iso_env).store_token(token) + + original = [ + "http://google.com/box.box", + "#{server_url}/foo.box", + "#{server_url}/bar.box?arg=true", + ] + + expected = original.dup + expected[1] = "#{original[1]}?access_token=#{token}" + expected[2] = "#{original[2]}&access_token=#{token}" + + env[:box_urls] = original.dup + subject.call(env) + + expect(env[:box_urls]).to eq(expected) + end + end +end diff --git a/vagrant.gemspec b/vagrant.gemspec index 23931940b..a2115b055 100644 --- a/vagrant.gemspec +++ b/vagrant.gemspec @@ -26,6 +26,7 @@ Gem::Specification.new do |s| s.add_dependency "net-sftp", "~> 2.1" s.add_dependency "net-scp", "~> 1.1.0" s.add_dependency "rb-kqueue", "~> 0.2.0" + s.add_dependency "rest-client", "~> 1.7" s.add_dependency "wdm", "~> 0.1.0" s.add_dependency "winrm", "~> 1.1.3" @@ -34,6 +35,7 @@ Gem::Specification.new do |s| s.add_development_dependency "rake" s.add_development_dependency "rspec", "~> 2.14.0" + s.add_development_dependency "webmock", "~> 1.20" s.add_development_dependency "fake_ftp", "~> 0.1" # The following block of code determines the files that should be included