diff --git a/lib/hex/api/web_auth.ex b/lib/hex/api/web_auth.ex new file mode 100644 index 00000000..51664a3b --- /dev/null +++ b/lib/hex/api/web_auth.ex @@ -0,0 +1,39 @@ +defmodule Hex.API.WebAuth do + @moduledoc false + + alias Hex.API + + def get_code(key_name) do + {:ok, {_status, code, _}} = + API.erlang_post_request(nil, "web_auth/code", %{key_name: key_name}) + + code + end + + def access_key(device_code) do + Hex.API.check_write_api() + + now = System.os_time(:millisecond) + access_key(%{device_code: device_code}, now, now + 5 * 60 * 1000) + end + + defp access_key(params, last_request_time, timeout_time) do + case API.erlang_post_request(nil, "web_auth/access_key", params) do + {:ok, {_code, %{"write_key" => write_key, "read_key" => read_key}, _headers}} -> + %{write_key: write_key, read_key: read_key} + + {:ok, {_code, %{"message" => "request to be verified"}, _headers}} -> + diff = System.os_time(:millisecond) - last_request_time + + if diff < 1000 do + Process.sleep(1000 - diff) + end + + access_key(params, System.os_time(:millisecond), timeout_time) + end + end + + defp access_key(_, last_request_time, timeout_time) when timeout_time > last_request_time do + raise "Browser-based authentication has timed out" + end +end diff --git a/lib/hex/utils.ex b/lib/hex/utils.ex index 89de5ba5..4d8735cc 100644 --- a/lib/hex/utils.ex +++ b/lib/hex/utils.ex @@ -320,4 +320,15 @@ defmodule Hex.Utils do {app, req, opts} end) end + + def open_url_in_browser(url) do + {cmd, args} = + case :os.type() do + {:unix, :darwin} -> {"open", [url]} + {:unix, _} -> {"xdg-open", [url]} + {:win32, _} -> {"cmd", ["/c", "start", url]} + end + + System.cmd(cmd, args) + end end diff --git a/lib/mix/tasks/hex.ex b/lib/mix/tasks/hex.ex index 5ab94996..f08662a3 100644 --- a/lib/mix/tasks/hex.ex +++ b/lib/mix/tasks/hex.ex @@ -124,6 +124,59 @@ defmodule Mix.Tasks.Hex do @doc false def auth(opts \\ []) do + if opts[:web] do + web_auth(opts) + else + normal_auth(opts) + end + end + + defp web_auth(opts \\ []) do + request = + opts[:key_name] + |> general_key_name() + |> Hex.API.WebAuth.get_code() + + device_code = request["device_code"] + user_code = request["user_code"] + + submit_code_url = + :api_url + |> Hex.State.fetch!() + |> String.replace_suffix("api", "login/web_auth") + + """ + First copy your one-time code: #{user_code} + Paste this code at #{submit_code_url}... + """ + |> Hex.Shell.format() + |> Hex.Shell.info() + + if Hex.Shell.yes?("Open link in browser?") do + Hex.Utils.open_url_in_browser(submit_code_url) + end + + keys = Hex.API.WebAuth.access_key(device_code) + + write_key = keys.write_key + read_key = keys.read_key + + """ + You have authenticated Hex using WebAuth. + + However, Hex requires you to have a local password that applies + only to this machine for security purposes. + + Please enter it. + """ + |> Hex.Shell.info() + + prompt_encrypt_key(write_key, read_key) + + {:ok, write_key, read_key} + end + + defp normal_auth(opts \\ []) do username = Hex.Shell.prompt("Username:") |> Hex.Stdlib.string_trim() account_password = Mix.Tasks.Hex.password_get("Account password:") |> Hex.Stdlib.string_trim() Mix.Tasks.Hex.generate_all_user_keys(username, account_password, opts) diff --git a/lib/mix/tasks/hex.user.ex b/lib/mix/tasks/hex.user.ex index 3d944e45..5ad76898 100644 --- a/lib/mix/tasks/hex.user.ex +++ b/lib/mix/tasks/hex.user.ex @@ -19,12 +19,13 @@ defmodule Mix.Tasks.Hex.User do Authorizes a new user on the local machine by generating a new API key and storing it in the Hex config. - $ mix hex.user auth [--key-name KEY_NAME] + $ mix hex.user auth [--key-name KEY_NAME, --web] ### Command line options * `--key-name KEY_NAME` - By default Hex will base the key name on your machine's hostname, use this option to give your own name. + * `--web` - Use browser based authentication. ## Deauthorize the user @@ -91,6 +92,7 @@ defmodule Mix.Tasks.Hex.User do @switches [ all: :boolean, key_name: :string, + web: :boolean, permission: [:string, :keep] ] diff --git a/test/hex/api_test.exs b/test/hex/api_test.exs index 5aef7114..700068c8 100644 --- a/test/hex/api_test.exs +++ b/test/hex/api_test.exs @@ -107,6 +107,26 @@ defmodule Hex.APITest do assert {:ok, {401, _, _}} = Hex.API.Key.get(auth_d) end + test "web auth" do + auth = Hexpm.new_key(user: "user", pass: "hunter42") + + # Get request + request = Hex.API.WebAuth.get_code("foobar") + user_code = request["user_code"] + device_code = request["device_code"] + + assert user_code + assert device_code + + Hexpm.submit_web_auth_request(user_code, auth) + + # Access keys + keys = Hex.API.WebAuth.access_key(device_code) + + assert keys.write_key + assert keys.read_key + end + test "owners" do auth = Hexpm.new_key(user: "user", pass: "hunter42") diff --git a/test/mix/tasks/hex.user_test.exs b/test/mix/tasks/hex.user_test.exs index c461401f..c545a9cb 100644 --- a/test/mix/tasks/hex.user_test.exs +++ b/test/mix/tasks/hex.user_test.exs @@ -63,6 +63,63 @@ defmodule Mix.Tasks.Hex.UserTest do end) end + test "auth with --web" do + in_tmp(fn -> + set_home_cwd() + + task = Task.async(fn -> Mix.Tasks.Hex.User.run(["auth", "--web"]) end) + send(task.pid, {:mix_shell_input, :yes?, false}) + send(task.pid, {:mix_shell_input, :prompt, "hunter43"}) + send(task.pid, {:mix_shell_input, :prompt, "hunter43"}) + + # Wait for output to be sent + Process.sleep(100) + + task.pid + |> get_user_code + |> Hexpm.submit_web_auth_request(Hexpm.new_key(user: "user", pass: "hunter42")) + + Task.await(task) + + {:ok, name} = :inet.gethostname() + name = List.to_string(name) + + auth = Mix.Tasks.Hex.auth_info(:read) + assert {:ok, {200, body, _}} = Hex.API.Key.get(auth) + assert "#{name}-write-WebAuth" in Enum.map(body, & &1["name"]) + assert "#{name}-read-WebAuth" in Enum.map(body, & &1["name"]) + end) + end + + test "auth with --web --key-name" do + in_tmp(fn -> + set_home_cwd() + + task = + Task.async(fn -> + Mix.Tasks.Hex.User.run(["auth", "--web", "--key-name", "userauthkeyname"]) + end) + + send(task.pid, {:mix_shell_input, :yes?, false}) + send(task.pid, {:mix_shell_input, :prompt, "hunter43"}) + send(task.pid, {:mix_shell_input, :prompt, "hunter43"}) + + # Wait for output to be sent + Process.sleep(100) + + task.pid + |> get_user_code + |> Hexpm.submit_web_auth_request(Hexpm.new_key(user: "user", pass: "hunter42")) + + Task.await(task) + + auth = Mix.Tasks.Hex.auth_info(:read) + assert {:ok, {200, body, _}} = Hex.API.Key.get(auth) + assert "userauthkeyname-write-WebAuth" in Enum.map(body, & &1["name"]) + assert "userauthkeyname-read-WebAuth" in Enum.map(body, & &1["name"]) + end) + end + test "auth organizations" do in_tmp(fn -> set_home_cwd() @@ -239,4 +296,12 @@ defmodule Mix.Tasks.Hex.UserTest do assert_received {:mix_shell, :error, ["Wrong password. Try again"]} end) end + + defp get_user_code(pid) do + {:messages, [_, _, {:mix_shell, :info, [message]}, _]} = Process.info(pid, :messages) + + ["First copy your one-time code: " <> user_code, _, _] = String.split(message, "\n") + + user_code + end end diff --git a/test/support/hexpm.ex b/test/support/hexpm.ex index c10bae54..f2bacbdd 100644 --- a/test/support/hexpm.ex +++ b/test/support/hexpm.ex @@ -233,6 +233,10 @@ defmodule HexTest.Hexpm do [key: secret] end + def submit_web_auth_request(user_code, auth) do + Hex.API.erlang_post_request(nil, "web_auth/submit", %{user_code: user_code}, auth) + end + def new_package(organization, name, version, deps, meta, auth, files \\ nil) do reqs = Enum.filter(deps, fn