Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WebAuth Client MVP #933

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
39 changes: 39 additions & 0 deletions lib/hex/api/web_auth.ex
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions lib/hex/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 53 additions & 0 deletions lib/mix/tasks/hex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion lib/mix/tasks/hex.user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -91,6 +92,7 @@ defmodule Mix.Tasks.Hex.User do
@switches [
all: :boolean,
key_name: :string,
web: :boolean,
permission: [:string, :keep]
]

Expand Down
20 changes: 20 additions & 0 deletions test/hex/api_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
65 changes: 65 additions & 0 deletions test/mix/tasks/hex.user_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use assert_received {:mix_shell, :info, [message]} to retrieve the test output.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test output is actually sent to the Task process, so I can't use assert_received.

The reason I need to run the task in an other process is because, I need to extract the user_code from the test output, and then submit the user_code to hexpm for the web auth task to access the key.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about submitting the auth request in another process instead. Would that make it easier?

Copy link
Contributor Author

@Benjamin-Philip Benjamin-Philip Feb 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That actually would. I did not think of that.
Will implement when free.

There is no real difference between submitting the user code to hexpm in a seperate process and submitting in the test process.

This is because I can't access the task process's mailbox like normal, and I have to use Process.info to access its messages. See the get_user_code anonymous function for more info.

What would help is some way to "echo" messages sent to the task process to the test process, in which case I can just assert_recieved. However, I think this would bring more complexity than there is already existing.

|> 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()
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions test/support/hexpm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down