-module(rebar3_hex_user). -export([init/1, do/1, format_error/1]). -export([hex_register/2, whoami/2, auth/2, deauth/2, reset_password/2, encrypt_write_key/3, decrypt_write_key/2, decrypt_write_key/3]). -include("rebar3_hex.hrl"). -define(PROVIDER, user). -define(DEPS, []). -define(ENDPOINT, "users"). %% =================================================================== %% Public API %% =================================================================== -spec init(rebar_state:t()) -> {ok, rebar_state:t()}. init(State) -> Provider = providers:create([{name, ?PROVIDER}, {module, ?MODULE}, {namespace, hex}, {bare, true}, {deps, ?DEPS}, {example, "rebar3 hex user "}, {short_desc, "Hex user tasks"}, {desc, ""}, {opts, [rebar3_hex_utils:repo_opt()]}]), State1 = rebar_state:add_provider(State, Provider), {ok, State1}. -spec do(rebar_state:t()) -> {ok, rebar_state:t()} | {error, string()}. do(State) -> Repo = rebar3_hex_utils:repo(State), case rebar_state:command_args(State) of ["register" | _] -> hex_register(Repo, State); ["whoami" | _] -> whoami(Repo, State); ["auth" | _] -> auth(Repo, State); ["deauth" | _] -> deauth(Repo, State); ["reset_password" | _] -> reset_password(Repo, State); _ -> throw(?PRV_ERROR(bad_command)) end. -spec format_error(any()) -> iolist(). format_error({whoami_failure, Reason}) -> io_lib:format("Fetching currently authenticated user failed: ~ts", [Reason]); format_error(bad_local_password) -> "Failure to decrypt write key: bad local password"; format_error({registration_failure, Reason}) -> io_lib:format("Registration of user failed: ~ts", [Reason]); format_error({generate_key, Reason}) -> io_lib:format("Failure generating authentication tokens: ~ts", [Reason]); format_error(no_match_local_password) -> "Password confirmation failed. The passwords must match."; format_error(bad_command) -> "Command must be one of register, whoami, auth, deauth or reset_password"; format_error(Reason) -> io_lib:format("~p", [Reason]). hex_register(Repo, State) -> ec_talk:say("By registering an account on Hex.pm you accept all our " "policies and terms of service found at https://hex.pm/policies\n"), Username = list_to_binary(ec_talk:ask_default("Username:", string, "")), Email = list_to_binary(ec_talk:ask_default("Email:", string, "")), case get_password() of <<"">> -> error; Password -> PasswordConfirm = get_password(confirm), case Password =:= PasswordConfirm of true -> ec_talk:say("Registering..."), create_user(Username, Email, Password, Repo, State); false -> ?PRV_ERROR({error, "passwords do not match"}) end end. whoami(Repo, State) -> case maps:get(read_key, Repo, undefined) of undefined -> ec_talk:say("Not authenticated as any user currently for this repository"); ReadKey -> case hex_api_user:me(Repo#{api_key => ReadKey}) of {ok, {200, _Headers, #{<<"username">> := Username, <<"email">> := Email}}} -> ec_talk:say("~ts (~ts)", [Username, Email]), {ok, State}; {ok, {_Status, _Headers, #{<<"message">> := Message}}} -> ?PRV_ERROR({whoami_failure, Message}); {error, Reason} -> ?PRV_ERROR({whoami_failure, io_lib:format("~p", [Reason])}) end end. auth(Repo, State) -> Username = list_to_binary(ec_talk:ask_default("Username:", string, "")), Password = get_password(), ec_talk:say("You have authenticated on Hex using your account password. However, " "Hex requires you to have a local password that applies only to this machine for security " "purposes. Please enter it."), LocalPassword = get_password(<<"Local Password: ">>), ConfirmLocalPassword = get_password(<<"Local Password (confirm): ">>), case LocalPassword =:= ConfirmLocalPassword of true -> generate_all_keys(Username, Password, LocalPassword, Repo, State); false -> throw(?PRV_ERROR(no_match_local_password)) end. deauth(_Repo, _State) -> ec_talk:say("Currently not implemented."). %% ec_talk:say("User `~s` removed from the local machine. " %% "To authenticate again, run `rebar3 hex user auth` " %% "or create a new user with `rebar3 hex user register`", [Username]). reset_password(Repo, State) -> User = ec_talk:ask_default("Username or Email:", string, ""), case hex_api_user:reset_password(User, Repo) of {ok, {201, _Headers, #{<<"email">> := Email}}} -> ec_talk:say("Email with reset link sent to ~ts", [Email]), {ok, State}; {ok, {_Status, _Headers, #{<<"message">> := Message}}} -> ?PRV_ERROR({reset_failure, Message}); {error, Reason} -> ?PRV_ERROR({reset_failure, io_lib:format("~p", [Reason])}) end. %% Internal functions get_password() -> rebar3_hex_utils:get_password(<<"Account Password: ">>). get_password(confirm) -> rebar3_hex_utils:get_password(<<"Account Password (confirm): ">>). create_user(Username, Email, Password, Repo, State) -> case hex_api_user:create(Repo, Username, Password, Email) of {ok, {201, _Headers, _Body}} -> ec_talk:say("You are required to confirm your email to access your account, " "a confirmation email has been sent to ~s", [Email]), ec_talk:say("Then run `rebar3 hex auth -r ~ts` to create and configure api tokens locally.", [maps:get(name, Repo)]), {ok, State}; {ok, {_Status, _Headers, #{<<"errors">> := Errors}}} -> ?PRV_ERROR({registration_failure, rebar3_hex_utils:pretty_print_errors(Errors)}); {error, Reason} -> ?PRV_ERROR({registration_failure, io_lib:format("~p", [Reason])}) end. pad(Binary) -> case byte_size(Binary) of Size when Size =< 16 -> <>; Size when Size =< 24 -> <>; Size when Size =< 32 -> <>; <> -> Bin end. generate_all_keys(Username, Password, LocalPassword, Repo, State) -> ec_talk:say("Generating all keys..."), RepoName = maps:get(name, Repo), Auth = base64:encode_to_string(<>), RepoConfig0 = Repo#{api_key => list_to_binary("Basic " ++ Auth)}, %% write key WriteKeyName = api_key_name(), WritePermissions = [#{<<"domain">> => <<"api">>}], {ok, WriteKey} = generate_key(RepoConfig0, WriteKeyName, WritePermissions), WriteKeyEncrypted = encrypt_write_key(Username, LocalPassword, WriteKey), %% read key RepoConfig1 = Repo#{api_key => WriteKey}, ReadKeyName = api_key_name("read"), ReadPermissions = [#{<<"domain">> => <<"api">>, <<"resource">> => <<"read">>}], {ok, ReadKey} = generate_key(RepoConfig1, ReadKeyName, ReadPermissions), %% repo key ReposKeyName = repos_key_name(), ReposPermissions = [#{<<"domain">> => <<"repositories">>}], {ok, ReposKey} = generate_key(RepoConfig1, ReposKeyName, ReposPermissions), rebar_hex_repos:update_auth_config(#{RepoName => #{username => Username, write_key => WriteKeyEncrypted, read_key => ReadKey, repos_key => ReposKey}}, State), {ok, State}. encrypt_write_key(Username, LocalPassword, WriteKey) -> AAD = Username, IV = crypto:strong_rand_bytes(16), {IV, crypto:block_encrypt(aes_gcm, pad(LocalPassword), IV, {AAD, WriteKey})}. decrypt_write_key(Username, {IV, {CipherText, CipherTag}}) -> LocalPassword = get_password(<<"Local Password: ">>), decrypt_write_key(Username, LocalPassword, {IV, {CipherText, CipherTag}}). decrypt_write_key(Username, LocalPassword, {IV, {CipherText, CipherTag}}) -> case crypto:block_decrypt(aes_gcm, pad(LocalPassword), IV, {Username, CipherText, CipherTag}) of error -> throw(?PRV_ERROR(bad_local_password)); Result -> Result end. generate_key(RepoConfig, KeyName, Permissions) -> case hex_api_key:add(RepoConfig, KeyName, Permissions) of {ok, {201, _Headers, #{<<"secret">> := Secret}}} -> {ok, Secret}; {ok, {_Status, _Headers, #{<<"message">> := Message}}} -> ?PRV_ERROR({generate_key, Message}); {error, Reason} -> ?PRV_ERROR({generate_key, io_lib:format("~p", [Reason])}) end. hostname() -> {ok, Name} = inet:gethostname(), Name. api_key_name() -> list_to_binary(hostname()). api_key_name(Postfix) -> list_to_binary([hostname(), "-api-", Postfix]). repos_key_name() -> list_to_binary([hostname(), "-repositories"]).