diff --git a/README.md b/README.md index 95089e9..c107c15 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,24 @@ Publish package tarball: {ok, {200, _Headers, _Body} = hex_api_package:publish(Config, Tarball). ``` +### Two-Factor Authentication + +When using OAuth tokens, two-factor authentication may be required. If required, the server will return `{error, otp_required}` and you should retry the request with the TOTP code via the `api_otp` configuration option: + +```erlang +%% First attempt without OTP +case hex_api_release:publish(Config, Tarball) of + {error, otp_required} -> + %% Retry with TOTP code + ConfigWithOTP = Config#{api_otp := <<"123456">>}, + hex_api_release:publish(ConfigWithOTP, Tarball); + Result -> + Result +end. +``` + +API keys don't require TOTP validation. + ### Package tarballs Unpack package tarball: diff --git a/src/hex_api.erl b/src/hex_api.erl index 2b49168..48f9e93 100644 --- a/src/hex_api.erl +++ b/src/hex_api.erl @@ -101,12 +101,13 @@ request(Config, Method, Path, Body) when is_binary(Path) and is_map(Config) -> case hex_http:request(Config, Method, build_url(Path, Config), ReqHeaders2, Body) of {ok, {Status, RespHeaders, RespBody}} -> ContentType = maps:get(<<"content-type">>, RespHeaders, <<"">>), - case binary:match(ContentType, ?ERL_CONTENT_TYPE) of + Response = case binary:match(ContentType, ?ERL_CONTENT_TYPE) of {_, _} -> {ok, {Status, RespHeaders, binary_to_term(RespBody)}}; nomatch -> {ok, {Status, RespHeaders, nil}} - end; + end, + detect_otp_error(Response); Other -> Other end. @@ -133,6 +134,8 @@ make_headers(Config) -> %% @private set_header(api_key, Token, Headers) when is_binary(Token) -> maps:put(<<"authorization">>, Token, Headers); +set_header(api_otp, OTP, Headers) when is_binary(OTP) -> + maps:put(<<"x-hex-otp">>, OTP, Headers); set_header(_, _, Headers) -> Headers. @@ -161,3 +164,17 @@ to_list(A) when is_atom(A) -> atom_to_list(A); to_list(B) when is_binary(B) -> unicode:characters_to_list(B); to_list(I) when is_integer(I) -> integer_to_list(I); to_list(Str) -> unicode:characters_to_list(Str). + +%% TODO: not needed after exdoc is fixed +%% @private +detect_otp_error({ok, {401, Headers, Body}}) -> + case maps:get(<<"www-authenticate">>, Headers, nil) of + <<"Bearer realm=\"hex\", error=\"totp_required\"", _/binary>> -> + {error, otp_required}; + <<"Bearer realm=\"hex\", error=\"invalid_totp\"", _/binary>> -> + {error, invalid_totp}; + _ -> + {ok, {401, Headers, Body}} + end; +detect_otp_error(Response) -> + Response. diff --git a/src/hex_api_release.erl b/src/hex_api_release.erl index 96d3c3d..df0cb37 100644 --- a/src/hex_api_release.erl +++ b/src/hex_api_release.erl @@ -105,15 +105,24 @@ publish(Config, Tarball) -> publish(Config, Tarball, []). publish(Config, Tarball, Params) when is_map(Config) andalso is_binary(Tarball) andalso is_list(Params) -> - QueryString = hex_api:encode_query_string([ - {replace, proplists:get_value(replace, Params, false)} - ]), - Path = hex_api:join_path_segments(hex_api:build_repository_path(Config, ["publish"])), - PathWithQuery = <>, - TarballContentType = "application/octet-stream", - Config2 = put_header(<<"content-length">>, integer_to_binary(byte_size(Tarball)), Config), - Body = {TarballContentType, Tarball}, - hex_api:post(Config2, PathWithQuery, Body). + case hex_tarball:unpack(Tarball, memory) of + {ok, #{metadata := Metadata}} -> + PackageName = maps:get(<<"name">>, Metadata), + QueryString = hex_api:encode_query_string([ + {replace, proplists:get_value(replace, Params, false)} + ]), + Path = hex_api:join_path_segments( + hex_api:build_repository_path(Config, ["packages", PackageName, "releases"]) + ), + PathWithQuery = <>, + TarballContentType = "application/octet-stream", + Config2 = put_header(<<"content-length">>, integer_to_binary(byte_size(Tarball)), Config), + Config3 = maybe_put_expect_header(Config2), + Body = {TarballContentType, Tarball}, + hex_api:post(Config3, PathWithQuery, Body); + {error, Reason} -> + {error, {tarball, Reason}} + end. %% @doc %% Deletes a package release. @@ -171,3 +180,10 @@ put_header(Name, Value, Config) -> Headers = maps:get(http_headers, Config, #{}), Headers2 = maps:put(Name, Value, Headers), maps:put(http_headers, Headers2, Config). + +%% @private +maybe_put_expect_header(Config) -> + case maps:get(send_100_continue, Config, true) of + true -> put_header(<<"expect">>, <<"100-continue">>, Config); + false -> Config + end. diff --git a/src/hex_core.erl b/src/hex_core.erl index 04b26f5..1047fb0 100644 --- a/src/hex_core.erl +++ b/src/hex_core.erl @@ -12,6 +12,15 @@ %% %% * `api_key' - Authentication key used when accessing the HTTP API. %% +%% * `api_otp' - TOTP (Time-based One-Time Password) code for two-factor authentication. +%% When using OAuth tokens, write operations require 2FA if the user has it enabled. +%% If required, the server returns one of: +%% - `{error, otp_required}' - Retry the request with a 6-digit TOTP code in this option +%% - `{error, invalid_totp}' - The provided TOTP code was incorrect, retry with correct code +%% - `{ok, {403, _, #{<<"message">> => <<"Two-factor authentication must be enabled for API write access">>}}}' - User must enable 2FA first +%% - `{ok, {429, _, _}}' - Too many failed TOTP attempts, rate limited +%% API keys do not require TOTP validation. +%% %% * `api_organization' - Name of the organization endpoint in the API, this should %% for example be set when accessing key for a specific organization. %% @@ -47,6 +56,10 @@ %% * `repo_verify_origin' - If `true' will verify the repository signature origin, %% requires protobuf messages as of hex_core v0.4.0 (default: `true'). %% +%% * `send_100_continue' - If `true' will send `Expect: 100-continue' header for +%% publish operations. This allows the server to validate authentication and +%% authorization before the client sends the request body (default: `true'). +%% %% * `tarball_max_size' - Maximum size of package tarball, defaults to %% `16_777_216' (16 MiB). Set to `infinity' to not enforce the limit. %% @@ -79,6 +92,7 @@ -type config() :: #{ api_key => binary() | undefined, + api_otp => binary() | undefined, api_organization => binary() | undefined, api_repository => binary() | undefined, api_url => binary(), @@ -93,6 +107,7 @@ repo_organization => binary() | undefined, repo_verify => boolean(), repo_verify_origin => boolean(), + send_100_continue => boolean(), tarball_max_size => pos_integer() | infinity, tarball_max_uncompressed_size => pos_integer() | infinity, docs_tarball_max_size => pos_integer() | infinity, @@ -103,6 +118,7 @@ default_config() -> #{ api_key => undefined, + api_otp => undefined, api_organization => undefined, api_repository => undefined, api_url => <<"https://hex.pm/api">>, @@ -117,6 +133,7 @@ default_config() -> repo_organization => undefined, repo_verify => true, repo_verify_origin => true, + send_100_continue => true, tarball_max_size => 16 * 1024 * 1024, tarball_max_uncompressed_size => 128 * 1024 * 1024, docs_tarball_max_size => 16 * 1024 * 1024, diff --git a/src/hex_repo.erl b/src/hex_repo.erl index 8068636..08728b7 100644 --- a/src/hex_repo.erl +++ b/src/hex_repo.erl @@ -254,5 +254,7 @@ set_header(http_etag, ETag, Headers) when is_binary(ETag) -> maps:put(<<"if-none-match">>, ETag, Headers); set_header(repo_key, Token, Headers) when is_binary(Token) -> maps:put(<<"authorization">>, Token, Headers); +set_header(api_otp, OTP, Headers) when is_binary(OTP) -> + maps:put(<<"x-hex-otp">>, OTP, Headers); set_header(_, _, Headers) -> Headers. diff --git a/test/hex_api_SUITE.erl b/test/hex_api_SUITE.erl index daedc27..2821e93 100644 --- a/test/hex_api_SUITE.erl +++ b/test/hex_api_SUITE.erl @@ -20,7 +20,8 @@ suite() -> all() -> [package_test, release_test, replace_test, user_test, owner_test, keys_test, auth_test, short_url_test, - oauth_device_flow_test, oauth_token_exchange_test, oauth_refresh_token_test, oauth_revoke_test]. + oauth_device_flow_test, oauth_token_exchange_test, oauth_refresh_token_test, oauth_revoke_test, + publish_with_expect_header_test, publish_without_expect_header_test]. package_test(_Config) -> {ok, {200, _, Package}} = hex_api_package:get(?CONFIG, <<"ecto">>), @@ -45,7 +46,9 @@ release_test(_Config) -> ok. publish_test(_Config) -> - {ok, {200, _, Release}} = hex_api_release:publish(?CONFIG, <<"dummy_tarball">>), + Metadata = #{<<"name">> => <<"ecto">>, <<"version">> => <<"1.0.0">>}, + {ok, #{tarball := Tarball}} = hex_tarball:create(Metadata, []), + {ok, {200, _, Release}} = hex_api_release:publish(?CONFIG, Tarball), #{<<"version">> := <<"1.0.0">>, <<"requirements">> := Requirements} = Release, #{ <<"decimal">> := #{ @@ -55,7 +58,9 @@ publish_test(_Config) -> ok. replace_test(_Config) -> - {ok, {201, _, Release}} = hex_api_release:publish(?CONFIG, <<"dummy_tarball">>, [ + Metadata = #{<<"name">> => <<"ecto">>, <<"version">> => <<"1.0.0">>}, + {ok, #{tarball := Tarball}} = hex_tarball:create(Metadata, []), + {ok, {201, _, Release}} = hex_api_release:publish(?CONFIG, Tarball, [ {replace, true} ]), #{<<"version">> := <<"1.0.0">>, <<"requirements">> := Requirements} = Release, @@ -168,3 +173,25 @@ oauth_revoke_test(_Config) -> NonExistentToken = <<"non_existent_token">>, {ok, {200, _, nil}} = hex_api_oauth:revoke_token(?CONFIG, ClientId, NonExistentToken), ok. + +publish_with_expect_header_test(_Config) -> + % Test that send_100_continue => true includes Expect: 100-continue header + Metadata = #{<<"name">> => <<"expect_test">>, <<"version">> => <<"1.0.0">>}, + {ok, #{tarball := Tarball}} = hex_tarball:create(Metadata, []), + + % Default config has send_100_continue => true + Config = ?CONFIG, + {ok, {200, _, Release}} = hex_api_release:publish(Config, Tarball), + #{<<"version">> := <<"1.0.0">>} = Release, + ok. + +publish_without_expect_header_test(_Config) -> + % Test that send_100_continue => false does not include Expect header + Metadata = #{<<"name">> => <<"no_expect_test">>, <<"version">> => <<"1.0.0">>}, + {ok, #{tarball := Tarball}} = hex_tarball:create(Metadata, []), + + % Explicitly disable send_100_continue + Config = maps:put(send_100_continue, false, ?CONFIG), + {ok, {200, _, Release}} = hex_api_release:publish(Config, Tarball), + #{<<"version">> := <<"1.0.0">>} = Release, + ok. diff --git a/test/support/hex_http_test.erl b/test/support/hex_http_test.erl index ec3a039..9397afb 100644 --- a/test/support/hex_http_test.erl +++ b/test/support/hex_http_test.erl @@ -158,9 +158,39 @@ fixture(get, <>, _, _) -> }, {ok, {200, api_headers(), term_to_binary(Payload)}}; -%% /publish +%% /packages/:name/releases - test expect header presence -fixture(get, <>, _, _) -> +fixture(post, <>, Headers, _) -> + % Verify that Expect: 100-continue header is present + case maps:get(<<"expect">>, Headers, undefined) of + <<"100-continue">> -> + Payload = #{ + <<"version">> => <<"1.0.0">>, + <<"requirements">> => #{} + }, + {ok, {200, api_headers(), term_to_binary(Payload)}}; + _ -> + error({expect_header_missing, Headers}) + end; + +%% /packages/:name/releases - test expect header absence + +fixture(post, <>, Headers, _) -> + % Verify that Expect header is NOT present + case maps:get(<<"expect">>, Headers, undefined) of + undefined -> + Payload = #{ + <<"version">> => <<"1.0.0">>, + <<"requirements">> => #{} + }, + {ok, {200, api_headers(), term_to_binary(Payload)}}; + Value -> + error({expect_header_present, Value}) + end; + +%% /packages/:name/releases + +fixture(post, <>, _, _) -> Payload = #{ <<"version">> => <<"1.0.0">>, <<"requirements">> => #{ @@ -173,9 +203,9 @@ fixture(get, <>, _, _) -> }, {ok, {200, api_headers(), term_to_binary(Payload)}}; -%% /publish?replace=true +%% /packages/:name/releases?replace=true -fixture(post, <>, _, _) -> +fixture(post, <>, _, _) -> Payload = #{ <<"version">> => <<"1.0.0">>, <<"requirements">> => #{