From 800b4b32c762d5344e53d6edc118af20a4a6b0ed Mon Sep 17 00:00:00 2001 From: Ilya Averyanov Date: Mon, 1 Nov 2021 12:52:03 +0300 Subject: [PATCH] refactor(authn api): use config schemas for request validations (#5999) --- apps/emqx/src/emqx_authentication_config.erl | 1 - apps/emqx/src/emqx_listeners.erl | 11 +- apps/emqx_authn/src/emqx_authn_api.erl | 2412 +++++------------ apps/emqx_authn/src/emqx_authn_schema.erl | 8 +- apps/emqx_authn/test/emqx_authn_api_SUITE.erl | 402 ++- apps/emqx_authn/test/emqx_authn_test_lib.erl | 27 +- .../emqx_dashboard/src/emqx_dashboard_api.erl | 2 +- .../src/emqx_dashboard_swagger.erl | 160 +- .../test/emqx_swagger_parameter_SUITE.erl | 43 +- .../test/emqx_swagger_requestBody_SUITE.erl | 55 +- apps/emqx_modules/src/emqx_rewrite_api.erl | 2 +- 11 files changed, 1228 insertions(+), 1895 deletions(-) diff --git a/apps/emqx/src/emqx_authentication_config.erl b/apps/emqx/src/emqx_authentication_config.erl index 24abb951c..2f7e55eba 100644 --- a/apps/emqx/src/emqx_authentication_config.erl +++ b/apps/emqx/src/emqx_authentication_config.erl @@ -268,4 +268,3 @@ dir(ChainName, ID) when is_binary(ID) -> binary:replace(iolist_to_binary([to_bin(ChainName), "-", ID]), <<":">>, <<"-">>); dir(ChainName, Config) when is_map(Config) -> dir(ChainName, authenticator_id(Config)). - diff --git a/apps/emqx/src/emqx_listeners.erl b/apps/emqx/src/emqx_listeners.erl index 187a55fdd..0581937b1 100644 --- a/apps/emqx/src/emqx_listeners.erl +++ b/apps/emqx/src/emqx_listeners.erl @@ -352,10 +352,13 @@ listener_id(Type, ListenerName) -> list_to_atom(lists:append([str(Type), ":", str(ListenerName)])). parse_listener_id(Id) -> - [Type, Name] = string:split(str(Id), ":", leading), - case lists:member(Type, ?TYPES_STRING) of - true -> {list_to_existing_atom(Type), list_to_atom(Name)}; - false -> {error, {invalid_listener_id, Id}} + case string:split(str(Id), ":", leading) of + [Type, Name] -> + case lists:member(Type, ?TYPES_STRING) of + true -> {list_to_existing_atom(Type), list_to_atom(Name)}; + false -> {error, {invalid_listener_id, Id}} + end; + _ -> {error, {invalid_listener_id, Id}} end. zone(Opts) -> diff --git a/apps/emqx_authn/src/emqx_authn_api.erl b/apps/emqx_authn/src/emqx_authn_api.erl index 1b02d37ae..c3978fb0d 100644 --- a/apps/emqx_authn/src/emqx_authn_api.erl +++ b/apps/emqx_authn/src/emqx_authn_api.erl @@ -18,1848 +18,582 @@ -behaviour(minirest_api). +-include_lib("typerefl/include/types.hrl"). -include("emqx_authn.hrl"). +-import(hoconsc, [mk/2, ref/1]). +-import(emqx_dashboard_swagger, [error_codes/2]). + +-define(BAD_REQUEST, 'BAD_REQUEST'). +-define(NOT_FOUND, 'NOT_FOUND'). +-define(CONFLICT, 'CONFLICT'). + +% Swagger + -export([ api_spec/0 - , authentication/2 - , authentication2/2 - , authentication3/2 - , authentication4/2 - , move/2 - , move2/2 - , import_users/2 - , import_users2/2 - , users/2 - , users2/2 - , users3/2 - , users4/2 + , paths/0 + , schema/1 ]). --define(EXAMPLE_1, #{mechanism => <<"password-based">>, - backend => <<"built-in-database">>, - user_id_type => <<"username">>, - password_hash_algorithm => #{ - name => <<"sha256">> - }}). +-export([ roots/0 + , fields/1 + ]). --define(EXAMPLE_2, #{mechanism => <<"password-based">>, - backend => <<"http">>, - method => <<"post">>, - url => <<"http://localhost:80/login">>, - headers => #{ - <<"content-type">> => <<"application/json">> - }, - body => #{ - <<"username">> => <<"${mqtt-username}">>, - <<"password">> => <<"${mqtt-password}">> - }}). +-export([ authenticators/2 + , authenticator/2 + , listener_authenticators/2 + , listener_authenticator/2 + , authenticator_move/2 + , listener_authenticator_move/2 + , authenticator_import_users/2 + , listener_authenticator_import_users/2 + , authenticator_users/2 + , authenticator_user/2 + , listener_authenticator_users/2 + , listener_authenticator_user/2 + ]). --define(EXAMPLE_3, #{mechanism => <<"jwt">>, - use_jwks => false, - algorithm => <<"hmac-based">>, - secret => <<"mysecret">>, - secret_base64_encoded => false, - verify_claims => #{ - <<"username">> => <<"${mqtt-username}">> - }}). - --define(EXAMPLE_4, #{mechanism => <<"password-based">>, - backend => <<"mongodb">>, - server => <<"127.0.0.1:27017">>, - database => example, - collection => users, - selector => #{ - username => <<"${mqtt-username}">> - }, - password_hash_field => <<"password_hash">>, - salt_field => <<"salt">>, - is_superuser_field => <<"is_superuser">>, - password_hash_algorithm => <<"sha256">>, - salt_position => <<"prefix">> - }). - --define(EXAMPLE_5, #{mechanism => <<"password-based">>, - backend => <<"redis">>, - server => <<"127.0.0.1:6379">>, - database => 0, - query => <<"HMGET ${mqtt-username} password_hash salt">>, - password_hash_algorithm => <<"sha256">>, - salt_position => <<"prefix">> - }). - --define(INSTANCE_EXAMPLE_1, maps:merge(?EXAMPLE_1, #{id => <<"password-based:built-in-database">>, - enable => true})). - --define(INSTANCE_EXAMPLE_2, maps:merge(?EXAMPLE_2, #{id => <<"password-based:http">>, - connect_timeout => "5s", - enable_pipelining => true, - headers => #{ - <<"accept">> => <<"application/json">>, - <<"cache-control">> => <<"no-cache">>, - <<"connection">> => <<"keepalive">>, - <<"content-type">> => <<"application/json">>, - <<"keep-alive">> => <<"timeout=5">> - }, - max_retries => 5, - pool_size => 8, - request_timeout => "5s", - retry_interval => "1s", - enable => true})). - --define(INSTANCE_EXAMPLE_3, maps:merge(?EXAMPLE_3, #{id => <<"jwt">>, - enable => true})). - --define(INSTANCE_EXAMPLE_4, maps:merge(?EXAMPLE_4, #{id => <<"password-based:mongodb">>, - mongo_type => <<"single">>, - pool_size => 8, - ssl => #{ - enable => false - }, - topology => #{ - max_overflow => 8, - pool_size => 8 - }, - enable => true})). - --define(INSTANCE_EXAMPLE_5, maps:merge(?EXAMPLE_5, #{id => <<"password-based:redis">>, - auto_reconnect => true, - redis_type => single, - pool_size => 8, - ssl => #{ - enable => false - }, - enable => true})). - --define(ERR_RESPONSE(Desc), #{description => Desc, - content => #{ - 'application/json' => #{ - schema => minirest:ref(<<"Error">>), - examples => #{ - example1 => #{ - summary => <<"Not Found">>, - value => #{code => <<"NOT_FOUND">>, message => <<"Authenticator '67e4c9d3' does not exist">>} - }, - example2 => #{ - summary => <<"Conflict">>, - value => #{code => <<"ALREADY_EXISTS">>, message => <<"Name has be used">>} - }, - example3 => #{ - summary => <<"Bad Request 1">>, - value => #{code => <<"OUT_OF_RANGE">>, message => <<"Out of range">>} - } - }}}}). +-export([authenticator_examples/0]). api_spec() -> - {[ authentication_api() - , authentication_api2() - , move_api() - , authentication_api3() - , authentication_api4() - , move_api2() - , import_users_api() - , import_users_api2() - , users_api() - , users2_api() - , users3_api() - , users4_api() - ], definitions()}. + emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}). -authentication_api() -> - Metadata = #{ - post => create_authenticator_api_spec(), - get => list_authenticators_api_spec() - }, - {"/authentication", Metadata, authentication}. +paths() -> [ "/authentication" + , "/authentication/:id" + , "/authentication/:id/move" + , "/authentication/:id/import_users" + , "/authentication/:id/users" + , "/authentication/:id/users/:user_id" -authentication_api2() -> - Metadata = #{ - get => find_authenticator_api_spec(), - put => update_authenticator_api_spec(), - delete => delete_authenticator_api_spec() - }, - {"/authentication/:id", Metadata, authentication2}. + , "/listeners/:listener_id/authentication" + , "/listeners/:listener_id/authentication/:id" + , "/listeners/:listener_id/authentication/:id/move" + , "/listeners/:listener_id/authentication/:id/import_users" + , "/listeners/:listener_id/authentication/:id/users" + , "/listeners/:listener_id/authentication/:id/users/:user_id" + ]. -authentication_api3() -> - Metadata = #{ - post => create_authenticator_api_spec2(), - get => list_authenticators_api_spec2() - }, - {"/listeners/:listener_id/authentication", Metadata, authentication3}. +roots() -> [ request_user_create + , request_user_update + , request_move + , request_import_users + , response_user + ]. -authentication_api4() -> - Metadata = #{ - get => find_authenticator_api_spec2(), - put => update_authenticator_api_spec2(), - delete => delete_authenticator_api_spec2() - }, - {"/listeners/:listener_id/authentication/:id", Metadata, authentication4}. +fields(request_user_create) -> + [ + {user_id, binary()}, + {password, binary()}, + {is_superuser, mk(boolean(), #{default => false, nullable => true})} + ]; -move_api() -> - Metadata = #{ - post => move_authenticator_api_spec() - }, - {"/authentication/:id/move", Metadata, move}. +fields(request_user_update) -> + [ + {password, binary()}, + {is_superuser, mk(boolean(), #{default => false, nullable => true})} + ]; -move_api2() -> - Metadata = #{ - post => move_authenticator_api_spec2() - }, - {"/listeners/:listener_id/authentication/:id/move", Metadata, move2}. +fields(request_move) -> + [{position, binary()}]; -import_users_api() -> - Metadata = #{ - post => import_users_api_spec() - }, - {"/authentication/:id/import_users", Metadata, import_users}. +fields(request_import_users) -> + [{filename, binary()}]; -import_users_api2() -> - Metadata = #{ - post => import_users_api_spec2() - }, - {"/listeners/:listener_id/authentication/:id/import_users", Metadata, import_users2}. - -users_api() -> - Metadata = #{ - post => create_user_api_spec(), - get => list_users_api_spec() - }, - {"/authentication/:id/users", Metadata, users}. - -users2_api() -> - Metadata = #{ - put => update_user_api_spec(), - get => find_user_api_spec(), - delete => delete_user_api_spec() - }, - {"/authentication/:id/users/:user_id", Metadata, users2}. - -users3_api() -> - Metadata = #{ - post => create_user_api_spec2(), - get => list_users_api_spec2() - }, - {"/listeners/:listener_id/authentication/:id/users", Metadata, users3}. - -users4_api() -> - Metadata = #{ - put => update_user_api_spec2(), - get => find_user_api_spec2(), - delete => delete_user_api_spec2() - }, - {"/listeners/:listener_id/authentication/:id/users/:user_id", Metadata, users4}. - -create_authenticator_api_spec() -> - #{ - description => <<"Create a authenticator for global authentication">>, - requestBody => #{ - content => #{ - 'application/json' => #{ - schema => minirest:ref(<<"AuthenticatorConfig">>), - examples => #{ - default => #{ - summary => <<"Default">>, - value => emqx_json:encode(?EXAMPLE_1) - }, - http => #{ - summary => <<"Authentication provided by HTTP Server">>, - value => emqx_json:encode(?EXAMPLE_2) - }, - jwt => #{ - summary => <<"JWT Authentication">>, - value => emqx_json:encode(?EXAMPLE_3) - }, - mongodb => #{ - summary => <<"Authentication with MongoDB">>, - value => emqx_json:encode(?EXAMPLE_4) - }, - redis => #{ - summary => <<"Authentication with Redis">>, - value => emqx_json:encode(?EXAMPLE_5) - } - } - } - } - }, - responses => #{ - <<"201">> => #{ - description => <<"Created">>, - content => #{ - 'application/json' => #{ - schema => minirest:ref(<<"AuthenticatorInstance">>), - examples => #{ - example1 => #{ - summary => <<"Example 1">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_1) - }, - example2 => #{ - summary => <<"Example 2">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_2) - }, - example3 => #{ - summary => <<"Example 3">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_3) - }, - example4 => #{ - summary => <<"Example 4">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_4) - }, - example5 => #{ - summary => <<"Example 5">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_5) - } - } - } - } - }, - <<"400">> => ?ERR_RESPONSE(<<"Bad Request">>), - <<"409">> => ?ERR_RESPONSE(<<"Conflict">>) - } - }. - -create_authenticator_api_spec2() -> - Spec = create_authenticator_api_spec(), - Spec#{ - description => <<"Create a authenticator for listener">>, - parameters => [ - #{ - name => listener_id, - in => path, - schema => #{ - type => string - }, - required => true - } - ] - }. - -list_authenticators_api_spec() -> - #{ - description => <<"List authenticators for global authentication">>, - responses => #{ - <<"200">> => #{ - description => <<"OK">>, - content => #{ - 'application/json' => #{ - schema => #{ - type => array, - items => minirest:ref(<<"AuthenticatorInstance">>) - }, - examples => #{ - example => #{ - summary => <<"Example">>, - value => emqx_json:encode([ ?INSTANCE_EXAMPLE_1 - , ?INSTANCE_EXAMPLE_2 - , ?INSTANCE_EXAMPLE_3 - , ?INSTANCE_EXAMPLE_4 - , ?INSTANCE_EXAMPLE_5 - ])}}}}}}}. - -list_authenticators_api_spec2() -> - Spec = list_authenticators_api_spec(), - Spec#{ - description => <<"List authenticators for listener">>, - parameters => [ - #{ - name => listener_id, - in => path, - schema => #{ - type => string - }, - required => true - } - ] - }. - -find_authenticator_api_spec() -> - #{ - description => <<"Get authenticator by id">>, - parameters => [ - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - } - ], - responses => #{ - <<"200">> => #{ - description => <<"OK">>, - content => #{ - 'application/json' => #{ - schema => minirest:ref(<<"AuthenticatorInstance">>), - examples => #{ - example1 => #{ - summary => <<"Example 1">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_1) - }, - example2 => #{ - summary => <<"Example 2">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_2) - }, - example3 => #{ - summary => <<"Example 3">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_3) - }, - example4 => #{ - summary => <<"Example 4">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_4) - }, - example5 => #{ - summary => <<"Example 5">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_5) - } - } - } - } - }, - <<"404">> => ?ERR_RESPONSE(<<"Not Found">>) - } - }. - -find_authenticator_api_spec2() -> - Spec = find_authenticator_api_spec(), - Spec#{ - parameters => [ - #{ - name => listener_id, - in => path, - description => <<"Listener id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - } - ] - }. - -update_authenticator_api_spec() -> - #{ - description => <<"Update authenticator">>, - parameters => [ - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - } - ], - requestBody => #{ - content => #{ - 'application/json' => #{ - schema => minirest:ref(<<"AuthenticatorConfig">>), - examples => #{ - example1 => #{ - summary => <<"Example 1">>, - value => emqx_json:encode(?EXAMPLE_1) - }, - example2 => #{ - summary => <<"Example 2">>, - value => emqx_json:encode(?EXAMPLE_2) - }, - example3 => #{ - summary => <<"Example 3">>, - value => emqx_json:encode(?EXAMPLE_3) - }, - example4 => #{ - summary => <<"Example 4">>, - value => emqx_json:encode(?EXAMPLE_4) - }, - example5 => #{ - summary => <<"Example 5">>, - value => emqx_json:encode(?EXAMPLE_5) - } - } - } - } - }, - responses => #{ - <<"200">> => #{ - description => <<"OK">>, - content => #{ - 'application/json' => #{ - schema => minirest:ref(<<"AuthenticatorInstance">>), - examples => #{ - example1 => #{ - summary => <<"Example 1">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_1) - }, - example2 => #{ - summary => <<"Example 2">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_2) - }, - example3 => #{ - summary => <<"Example 3">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_3) - }, - example4 => #{ - summary => <<"Example 4">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_4) - }, - example5 => #{ - summary => <<"Example 5">>, - value => emqx_json:encode(?INSTANCE_EXAMPLE_5) - } - } - } - } - }, - <<"400">> => ?ERR_RESPONSE(<<"Bad Request">>), - <<"404">> => ?ERR_RESPONSE(<<"Not Found">>), - <<"409">> => ?ERR_RESPONSE(<<"Conflict">>) - } - }. - -update_authenticator_api_spec2() -> - Spec = update_authenticator_api_spec(), - Spec#{ - parameters => [ - #{ - name => listener_id, - in => path, - description => <<"Listener id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - } - ] - }. - -delete_authenticator_api_spec() -> - #{ - description => <<"Delete authenticator">>, - parameters => [ - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - } - ], - responses => #{ - <<"204">> => #{ - description => <<"No Content">> - }, - <<"404">> => ?ERR_RESPONSE(<<"Not Found">>) - } - }. - -delete_authenticator_api_spec2() -> - Spec = delete_authenticator_api_spec(), - Spec#{ - parameters => [ - #{ - name => listener_id, - in => path, - description => <<"Listener id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - } - ] - }. - -move_authenticator_api_spec() -> - #{ - description => <<"Move authenticator">>, - parameters => [ - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - } - ], - requestBody => #{ - content => #{ - 'application/json' => #{ - schema => #{ - oneOf => [ - #{ - type => object, - required => [position], - properties => #{ - position => #{ - type => string, - enum => [<<"top">>, <<"bottom">>], - example => <<"top">> - } - } - }, - #{ - type => object, - required => [position], - properties => #{ - position => #{ - type => string, - description => <<"before:">>, - example => <<"before:password-based:mysql">> - } - } - } - ] - } - } - } - }, - responses => #{ - <<"204">> => #{ - description => <<"No Content">> - }, - <<"400">> => ?ERR_RESPONSE(<<"Bad Request">>), - <<"404">> => ?ERR_RESPONSE(<<"Not Found">>) - } - }. - -move_authenticator_api_spec2() -> - Spec = move_authenticator_api_spec(), - Spec#{ - parameters => [ - #{ - name => listener_id, - in => path, - description => <<"Listener id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - } - ] - }. - -import_users_api_spec() -> - #{ - description => <<"Import users from json/csv file">>, - parameters => [ - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - } - ], - requestBody => #{ - content => #{ - 'application/json' => #{ - schema => #{ - type => object, - required => [filename], - properties => #{ - filename => #{ - type => string - } - } - } - } - } - }, - responses => #{ - <<"204">> => #{ - description => <<"No Content">> - }, - <<"400">> => ?ERR_RESPONSE(<<"Bad Request">>), - <<"404">> => ?ERR_RESPONSE(<<"Not Found">>) - } - }. - -import_users_api_spec2() -> - Spec = import_users_api_spec(), - Spec#{ - parameters => [ - #{ - name => listener_id, - in => path, - description => <<"Listener id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - } - ] - }. - -create_user_api_spec() -> - #{ - description => <<"Add user">>, - parameters => [ - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - } - ], - requestBody => #{ - content => #{ - 'application/json' => #{ - schema => #{ - type => object, - required => [user_id, password], - properties => #{ - user_id => #{ - type => string - }, - password => #{ - type => string - }, - is_superuser => #{ - type => boolean, - default => false - } - } - } - } - } - }, - responses => #{ - <<"201">> => #{ - description => <<"Created">>, - content => #{ - 'application/json' => #{ - schema => #{ - type => object, - properties => #{ - user_id => #{ - type => string - }, - is_superuser => #{ - type => boolean - } - } - } - } - } - }, - <<"400">> => ?ERR_RESPONSE(<<"Bad Request">>), - <<"404">> => ?ERR_RESPONSE(<<"Not Found">>) - } - }. - -create_user_api_spec2() -> - Spec = create_user_api_spec(), - Spec#{ - parameters => [ - #{ - name => listener_id, - in => path, - description => <<"Listener id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - } - ] - }. - -list_users_api_spec() -> - #{ - description => <<"List users">>, - parameters => [ - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => page, - in => query, - description => <<"Page Index">>, - schema => #{ - type => integer - }, - required => false - }, - #{ - name => limit, - in => query, - description => <<"Page limit">>, - schema => #{ - type => integer - }, - required => false - } - ], - responses => #{ - <<"200">> => #{ - description => <<"OK">>, - content => #{ - 'application/json' => #{ - schema => #{ - type => array, - items => #{ - type => object, - properties => #{ - user_id => #{ - type => string - }, - is_superuser => #{ - type => boolean - } - } - } - } - } - } - }, - <<"404">> => ?ERR_RESPONSE(<<"Not Found">>) - } - }. - -list_users_api_spec2() -> - Spec = list_users_api_spec(), - Spec#{ - parameters => [ - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => listener_id, - in => path, - description => <<"Listener id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => page, - in => query, - description => <<"Page Index">>, - schema => #{ - type => integer - }, - required => false - }, - #{ - name => limit, - in => query, - description => <<"Page limit">>, - schema => #{ - type => integer - }, - required => false - } - ] - }. - -update_user_api_spec() -> - #{ - description => <<"Update user">>, - parameters => [ - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => user_id, - in => path, - description => <<"User id">>, - schema => #{ - type => string - }, - required => true - } - ], - requestBody => #{ - content => #{ - 'application/json' => #{ - schema => #{ - type => object, - properties => #{ - password => #{ - type => string - }, - is_superuser => #{ - type => boolean - } - } - } - } - } - }, - responses => #{ - <<"200">> => #{ - description => <<"OK">>, - content => #{ - 'application/json' => #{ - schema => #{ - type => array, - items => #{ - type => object, - properties => #{ - user_id => #{ - type => string - }, - is_superuser => #{ - type => boolean - } - } - } - } - } - } - }, - <<"400">> => ?ERR_RESPONSE(<<"Bad Request">>), - <<"404">> => ?ERR_RESPONSE(<<"Not Found">>) - } - }. - -update_user_api_spec2() -> - Spec = update_user_api_spec(), - Spec#{ - parameters => [ - #{ - name => listener_id, - in => path, - description => <<"Listener id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => user_id, - in => path, - description => <<"User id">>, - schema => #{ - type => string - }, - required => true - } - ] - }. - -find_user_api_spec() -> - #{ - description => <<"Get user info">>, - parameters => [ - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => user_id, - in => path, - description => <<"User id">>, - schema => #{ - type => string - }, - required => true - } - ], - responses => #{ - <<"200">> => #{ - description => <<"OK">>, - content => #{ - 'application/json' => #{ - schema => #{ - type => array, - items => #{ - type => object, - properties => #{ - user_id => #{ - type => string - }, - is_superuser => #{ - type => boolean - } - } - } - } - } - } - }, - <<"404">> => ?ERR_RESPONSE(<<"Not Found">>) - } - }. - -find_user_api_spec2() -> - Spec = find_user_api_spec(), - Spec#{ - parameters => [ - #{ - name => listener_id, - in => path, - description => <<"Listener id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => user_id, - in => path, - description => <<"User id">>, - schema => #{ - type => string - }, - required => true - } - ] - }. - -delete_user_api_spec() -> - #{ - description => <<"Delete user">>, - parameters => [ - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => user_id, - in => path, - description => <<"User id">>, - schema => #{ - type => string - }, - required => true - } - ], - responses => #{ - <<"204">> => #{ - description => <<"No Content">> - }, - <<"404">> => ?ERR_RESPONSE(<<"Not Found">>) - } - }. - -delete_user_api_spec2() -> - Spec = delete_user_api_spec(), - Spec#{ - parameters => [ - #{ - name => listener_id, - in => path, - description => <<"Listener id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => id, - in => path, - description => <<"Authenticator id">>, - schema => #{ - type => string - }, - required => true - }, - #{ - name => user_id, - in => path, - description => <<"User id">>, - schema => #{ - type => string - }, - required => true - } - ] - }. - - -definitions() -> - AuthenticatorConfigDef = #{ - allOf => [ - #{ - type => object, - properties => #{ - enable => #{ - type => boolean, - default => true, - example => true - } - } - }, - #{ - oneOf => [ minirest:ref(<<"PasswordBasedBuiltInDatabase">>) - , minirest:ref(<<"PasswordBasedMySQL">>) - , minirest:ref(<<"PasswordBasedPostgreSQL">>) - , minirest:ref(<<"PasswordBasedMongoDB">>) - , minirest:ref(<<"PasswordBasedRedis">>) - , minirest:ref(<<"PasswordBasedHTTPServer">>) - , minirest:ref(<<"JWT">>) - , minirest:ref(<<"SCRAMBuiltInDatabase">>) - ] - } - ] - }, - - AuthenticatorInstanceDef = #{ - allOf => [ - #{ - type => object, - properties => #{ - id => #{ - type => string - } - } - } - ] ++ maps:get(allOf, AuthenticatorConfigDef) - }, - - PasswordBasedBuiltInDatabaseDef = #{ - type => object, - required => [mechanism, backend], - properties => #{ - mechanism => #{ - type => string, - enum => [<<"password-based">>], - example => <<"password-based">> - }, - backend => #{ - type => string, - enum => [<<"built-in-database">>], - example => <<"built-in-database">> - }, - user_id_type => #{ - type => string, - enum => [<<"username">>, <<"clientid">>], - example => <<"username">> - }, - password_hash_algorithm => minirest:ref(<<"PasswordHashAlgorithm">>) - } - }, - - PasswordBasedMySQLDef = #{ - type => object, - required => [ mechanism - , backend - , server - , database - , username - , password - , query], - properties => #{ - mechanism => #{ - type => string, - enum => [<<"password-based">>], - example => <<"password-based">> - }, - backend => #{ - type => string, - enum => [<<"mysql">>], - example => <<"mysql">> - }, - server => #{ - type => string, - example => <<"localhost:3306">> - }, - database => #{ - type => string - }, - pool_size => #{ - type => integer, - default => 8 - }, - username => #{ - type => string - }, - password => #{ - type => string - }, - auto_reconnect => #{ - type => boolean, - default => true - }, - ssl => minirest:ref(<<"SSL">>), - password_hash_algorithm => #{ - type => string, - enum => [<<"plain">>, <<"md5">>, <<"sha">>, <<"sha256">>, <<"sha512">>, <<"bcrypt">>], - default => <<"sha256">> - }, - salt_position => #{ - type => string, - enum => [<<"prefix">>, <<"suffix">>], - default => <<"prefix">> - }, - query => #{ - type => string, - example => <<"SELECT password_hash FROM mqtt_user WHERE username = ${mqtt-username}">> - }, - query_timeout => #{ - type => string, - description => <<"Query timeout">>, - default => "5s" - } - } - }, - - PasswordBasedPostgreSQLDef = #{ - type => object, - required => [ mechanism - , backend - , server - , database - , username - , password - , query], - properties => #{ - mechanism => #{ - type => string, - enum => [<<"password-based">>], - example => <<"password-based">> - }, - backend => #{ - type => string, - enum => [<<"postgresql">>], - example => <<"postgresql">> - }, - server => #{ - type => string, - example => <<"localhost:5432">> - }, - database => #{ - type => string - }, - pool_size => #{ - type => integer, - default => 8 - }, - username => #{ - type => string - }, - password => #{ - type => string - }, - auto_reconnect => #{ - type => boolean, - default => true - }, - password_hash_algorithm => #{ - type => string, - enum => [<<"plain">>, <<"md5">>, <<"sha">>, <<"sha256">>, <<"sha512">>, <<"bcrypt">>], - default => <<"sha256">> - }, - salt_position => #{ - type => string, - enum => [<<"prefix">>, <<"suffix">>], - default => <<"prefix">> - }, - query => #{ - type => string, - example => <<"SELECT password_hash FROM mqtt_user WHERE username = ${mqtt-username}">> - } - } - }, - - PasswordBasedMongoDBDef = #{ - type => object, - required => [ mechanism - , backend - , server - , servers - , replica_set_name - , database - , username - , password - , collection - , selector - , password_hash_field - ], - properties => #{ - mechanism => #{ - type => string, - enum => [<<"password-based">>], - example => <<"password-based">> - }, - backend => #{ - type => string, - enum => [<<"mongodb">>], - example => <<"mongodb">> - }, - server => #{ - description => <<"Mutually exclusive with the 'servers' field, only valid in standalone mode">>, - type => string, - example => <<"127.0.0.1:27017">> - }, - servers => #{ - description => <<"Mutually exclusive with the 'server' field, only valid in replica set and sharded mode">>, - type => array, - items => #{ - type => string - }, - example => [<<"127.0.0.1:27017">>] - }, - replica_set_name => #{ - description => <<"Only valid in replica set mode">>, - type => string - }, - database => #{ - type => string - }, - username => #{ - type => string - }, - password => #{ - type => string - }, - auth_source => #{ - type => string, - default => <<"admin">> - }, - pool_size => #{ - type => integer, - default => 8 - }, - collection => #{ - type => string - }, - selector => #{ - type => object, - additionalProperties => true, - example => <<"{\"username\":\"${mqtt-username}\"}">> - }, - password_hash_field => #{ - type => string, - example => <<"password_hash">> - }, - salt_field => #{ - type => string, - example => <<"salt">> - }, - is_superuser_field => #{ - type => string, - example => <<"is_superuser">> - }, - password_hash_algorithm => #{ - type => string, - enum => [<<"plain">>, <<"md5">>, <<"sha">>, <<"sha256">>, <<"sha512">>, <<"bcrypt">>], - default => <<"sha256">>, - example => <<"sha256">> - }, - salt_position => #{ - description => <<"Only valid when the 'salt_field' field is specified">>, - type => string, - enum => [<<"prefix">>, <<"suffix">>], - default => <<"prefix">>, - example => <<"prefix">> - } - } - }, - - PasswordBasedRedisDef = #{ - type => object, - required => [ mechanism - , backend - , server - , servers - , password - , database - , query - ], - properties => #{ - mechanism => #{ - type => string, - enum => [<<"password-based">>], - example => <<"password-based">> - }, - backend => #{ - type => string, - enum => [<<"redis">>], - example => <<"redis">> - }, - server => #{ - description => <<"Mutually exclusive with the 'servers' field, only valid in standalone mode">>, - type => string, - example => <<"127.0.0.1:27017">> - }, - servers => #{ - description => <<"Mutually exclusive with the 'server' field, only valid in cluster and sentinel mode">>, - type => array, - items => #{ - type => string - }, - example => [<<"127.0.0.1:27017">>] - }, - sentinel => #{ - description => <<"Only valid in sentinel mode">>, - type => string - }, - password => #{ - type => string - }, - database => #{ - type => integer, - example => 0 - }, - query => #{ - type => string, - example => <<"HMGET ${mqtt-username} password_hash salt">> - }, - password_hash_algorithm => #{ - type => string, - enum => [<<"plain">>, <<"md5">>, <<"sha">>, <<"sha256">>, <<"sha512">>, <<"bcrypt">>], - default => <<"sha256">>, - example => <<"sha256">> - }, - salt_position => #{ - type => string, - enum => [<<"prefix">>, <<"suffix">>], - default => <<"prefix">>, - example => <<"prefix">> - }, - pool_size => #{ - type => integer, - default => 8 - }, - auto_reconnect => #{ - type => boolean, - default => true - } - } - }, - - PasswordBasedHTTPServerDef = #{ - type => object, - required => [ mechanism - , backend - , url - , body - ], - properties => #{ - mechanism => #{ - type => string, - enum => [<<"password-based">>], - example => <<"password-based">> - }, - backend => #{ - type => string, - enum => [<<"http">>], - example => <<"http">> - }, - method => #{ - type => string, - enum => [<<"get">>, <<"post">>], - default => <<"post">> - }, - url => #{ - type => string, - example => <<"http://localhost:80/login">> - }, - headers => #{ - type => object, - additionalProperties => #{ - type => string - } - }, - body => #{ - type => object - }, - connect_timeout => #{ - type => string, - default => <<"5s">> - }, - max_retries => #{ - type => integer, - default => 5 - }, - retry_interval => #{ - type => string, - default => <<"1s">> - }, - request_timout => #{ - type => integer, - default => 5000 - }, - pool_size => #{ - type => integer, - default => 8 - }, - enable_pipelining => #{ - type => boolean, - default => true - }, - ssl => minirest:ref(<<"SSL">>) - } - }, - - JWTDef = #{ - type => object, - required => [mechanism], - properties => #{ - mechanism => #{ - type => string, - enum => [<<"jwt">>], - example => <<"jwt">> - }, - use_jwks => #{ - type => boolean, - default => false, - example => false - }, - algorithm => #{ - type => string, - enum => [<<"hmac-based">>, <<"public-key">>], - default => <<"hmac-based">>, - example => <<"hmac-based">> - }, - secret => #{ - type => string - }, - secret_base64_encoded => #{ - type => boolean, - default => false - }, - certificate => #{ - type => string - }, - endpoint => #{ - type => string, - example => <<"http://localhost:80">> - }, - refresh_interval => #{ - type => integer, - default => 300, - example => 300 - }, - verify_claims => #{ - type => object, - additionalProperties => #{ - type => string - } - }, - ssl => minirest:ref(<<"SSL">>) - } - }, - - SCRAMBuiltInDatabaseDef = #{ - type => object, - required => [mechanism, backend], - properties => #{ - mechanism => #{ - type => string, - enum => [<<"scram">>], - example => <<"scram">> - }, - backend => #{ - type => string, - enum => [<<"built-in-database">>], - example => <<"built-in-database">> - }, - algorithm => #{ - type => string, - enum => [<<"sha256">>, <<"sha512">>], - default => <<"sha256">> - }, - iteration_count => #{ - type => integer, - default => 4096 - } - } - }, - - PasswordHashAlgorithmDef = #{ - type => object, - required => [name], - properties => #{ - name => #{ - type => string, - enum => [<<"plain">>, <<"md5">>, <<"sha">>, <<"sha256">>, <<"sha512">>, <<"bcrypt">>], - default => <<"sha256">> - }, - salt_rounds => #{ - type => integer, - description => <<"Only valid when the name field is set to bcrypt">>, - default => 10 - } - } - }, - - SSLDef = #{ - type => object, - properties => #{ - enable => #{ - type => boolean, - default => false - }, - certfile => #{ - type => string - }, - keyfile => #{ - type => string - }, - cacertfile => #{ - type => string - }, - verify => #{ - type => boolean, - default => true - }, - server_name_indication => #{ - type => object, - properties => #{ - enable => #{ - type => boolean, - default => false - }, - hostname => #{ - type => string - } - } - } - } - }, - - ErrorDef = #{ - type => object, - properties => #{ - code => #{ - type => string, - enum => [<<"NOT_FOUND">>], - example => <<"NOT_FOUND">> - }, - message => #{ - type => string - } - } - }, - - [ #{<<"AuthenticatorConfig">> => AuthenticatorConfigDef} - , #{<<"AuthenticatorInstance">> => AuthenticatorInstanceDef} - , #{<<"PasswordBasedBuiltInDatabase">> => PasswordBasedBuiltInDatabaseDef} - , #{<<"PasswordBasedMySQL">> => PasswordBasedMySQLDef} - , #{<<"PasswordBasedPostgreSQL">> => PasswordBasedPostgreSQLDef} - , #{<<"PasswordBasedMongoDB">> => PasswordBasedMongoDBDef} - , #{<<"PasswordBasedRedis">> => PasswordBasedRedisDef} - , #{<<"PasswordBasedHTTPServer">> => PasswordBasedHTTPServerDef} - , #{<<"JWT">> => JWTDef} - , #{<<"SCRAMBuiltInDatabase">> => SCRAMBuiltInDatabaseDef} - , #{<<"PasswordHashAlgorithm">> => PasswordHashAlgorithmDef} - , #{<<"SSL">> => SSLDef} - , #{<<"Error">> => ErrorDef} +fields(response_user) -> + [ + {user_id, binary()}, + {is_superuser, mk(boolean(), #{default => false, nullable => true})} ]. -authentication(post, #{body := Config}) -> +schema("/authentication") -> + #{ + operationId => authenticators, + get => #{ + tags => [<<"authentication">>, <<"global">>], + description => <<"List authenticators for global authentication">>, + responses => #{ + 200 => emqx_dashboard_swagger:schema_with_example( + hoconsc:array(emqx_authn_schema:authenticator_type()), + authenticator_array_example()) + } + }, + post => #{ + tags => [<<"authentication">>, <<"global">>], + description => <<"Create authenticator for global authentication">>, + requestBody => emqx_dashboard_swagger:schema_with_examples( + emqx_authn_schema:authenticator_type(), + authenticator_examples()), + responses => #{ + 200 => emqx_dashboard_swagger:schema_with_examples( + emqx_authn_schema:authenticator_type(), + authenticator_examples()), + 400 => error_codes([?BAD_REQUEST], <<"Bad Request">>), + 409 => error_codes([?CONFLICT], <<"Conflict">>) + } + } + }; + +schema("/authentication/:id") -> + #{ + operationId => authenticator, + get => #{ + tags => [<<"authentication">>, <<"global">>], + description => <<"Get authenticator from global authentication chain">>, + parameters => [{id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}], + responses => #{ + 200 => emqx_dashboard_swagger:schema_with_examples( + emqx_authn_schema:authenticator_type(), + authenticator_examples()), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + }, + put => #{ + tags => [<<"authentication">>, <<"global">>], + description => <<"Update authenticator from global authentication chain">>, + parameters => [{id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}], + requestBody => emqx_dashboard_swagger:schema_with_examples( + emqx_authn_schema:authenticator_type(), + authenticator_examples() + ), + responses => #{ + 200 => emqx_dashboard_swagger:schema_with_examples( + emqx_authn_schema:authenticator_type(), + authenticator_examples()), + 400 => error_codes([?BAD_REQUEST], <<"Bad Request">>), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>), + 409 => error_codes([?CONFLICT], <<"Conflict">>) + } + }, + delete => #{ + tags => [<<"authentication">>, <<"global">>], + description => <<"Delete authenticator from global authentication chain">>, + parameters => [{id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}], + responses => #{ + 200 => <<"Authenticator deleted">>, + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + } + }; + +schema("/listeners/:listener_id/authentication") -> + #{ + operationId => listener_authenticators, + get => #{ + tags => [<<"authentication">>, <<"listener">>], + description => <<"List authenticators for listener authentication">>, + parameters => [{listener_id, mk(binary(), #{in => path, desc => <<"Listener ID">>})}], + responses => #{ + 200 => emqx_dashboard_swagger:schema_with_example( + hoconsc:array(emqx_authn_schema:authenticator_type()), + authenticator_array_example()) + } + }, + post => #{ + tags => [<<"authentication">>, <<"listener">>], + description => <<"Create authenticator for listener authentication">>, + parameters => [{listener_id, mk(binary(), #{in => path, desc => <<"Listener ID">>})}], + requestBody => emqx_dashboard_swagger:schema_with_examples( + emqx_authn_schema:authenticator_type(), + authenticator_examples() + ), + responses => #{ + 200 => emqx_dashboard_swagger:schema_with_examples( + emqx_authn_schema:authenticator_type(), + authenticator_examples()), + 400 => error_codes([?BAD_REQUEST], <<"Bad Request">>), + 409 => error_codes([?CONFLICT], <<"Conflict">>) + } + } + }; + +schema("/listeners/:listener_id/authentication/:id") -> + #{ + operationId => listener_authenticator, + get => #{ + tags => [<<"authentication">>, <<"listener">>], + description => <<"Get authenticator from listener authentication chain">>, + parameters => [ + {listener_id, mk(binary(), #{in => path, desc => <<"Listener ID">>})}, + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})} + ], + responses => #{ + 200 => emqx_dashboard_swagger:schema_with_examples( + emqx_authn_schema:authenticator_type(), + authenticator_examples()), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + }, + put => #{ + tags => [<<"authentication">>, <<"listener">>], + description => <<"Update authenticator from listener authentication chain">>, + parameters => [ + {listener_id, mk(binary(), #{in => path, desc => <<"Listener ID">>})}, + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})} + ], + requestBody => emqx_dashboard_swagger:schema_with_examples( + emqx_authn_schema:authenticator_type(), + authenticator_examples()), + responses => #{ + 200 => emqx_dashboard_swagger:schema_with_examples( + emqx_authn_schema:authenticator_type(), + authenticator_examples()), + 400 => error_codes([?BAD_REQUEST], <<"Bad Request">>), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>), + 409 => error_codes([?CONFLICT], <<"Conflict">>) + } + }, + delete => #{ + tags => [<<"authentication">>, <<"listener">>], + description => <<"Delete authenticator from listener authentication chain">>, + parameters => [ + {listener_id, mk(binary(), #{in => path, desc => <<"Listener ID">>})}, + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})} + ], + responses => #{ + 204 => <<"Authenticator deleted">>, + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + } + }; + + +schema("/authentication/:id/move") -> + #{ + operationId => authenticator_move, + post => #{ + tags => [<<"authentication">>, <<"global">>], + description => <<"Move authenticator in global authentication chain">>, + parameters => [{id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}], + requestBody => ref(request_move), + responses => #{ + 204 => <<"Authenticator moved">>, + 400 => error_codes([?BAD_REQUEST], <<"Bad Request">>), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + } + }; + +schema("/listeners/:listener_id/authentication/:id/move") -> + #{ + operationId => listener_authenticator_move, + post => #{ + tags => [<<"authentication">>, <<"listener">>], + description => <<"Move authenticator in listener authentication chain">>, + parameters => [ + {listener_id, mk(binary(), #{in => path, desc => <<"Listener ID">>})}, + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})} + ], + requestBody => ref(request_move), + responses => #{ + 204 => <<"Authenticator moved">>, + 400 => error_codes([?BAD_REQUEST], <<"Bad Request">>), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + } + }; + +schema("/authentication/:id/import_users") -> + #{ + operationId => authenticator_import_users, + post => #{ + tags => [<<"authentication">>, <<"global">>], + description => <<"Import users into authenticator in global authentication chain">>, + parameters => [{id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}], + requestBody => ref(request_import_users), + responses => #{ + 204 => <<"Users imported">>, + 400 => error_codes([?BAD_REQUEST], <<"Bad Request">>), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + } + }; + +schema("/listeners/:listener_id/authentication/:id/import_users") -> + #{ + operationId => listener_authenticator_import_users, + post => #{ + tags => [<<"authentication">>, <<"listener">>], + description => <<"Import users into authenticator in listener authentication chain">>, + parameters => [ + {listener_id, mk(binary(), #{in => path, desc => <<"Listener ID">>})}, + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})} + ], + requestBody => ref(request_import_users), + responses => #{ + 204 => <<"Users imported">>, + 400 => error_codes([?BAD_REQUEST], <<"Bad Request">>), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + } + }; + +schema("/authentication/:id/users") -> + #{ + operationId => authenticator_users, + post => #{ + tags => [<<"authentication">>, <<"global">>], + description => <<"Create users for authenticator in global authentication chain">>, + parameters => [{id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}], + requestBody => ref(request_user_create), + responses => #{ + 201 => ref(response_user), + 400 => error_codes([?BAD_REQUEST], <<"Bad Request">>), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + }, + get => #{ + tags => [<<"authentication">>, <<"global">>], + description => <<"List users in authenticator in global authentication chain">>, + parameters => [ + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}, + {page, mk(integer(), #{in => query, desc => <<"Page Index">>, nullable => true})}, + {limit, mk(integer(), #{in => query, desc => <<"Page Limit">>, nullable => true})} + ], + responses => #{ + 200 => mk(hoconsc:array(ref(response_user)), #{}), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + + } + }; + +schema("/listeners/:listener_id/authentication/:id/users") -> + #{ + operationId => listener_authenticator_users, + post => #{ + tags => [<<"authentication">>, <<"listener">>], + description => <<"Create users for authenticator in global authentication chain">>, + parameters => [ + {listener_id, mk(binary(), #{in => path, desc => <<"Listener ID">>})}, + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})} + ], + requestBody => ref(request_user_create), + responses => #{ + 201 => ref(response_user), + 400 => error_codes([?BAD_REQUEST], <<"Bad Request">>), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + }, + get => #{ + tags => [<<"authentication">>, <<"listener">>], + description => <<"List users in authenticator in listener authentication chain">>, + parameters => [ + {listener_id, mk(binary(), #{in => path, desc => <<"Listener ID">>})}, + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}, + {page, mk(integer(), #{in => query, desc => <<"Page Index">>, nullable => true})}, + {limit, mk(integer(), #{in => query, desc => <<"Page Limit">>, nullable => true})} + ], + responses => #{ + 200 => mk(hoconsc:array(ref(response_user)), #{}), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + + } + }; + +schema("/authentication/:id/users/:user_id") -> + #{ + operationId => authenticator_user, + get => #{ + tags => [<<"authentication">>, <<"global">>], + description => <<"Get user from authenticator in global authentication chain">>, + parameters => [ + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}, + {user_id, mk(binary(), #{in => path, desc => <<"User ID">>})} + ], + responses => #{ + 200 => ref(response_user), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + }, + put => #{ + tags => [<<"authentication">>, <<"global">>], + description => <<"Update user in authenticator in global authentication chain">>, + parameters => [ + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}, + {user_id, mk(binary(), #{in => path, desc => <<"User ID">>})} + ], + requestBody => ref(request_user_update), + responses => #{ + 200 => mk(hoconsc:array(ref(response_user)), #{}), + 400 => error_codes([?BAD_REQUEST], <<"Bad Request">>), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + }, + delete => #{ + tags => [<<"authentication">>, <<"global">>], + description => <<"Update user in authenticator in global authentication chain">>, + parameters => [ + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}, + {user_id, mk(binary(), #{in => path, desc => <<"User ID">>})} + ], + responses => #{ + 204 => <<"User deleted">>, + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + } + }; + +schema("/listeners/:listener_id/authentication/:id/users/:user_id") -> + #{ + operationId => listener_authenticator_user, + get => #{ + tags => [<<"authentication">>, <<"listener">>], + description => <<"Get user from authenticator in listener authentication chain">>, + parameters => [ + {listener_id, mk(binary(), #{in => path, desc => <<"Listener ID">>})}, + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}, + {user_id, mk(binary(), #{in => path, desc => <<"User ID">>})} + ], + responses => #{ + 200 => ref(response_user), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + }, + put => #{ + tags => [<<"authentication">>, <<"listener">>], + description => <<"Update user in authenticator in listener authentication chain">>, + parameters => [ + {listener_id, mk(binary(), #{in => path, desc => <<"Listener ID">>})}, + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}, + {user_id, mk(binary(), #{in => path, desc => <<"User ID">>})} + ], + requestBody => ref(request_user_update), + responses => #{ + 200 => mk(hoconsc:array(ref(response_user)), #{}), + 400 => error_codes([?BAD_REQUEST], <<"Bad Request">>), + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + + }, + delete => #{ + tags => [<<"authentication">>, <<"listener">>], + description => <<"Update user in authenticator in listener authentication chain">>, + parameters => [ + {listener_id, mk(binary(), #{in => path, desc => <<"Listener ID">>})}, + {id, mk(binary(), #{in => path, desc => <<"Authenticator ID">>})}, + {user_id, mk(binary(), #{in => path, desc => <<"User ID">>})} + ], + responses => #{ + 204 => <<"User deleted">>, + 404 => error_codes([?NOT_FOUND], <<"Not Found">>) + } + } + }. + +authenticators(post, #{body := Config}) -> create_authenticator([authentication], ?GLOBAL, Config); -authentication(get, _Params) -> +authenticators(get, _Params) -> list_authenticators([authentication]). -authentication2(get, #{bindings := #{id := AuthenticatorID}}) -> +authenticator(get, #{bindings := #{id := AuthenticatorID}}) -> list_authenticator([authentication], AuthenticatorID); -authentication2(put, #{bindings := #{id := AuthenticatorID}, body := Config}) -> +authenticator(put, #{bindings := #{id := AuthenticatorID}, body := Config}) -> update_authenticator([authentication], ?GLOBAL, AuthenticatorID, Config); -authentication2(delete, #{bindings := #{id := AuthenticatorID}}) -> +authenticator(delete, #{bindings := #{id := AuthenticatorID}}) -> delete_authenticator([authentication], ?GLOBAL, AuthenticatorID). -authentication3(post, #{bindings := #{listener_id := ListenerID}, body := Config}) -> - case find_listener(ListenerID) of - {ok, {Type, Name}} -> - create_authenticator([listeners, Type, Name, authentication], ListenerID, Config); - {error, Reason} -> - serialize_error(Reason) - end; -authentication3(get, #{bindings := #{listener_id := ListenerID}}) -> - case find_listener(ListenerID) of - {ok, {Type, Name}} -> - list_authenticators([listeners, Type, Name, authentication]); - {error, Reason} -> - serialize_error(Reason) - end. +listener_authenticators(post, #{bindings := #{listener_id := ListenerID}, body := Config}) -> + with_listener(ListenerID, + fun(Type, Name) -> + create_authenticator([listeners, Type, Name, authentication], + ListenerID, + Config) + end); -authentication4(get, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID}}) -> - case find_listener(ListenerID) of - {ok, {Type, Name}} -> - list_authenticator([listeners, Type, Name, authentication], AuthenticatorID); - {error, Reason} -> - serialize_error(Reason) - end; -authentication4(put, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID}, body := Config}) -> - case find_listener(ListenerID) of - {ok, {Type, Name}} -> - update_authenticator([listeners, Type, Name, authentication], ListenerID, AuthenticatorID, Config); - {error, Reason} -> - serialize_error(Reason) - end; -authentication4(delete, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID}}) -> - case find_listener(ListenerID) of - {ok, {Type, Name}} -> - delete_authenticator([listeners, Type, Name, authentication], ListenerID, AuthenticatorID); - {error, Reason} -> - serialize_error(Reason) - end. +listener_authenticators(get, #{bindings := #{listener_id := ListenerID}}) -> + with_listener(ListenerID, + fun(Type, Name) -> + list_authenticators([listeners, Type, Name, authentication]) + end). -move(post, #{bindings := #{id := AuthenticatorID}, body := #{<<"position">> := Position}}) -> +listener_authenticator(get, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID}}) -> + with_listener(ListenerID, + fun(Type, Name) -> + list_authenticator([listeners, Type, Name, authentication], + AuthenticatorID) + end); +listener_authenticator(put, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID}, body := Config}) -> + with_listener(ListenerID, + fun(Type, Name) -> + update_authenticator([listeners, Type, Name, authentication], + ListenerID, + AuthenticatorID, + Config) + end); +listener_authenticator(delete, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID}}) -> + with_listener(ListenerID, + fun(Type, Name) -> + delete_authenticator([listeners, Type, Name, authentication], + ListenerID, + AuthenticatorID) + end). + +authenticator_move(post, #{bindings := #{id := AuthenticatorID}, body := #{<<"position">> := Position}}) -> move_authenitcator([authentication], ?GLOBAL, AuthenticatorID, Position); -move(post, #{bindings := #{id := _}, body := _}) -> +authenticator_move(post, #{bindings := #{id := _}, body := _}) -> serialize_error({missing_parameter, position}). -move2(post, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID}, body := #{<<"position">> := Position}}) -> - case find_listener(ListenerID) of - {ok, {Type, Name}} -> - move_authenitcator([listeners, Type, Name, authentication], ListenerID, AuthenticatorID, Position); - {error, Reason} -> - serialize_error(Reason) - end; -move2(post, #{bindings := #{listener_id := _, id := _}, body := _}) -> +listener_authenticator_move(post, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID}, body := #{<<"position">> := Position}}) -> + with_listener(ListenerID, + fun(Type, Name) -> + move_authenitcator([listeners, Type, Name, authentication], + ListenerID, + AuthenticatorID, + Position) + end); +listener_authenticator_move(post, #{bindings := #{listener_id := _, id := _}, body := _}) -> serialize_error({missing_parameter, position}). -import_users(post, #{bindings := #{id := AuthenticatorID}, body := #{<<"filename">> := Filename}}) -> +authenticator_import_users(post, #{bindings := #{id := AuthenticatorID}, body := #{<<"filename">> := Filename}}) -> case ?AUTHN:import_users(?GLOBAL, AuthenticatorID, Filename) of ok -> {204}; {error, Reason} -> serialize_error(Reason) end; -import_users(post, #{bindings := #{id := _}, body := _}) -> +authenticator_import_users(post, #{bindings := #{id := _}, body := _}) -> serialize_error({missing_parameter, filename}). -import_users2(post, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID}, body := #{<<"filename">> := Filename}}) -> - case ?AUTHN:import_users(ListenerID, AuthenticatorID, Filename) of +listener_authenticator_import_users(post, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID}, body := #{<<"filename">> := Filename}}) -> + ChainName = to_atom(ListenerID), + case ?AUTHN:import_users(ChainName, AuthenticatorID, Filename) of ok -> {204}; {error, Reason} -> serialize_error(Reason) end; -import_users2(post, #{bindings := #{listener_id := _, id := _}, body := _}) -> +listener_authenticator_import_users(post, #{bindings := #{listener_id := _, id := _}, body := _}) -> serialize_error({missing_parameter, filename}). -users(post, #{bindings := #{id := AuthenticatorID}, body := UserInfo}) -> +authenticator_users(post, #{bindings := #{id := AuthenticatorID}, body := UserInfo}) -> add_user(?GLOBAL, AuthenticatorID, UserInfo); -users(get, #{bindings := #{id := AuthenticatorID}, query_string := PageParams}) -> +authenticator_users(get, #{bindings := #{id := AuthenticatorID}, query_string := PageParams}) -> list_users(?GLOBAL, AuthenticatorID, PageParams). -users2(put, #{bindings := #{id := AuthenticatorID, +authenticator_user(put, #{bindings := #{id := AuthenticatorID, user_id := UserID}, body := UserInfo}) -> update_user(?GLOBAL, AuthenticatorID, UserID, UserInfo); -users2(get, #{bindings := #{id := AuthenticatorID, user_id := UserID}}) -> +authenticator_user(get, #{bindings := #{id := AuthenticatorID, user_id := UserID}}) -> find_user(?GLOBAL, AuthenticatorID, UserID); -users2(delete, #{bindings := #{id := AuthenticatorID, user_id := UserID}}) -> +authenticator_user(delete, #{bindings := #{id := AuthenticatorID, user_id := UserID}}) -> delete_user(?GLOBAL, AuthenticatorID, UserID). -users3(post, #{bindings := #{listener_id := ListenerID, +listener_authenticator_users(post, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID}, body := UserInfo}) -> add_user(ListenerID, AuthenticatorID, UserInfo); -users3(get, #{bindings := #{listener_id := ListenerID, - id := AuthenticatorID}, - query_string := PageParams}) -> +listener_authenticator_users(get, #{bindings := #{listener_id := ListenerID, + id := AuthenticatorID}, query_string := PageParams}) -> list_users(ListenerID, AuthenticatorID, PageParams). -users4(put, #{bindings := #{listener_id := ListenerID, +listener_authenticator_user(put, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID, user_id := UserID}, body := UserInfo}) -> update_user(ListenerID, AuthenticatorID, UserID, UserInfo); -users4(get, #{bindings := #{listener_id := ListenerID, +listener_authenticator_user(get, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID, user_id := UserID}}) -> find_user(ListenerID, AuthenticatorID, UserID); -users4(delete, #{bindings := #{listener_id := ListenerID, +listener_authenticator_user(delete, #{bindings := #{listener_id := ListenerID, id := AuthenticatorID, user_id := UserID}}) -> delete_user(ListenerID, AuthenticatorID, UserID). @@ -1868,13 +602,25 @@ users4(delete, #{bindings := #{listener_id := ListenerID, %% Internal functions %%------------------------------------------------------------------------------ +with_listener(ListenerID, Fun) -> + case find_listener(ListenerID) of + {ok, {Type, Name}} -> + Fun(Type, Name); + {error, Reason} -> + serialize_error(Reason) + end. + find_listener(ListenerID) -> - {Type, Name} = emqx_listeners:parse_listener_id(ListenerID), - case emqx_config:find([listeners, Type, Name]) of - {not_found, _, _} -> + case emqx_listeners:parse_listener_id(ListenerID) of + {error, _} -> {error, {not_found, {listener, ListenerID}}}; - {ok, _} -> - {ok, {Type, Name}} + {Type, Name} -> + case emqx_config:find([listeners, Type, Name]) of + {not_found, _, _} -> + {error, {not_found, {listener, ListenerID}}}; + {ok, _} -> + {ok, {Type, Name}} + end end. create_authenticator(ConfKeyPath, ChainName, Config) -> @@ -1944,7 +690,7 @@ add_user(ChainName0, AuthenticatorID, #{<<"user_id">> := UserID, <<"password">> {ok, User} -> {201, User}; {error, Reason} -> - serialize_error(Reason) + serialize_error({user_error, Reason}) end; add_user(_, _, #{<<"user_id">> := _}) -> serialize_error({missing_parameter, password}); @@ -1961,7 +707,7 @@ update_user(ChainName0, AuthenticatorID, UserID, UserInfo) -> {ok, User} -> {200, User}; {error, Reason} -> - serialize_error(Reason) + serialize_error({user_error, Reason}) end end. @@ -1971,7 +717,7 @@ find_user(ChainName0, AuthenticatorID, UserID) -> {ok, User} -> {200, User}; {error, Reason} -> - serialize_error(Reason) + serialize_error({user_error, Reason}) end. delete_user(ChainName0, AuthenticatorID, UserID) -> @@ -1980,7 +726,7 @@ delete_user(ChainName0, AuthenticatorID, UserID) -> ok -> {204}; {error, Reason} -> - serialize_error(Reason) + serialize_error({user_error, Reason}) end. list_users(ChainName0, AuthenticatorID, PageParams) -> @@ -2024,6 +770,15 @@ convert_certs(#{<<"ssl">> := SSLOpts} = Config) -> convert_certs(Config) -> Config. +serialize_error({user_error, not_found}) -> + {404, #{code => <<"NOT_FOUND">>, + message => binfmt("User not found", [])}}; +serialize_error({user_error, already_exist}) -> + {409, #{code => <<"BAD_REQUEST">>, + message => binfmt("User already exists", [])}}; +serialize_error({user_error, Reason}) -> + {400, #{code => <<"BAD_REQUEST">>, + message => binfmt("User error: ~p", [Reason])}}; serialize_error({not_found, {authenticator, ID}}) -> {404, #{code => <<"NOT_FOUND">>, message => binfmt("Authenticator '~ts' does not exist", [ID]) }}; @@ -2035,7 +790,7 @@ serialize_error({not_found, {chain, ?GLOBAL}}) -> message => <<"Authenticator not found in the 'global' scope">>}}; serialize_error({not_found, {chain, Name}}) -> {400, #{code => <<"BAD_REQUEST">>, - message => binfmt("No authentication has been create for listener '~ts'", [Name])}}; + message => binfmt("No authentication has been created for listener ~p", [Name])}}; serialize_error({already_exists, {authenticator, ID}}) -> {409, #{code => <<"ALREADY_EXISTS">>, message => binfmt("Authenticator '~ts' already exist", [ID])}}; @@ -2079,9 +834,92 @@ parse_position(_) -> ensure_list(M) when is_map(M) -> [M]; ensure_list(L) when is_list(L) -> L. +% TODO: fix atom leak! to_atom(B) when is_binary(B) -> binary_to_atom(B); to_atom(A) when is_atom(A) -> A. binfmt(Fmt, Args) -> iolist_to_binary(io_lib:format(Fmt, Args)). + +authenticator_array_example() -> + [Config || #{value := Config} <- maps:values(authenticator_examples())]. + +authenticator_examples() -> + #{ + 'password-based:built-in-database' => #{ + summary => <<"Built-in password-based authentication">>, + value => #{ + mechanism => <<"password-based">>, + backend => <<"built-in-database">>, + user_id_type => <<"username">>, + password_hash_algorithm => #{ + name => <<"sha256">> + } + } + }, + 'password-based:http' => #{ + summary => <<"Password-based authentication througth external HTTP API">>, + value => #{ + mechanism => <<"password-based">>, + backend => <<"http">>, + method => <<"post">>, + url => <<"http://127.0.0.2:8080">>, + headers => #{ + <<"content-type">> => <<"application/json">> + }, + body => #{ + <<"username">> => <<"${mqtt-username}">>, + <<"password">> => <<"${mqtt-password}">> + }, + pool_size => 8, + connect_timeout => 5000, + request_timeout => 5000, + enable_pipelining => true, + ssl => #{enable => false} + } + }, + 'jwt' => #{ + summary => <<"JWT authentication">>, + value => #{ + mechanism => <<"jwt">>, + use_jwks => false, + algorithm => <<"hmac-based">>, + secret => <<"mysecret">>, + secret_base64_encoded => false, + verify_claims => #{ + <<"username">> => <<"${mqtt-username}">> + } + } + }, + 'password-based:mongodb' => #{ + summary => <<"Password-based authentication with MongoDB backend">>, + value => #{ + mechanism => <<"password-based">>, + backend => <<"mongodb">>, + server => <<"127.0.0.1:27017">>, + database => example, + collection => users, + selector => #{ + username => <<"${mqtt-username}">> + }, + password_hash_field => <<"password_hash">>, + salt_field => <<"salt">>, + is_superuser_field => <<"is_superuser">>, + password_hash_algorithm => <<"sha256">>, + salt_position => <<"prefix">> + } + }, + 'password-based:redis' => #{ + summary => <<"Password-based authentication with Redis backend">>, + value => #{ + mechanism => <<"password-based">>, + backend => <<"redis">>, + server => <<"127.0.0.1:6379">>, + database => 0, + query => <<"HMGET ${mqtt-username} password_hash salt">>, + password_hash_algorithm => <<"sha256">>, + salt_position => <<"prefix">> + } + } + }. diff --git a/apps/emqx_authn/src/emqx_authn_schema.erl b/apps/emqx_authn/src/emqx_authn_schema.erl index b36e88ebf..22f62f519 100644 --- a/apps/emqx_authn/src/emqx_authn_schema.erl +++ b/apps/emqx_authn/src/emqx_authn_schema.erl @@ -21,12 +21,11 @@ -export([ common_fields/0 , roots/0 , fields/1 + , authenticator_type/0 ]). %% only for doc generation -roots() -> [{authenticator_config, - #{type => hoconsc:union(config_refs([Module || {_AuthnType, Module} <- emqx_authn:providers()])) - }}]. +roots() -> [{authenticator_config, hoconsc:mk(authenticator_type())}]. fields(_) -> []. @@ -38,5 +37,8 @@ enable(type) -> boolean(); enable(default) -> true; enable(_) -> undefined. +authenticator_type() -> + hoconsc:union(config_refs([Module || {_AuthnType, Module} <- emqx_authn:providers()])). + config_refs(Modules) -> lists:append([Module:refs() || Module <- Modules]). diff --git a/apps/emqx_authn/test/emqx_authn_api_SUITE.erl b/apps/emqx_authn/test/emqx_authn_api_SUITE.erl index 32ba06c33..b2f9bb106 100644 --- a/apps/emqx_authn/test/emqx_authn_api_SUITE.erl +++ b/apps/emqx_authn/test/emqx_authn_api_SUITE.erl @@ -18,7 +18,7 @@ -compile(nowarn_export_all). -compile(export_all). --include("emqx_authz.hrl"). +-include("emqx_authn.hrl"). -include_lib("eunit/include/eunit.hrl"). -include_lib("common_test/include/ct.hrl"). @@ -27,12 +27,27 @@ -define(API_VERSION, "v5"). -define(BASE_PATH, "api"). +-define(TCP_DEFAULT, 'tcp:default'). + +-define( + assertAuthenticatorsMatch(Guard, Path), + (fun() -> + {ok, 200, Response} = request(get, uri(Path)), + ?assertMatch(Guard, jiffy:decode(Response, [return_maps])) + end)()). + all() -> emqx_common_test_helpers:all(?MODULE). groups() -> []. +init_per_testcase(_, Config) -> + delete_authenticators([authentication], ?GLOBAL), + delete_authenticators([listeners, tcp, default, authentication], ?TCP_DEFAULT), + {atomic, ok} = mria:clear_table(emqx_authn_mnesia), + Config. + init_per_suite(Config) -> ok = emqx_common_test_helpers:start_apps([emqx_authn, emqx_dashboard], fun set_special_configs/1), Config. @@ -55,10 +70,379 @@ set_special_configs(emqx_dashboard) -> set_special_configs(_App) -> ok. -t_create_http_authn(_) -> - {ok, 200, _} = request(post, uri(["authentication"]), - emqx_authn_test_lib:http_example()), - {ok, 200, _} = request(get, uri(["authentication"])). +%%------------------------------------------------------------------------------ +%% Tests +%%------------------------------------------------------------------------------ + +t_invalid_listener(_) -> + {ok, 404, _} = request(get, uri(["listeners", "invalid", "authentication"])), + {ok, 404, _} = request(get, uri(["listeners", "in:valid", "authentication"])). + +t_authenticators(_) -> + test_authenticators([]). + +t_authenticator(_) -> + test_authenticator([]). + +t_authenticator_users(_) -> + test_authenticator_users([]). + +t_authenticator_user(_) -> + test_authenticator_user([]). + +t_authenticator_move(_) -> + test_authenticator_move([]). + +t_authenticator_import_users(_) -> + test_authenticator_import_users([]). + +t_listener_authenticators(_) -> + test_authenticators(["listeners", ?TCP_DEFAULT]). + +t_listener_authenticator(_) -> + test_authenticator(["listeners", ?TCP_DEFAULT]). + +t_listener_authenticator_users(_) -> + test_authenticator_users(["listeners", ?TCP_DEFAULT]). + +t_listener_authenticator_user(_) -> + test_authenticator_user(["listeners", ?TCP_DEFAULT]). + +t_listener_authenticator_move(_) -> + test_authenticator_move(["listeners", ?TCP_DEFAULT]). + +t_listener_authenticator_import_users(_) -> + test_authenticator_import_users(["listeners", ?TCP_DEFAULT]). + +test_authenticators(PathPrefix) -> + + ValidConfig = emqx_authn_test_lib:http_example(), + {ok, 200, _} = request( + post, + uri(PathPrefix ++ ["authentication"]), + ValidConfig), + + InvalidConfig = ValidConfig#{method => <<"delete">>}, + {ok, 400, _} = request( + post, + uri(PathPrefix ++ ["authentication"]), + InvalidConfig), + + ?assertAuthenticatorsMatch( + [#{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"http">>}], + PathPrefix ++ ["authentication"]). + +test_authenticator(PathPrefix) -> + ValidConfig0 = emqx_authn_test_lib:http_example(), + + {ok, 200, _} = request( + post, + uri(PathPrefix ++ ["authentication"]), + ValidConfig0), + + {ok, 200, _} = request( + get, + uri(PathPrefix ++ ["authentication", "password-based:http"])), + + {ok, 404, _} = request( + get, + uri(PathPrefix ++ ["authentication", "password-based:redis"])), + + + {ok, 404, _} = request( + put, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database"]), + emqx_authn_test_lib:built_in_database_example()), + + InvalidConfig0 = ValidConfig0#{method => <<"delete">>}, + {ok, 400, _} = request( + put, + uri(PathPrefix ++ ["authentication", "password-based:http"]), + InvalidConfig0), + + ValidConfig1 = ValidConfig0#{pool_size => 9}, + {ok, 200, _} = request( + put, + uri(PathPrefix ++ ["authentication", "password-based:http"]), + ValidConfig1), + + {ok, 404, _} = request( + delete, + uri(PathPrefix ++ ["authentication", "password-based:redis"])), + + {ok, 204, _} = request( + delete, + uri(PathPrefix ++ ["authentication", "password-based:http"])), + + ?assertAuthenticatorsMatch([], PathPrefix ++ ["authentication"]). + +test_authenticator_users(PathPrefix) -> + {ok, 200, _} = request( + post, + uri(PathPrefix ++ ["authentication"]), + emqx_authn_test_lib:built_in_database_example()), + + InvalidUsers = [ + #{clientid => <<"u1">>, password => <<"p1">>}, + #{user_id => <<"u2">>}, + #{user_id => <<"u3">>, password => <<"p3">>, foobar => <<"foobar">>}], + + lists:foreach( + fun(User) -> + {ok, 400, _} = request( + post, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users"]), + User) + end, + InvalidUsers), + + + ValidUsers = [ + #{user_id => <<"u1">>, password => <<"p1">>}, + #{user_id => <<"u2">>, password => <<"p2">>, is_superuser => true}, + #{user_id => <<"u3">>, password => <<"p3">>}], + + lists:foreach( + fun(User) -> + {ok, 201, _} = request( + post, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users"]), + User) + end, + ValidUsers), + + {ok, 200, Page1Data} = + request( + get, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users"]) ++ "?page=1&limit=2"), + + Page1Users = response_data(Page1Data), + + {ok, 200, Page2Data} = + request( + get, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users"]) ++ "?page=2&limit=2"), + + Page2Users = response_data(Page2Data), + + ?assertEqual(2, length(Page1Users)), + ?assertEqual(1, length(Page2Users)), + + ?assertEqual( + [<<"u1">>, <<"u2">>, <<"u3">>], + lists:usort([ UserId || #{<<"user_id">> := UserId} <- Page1Users ++ Page2Users])). + +test_authenticator_user(PathPrefix) -> + {ok, 200, _} = request( + post, + uri(PathPrefix ++ ["authentication"]), + emqx_authn_test_lib:built_in_database_example()), + + User = #{user_id => <<"u1">>, password => <<"p1">>}, + {ok, 201, _} = request( + post, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users"]), + User), + + {ok, 404, _} = request( + get, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users", "u123"])), + + {ok, 409, _} = request( + post, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users"]), + User), + + {ok, 200, UserData} = request( + get, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users", "u1"])), + + FetchedUser = jiffy:decode(UserData, [return_maps]), + ?assertMatch(#{<<"user_id">> := <<"u1">>}, FetchedUser), + ?assertNotMatch(#{<<"password">> := _}, FetchedUser), + + ValidUserUpdates = [ + #{password => <<"p1">>}, + #{password => <<"p1">>, is_superuser => true}], + + lists:foreach( + fun(UserUpdate) -> + {ok, 200, _} = request( + put, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users", "u1"]), + UserUpdate) + end, + ValidUserUpdates), + + InvalidUserUpdates = [ + #{user_id => <<"u1">>, password => <<"p1">>}, + #{is_superuser => true}], + + lists:foreach( + fun(UserUpdate) -> + {ok, 400, _} = request( + put, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users", "u1"]), + UserUpdate) + end, + InvalidUserUpdates), + + {ok, 404, _} = request( + delete, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users", "u123"])), + + {ok, 204, _} = request( + delete, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "users", "u1"])). + +test_authenticator_move(PathPrefix) -> + AuthenticatorConfs = [ + emqx_authn_test_lib:http_example(), + emqx_authn_test_lib:jwt_example(), + emqx_authn_test_lib:built_in_database_example() + ], + + lists:foreach( + fun(Conf) -> + {ok, 200, _} = request( + post, + uri(PathPrefix ++ ["authentication"]), + Conf) + end, + AuthenticatorConfs), + + ?assertAuthenticatorsMatch( + [ + #{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"http">>}, + #{<<"mechanism">> := <<"jwt">>}, + #{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"built-in-database">>} + ], + PathPrefix ++ ["authentication"]), + + % Invalid moves + + {ok, 400, _} = request( + post, + uri(PathPrefix ++ ["authentication", "jwt", "move"]), + #{position => <<"up">>}), + + {ok, 400, _} = request( + post, + uri(PathPrefix ++ ["authentication", "jwt", "move"]), + #{}), + + {ok, 404, _} = request( + post, + uri(PathPrefix ++ ["authentication", "jwt", "move"]), + #{position => <<"before:invalid">>}), + + {ok, 404, _} = request( + post, + uri(PathPrefix ++ ["authentication", "jwt", "move"]), + #{position => <<"before:password-based:redis">>}), + + {ok, 404, _} = request( + post, + uri(PathPrefix ++ ["authentication", "jwt", "move"]), + #{position => <<"before:password-based:redis">>}), + + % Valid moves + + {ok, 204, _} = request( + post, + uri(PathPrefix ++ ["authentication", "jwt", "move"]), + #{position => <<"top">>}), + + ?assertAuthenticatorsMatch( + [ + #{<<"mechanism">> := <<"jwt">>}, + #{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"http">>}, + #{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"built-in-database">>} + ], + PathPrefix ++ ["authentication"]), + + {ok, 204, _} = request( + post, + uri(PathPrefix ++ ["authentication", "jwt", "move"]), + #{position => <<"bottom">>}), + + ?assertAuthenticatorsMatch( + [ + #{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"http">>}, + #{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"built-in-database">>}, + #{<<"mechanism">> := <<"jwt">>} + ], + PathPrefix ++ ["authentication"]), + + {ok, 204, _} = request( + post, + uri(PathPrefix ++ ["authentication", "jwt", "move"]), + #{position => <<"before:password-based:built-in-database">>}), + + ?assertAuthenticatorsMatch( + [ + #{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"http">>}, + #{<<"mechanism">> := <<"jwt">>}, + #{<<"mechanism">> := <<"password-based">>, <<"backend">> := <<"built-in-database">>} + ], + PathPrefix ++ ["authentication"]). + +test_authenticator_import_users(PathPrefix) -> + {ok, 200, _} = request( + post, + uri(PathPrefix ++ ["authentication"]), + emqx_authn_test_lib:built_in_database_example()), + + {ok, 400, _} = request( + post, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "import_users"]), + #{}), + + {ok, 400, _} = request( + post, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "import_users"]), + #{filename => <<"/etc/passwd">>}), + + {ok, 400, _} = request( + post, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "import_users"]), + #{filename => <<"/not_exists.csv">>}), + + Dir = code:lib_dir(emqx_authn, test), + JSONFileName = filename:join([Dir, <<"data/user-credentials.json">>]), + CSVFileName = filename:join([Dir, <<"data/user-credentials.csv">>]), + + {ok, 204, _} = request( + post, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "import_users"]), + #{filename => JSONFileName}), + + {ok, 204, _} = request( + post, + uri(PathPrefix ++ ["authentication", "password-based:built-in-database", "import_users"]), + #{filename => CSVFileName}). + +%%------------------------------------------------------------------------------ +%% Helpers +%%------------------------------------------------------------------------------ + +delete_authenticators(Path, Chain) -> + case emqx_authentication:list_authenticators(Chain) of + {error, _} -> ok; + {ok, Authenticators} -> + lists:foreach( + fun(#{id := ID}) -> + emqx:update_config( + Path, + {delete_authenticator, Chain, ID}, + #{rawconf_with_defaults => true}) + end, + Authenticators) + end. + +response_data(Response) -> + #{<<"data">> := Data} = jiffy:decode(Response, [return_maps]), + Data. request(Method, Url) -> request(Method, Url, []). @@ -83,10 +467,7 @@ request(Method, Url, Body) -> uri() -> uri([]). uri(Parts) when is_list(Parts) -> - NParts = [E || E <- Parts], - ?HOST ++ filename:join([?BASE_PATH, ?API_VERSION | NParts]). - -get_sources(Result) -> jsx:decode(Result). + ?HOST ++ filename:join([?BASE_PATH, ?API_VERSION | Parts]). auth_header() -> Username = <<"admin">>, @@ -94,6 +475,5 @@ auth_header() -> {ok, Token} = emqx_dashboard_admin:sign_token(Username, Password), {"Authorization", "Bearer " ++ binary_to_list(Token)}. -to_json(Hocon) -> - {ok, Map} =hocon:binary(Hocon), +to_json(Map) -> jiffy:encode(Map). diff --git a/apps/emqx_authn/test/emqx_authn_test_lib.erl b/apps/emqx_authn/test/emqx_authn_test_lib.erl index e30854318..7ab07c3d0 100644 --- a/apps/emqx_authn/test/emqx_authn_test_lib.erl +++ b/apps/emqx_authn/test/emqx_authn_test_lib.erl @@ -19,20 +19,15 @@ -compile(nowarn_export_all). -compile(export_all). +authenticator_example(Id) -> + #{Id := #{value := Example}} = emqx_authn_api:authenticator_examples(), + Example. + http_example() -> -""" -{ - mechanism = \"password-based\" - backend = http - method = post - url = \"http://127.0.0.2:8080\" - headers = {\"content-type\" = \"application/json\"} - body = {username = \"${username}\", - password = \"${password}\"} - pool_size = 8 - connect_timeout = 5000 - request_timeout = 5000 - enable_pipelining = true - ssl = {enable = false} -} -""". + authenticator_example('password-based:http'). + +built_in_database_example() -> + authenticator_example('password-based:built-in-database'). + +jwt_example() -> + authenticator_example(jwt). diff --git a/apps/emqx_dashboard/src/emqx_dashboard_api.erl b/apps/emqx_dashboard/src/emqx_dashboard_api.erl index 5cc4d2a16..2396af9eb 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_api.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_api.erl @@ -41,7 +41,7 @@ namespace() -> "dashboard". api_spec() -> - emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}). + emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true, translate_body => true}). paths() -> ["/login", "/logout", "/users", "/users/:username", "/users/:username/change_pwd"]. diff --git a/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl b/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl index 64514a7aa..6f191bd5c 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard_swagger.erl @@ -5,16 +5,16 @@ %% API -export([spec/1, spec/2]). --export([translate_req/2]). -export([namespace/0, fields/1]). +-export([schema_with_example/2, schema_with_examples/2]). -export([error_codes/1, error_codes/2]). --define(MAX_ROW_LIMIT, 100). - -%% API -ifdef(TEST). --compile(export_all). --compile(nowarn_export_all). +-export([ + parse_spec_ref/2, + components/1, + filter_check_request/2, + filter_check_request_and_translate_body/2]). -endif. -define(METHODS, [get, post, put, head, delete, patch, options, trace]). @@ -22,33 +22,39 @@ -define(DEFAULT_FIELDS, [example, allowReserved, style, explode, maxLength, allowEmptyValue, deprecated, minimum, maximum]). --define(DEFAULT_FILTER, #{filter => fun ?MODULE:translate_req/2}). - -define(INIT_SCHEMA, #{fields => #{}, translations => #{}, validations => [], namespace => undefined}). -define(TO_REF(_N_, _F_), iolist_to_binary([to_bin(_N_), ".", to_bin(_F_)])). -define(TO_COMPONENTS_SCHEMA(_M_, _F_), iolist_to_binary([<<"#/components/schemas/">>, ?TO_REF(namespace(_M_), _F_)])). -define(TO_COMPONENTS_PARAM(_M_, _F_), iolist_to_binary([<<"#/components/parameters/">>, ?TO_REF(namespace(_M_), _F_)])). +-define(MAX_ROW_LIMIT, 100). + +-type(request() :: #{bindings => map(), query_string => map(), body => map()}). +-type(request_meta() :: #{module => module(), path => string(), method => atom()}). + +-type(filter_result() :: {ok, request()} | {400, 'BAD_REQUEST', binary()}). +-type(filter() :: fun((request(), request_meta()) -> filter_result())). + +-type(spec_opts() :: #{check_schema => boolean() | filter(), translate_body => boolean()}). + +-type(route_path() :: string() | binary()). +-type(route_methods() :: map()). +-type(route_handler() :: atom()). +-type(route_options() :: #{filter => filter() | undefined}). + +-type(api_spec_entry() :: {route_path(), route_methods(), route_handler(), route_options()}). +-type(api_spec_component() :: map()). + +%%------------------------------------------------------------------------------ +%% API +%%------------------------------------------------------------------------------ + %% @equiv spec(Module, #{check_schema => false}) --spec(spec(module()) -> - {list({Path, Specs, OperationId, Options}), list(Component)} when - Path :: string()|binary(), - Specs :: map(), - OperationId :: atom(), - Options :: #{filter => fun((map(), - #{module => module(), path => string(), method => atom()}) -> map())}, - Component :: map()). +-spec(spec(module()) -> {list(api_spec_entry()), list(api_spec_component())}). spec(Module) -> spec(Module, #{check_schema => false}). --spec(spec(module(), #{check_schema => boolean()}) -> - {list({Path, Specs, OperationId, Options}), list(Component)} when - Path :: string()|binary(), - Specs :: map(), - OperationId :: atom(), - Options :: #{filter => fun((map(), - #{module => module(), path => string(), method => atom()}) -> map())}, - Component :: map()). +-spec(spec(module(), spec_opts()) -> {list(api_spec_entry()), list(api_spec_component())}). spec(Module, Options) -> Paths = apply(Module, paths, []), {ApiSpec, AllRefs} = @@ -60,26 +66,10 @@ spec(Module, Options) -> end, {[], []}, Paths), {ApiSpec, components(lists:usort(AllRefs))}. --spec(translate_req(#{binding => list(), query_string => list(), body => map()}, - #{module => module(), path => string(), method => atom()}) -> - {ok, #{binding => list(), query_string => list(), body => map()}}| - {400, 'BAD_REQUEST', binary()}). -translate_req(Request, #{module := Module, path := Path, method := Method}) -> - #{Method := Spec} = apply(Module, schema, [Path]), - try - Params = maps:get(parameters, Spec, []), - Body = maps:get(requestBody, Spec, []), - {Bindings, QueryStr} = check_parameters(Request, Params, Module), - NewBody = check_requestBody(Request, Body, Module, hoconsc:is_schema(Body)), - {ok, Request#{bindings => Bindings, query_string => QueryStr, body => NewBody}} - catch throw:Error -> - {_, [{validation_error, ValidErr}]} = Error, - #{path := Key, reason := Reason} = ValidErr, - {400, 'BAD_REQUEST', iolist_to_binary(io_lib:format("~ts : ~p", [Key, Reason]))} - end. - +-spec(namespace() -> hocon_schema:name()). namespace() -> "public". +-spec(fields(hocon_schema:name()) -> hocon_schema:fields()). fields(page) -> Desc = <<"Page number of the results to fetch.">>, Meta = #{in => query, desc => Desc, default => 1, example => 1}, @@ -90,9 +80,19 @@ fields(limit) -> Meta = #{in => query, desc => Desc, default => ?MAX_ROW_LIMIT, example => 50}, [{limit, hoconsc:mk(range(1, ?MAX_ROW_LIMIT), Meta)}]. +-spec(schema_with_example(hocon_schema:type(), term()) -> hocon_schema:field_schema_map()). +schema_with_example(Type, Example) -> + hoconsc:mk(Type, #{examples => #{<<"example">> => Example}}). + +-spec(schema_with_examples(hocon_schema:type(), map()) -> hocon_schema:field_schema_map()). +schema_with_examples(Type, Examples) -> + hoconsc:mk(Type, #{examples => #{<<"examples">> => Examples}}). + +-spec(error_codes(list(atom())) -> hocon_schema:fields()). error_codes(Codes) -> error_codes(Codes, <<"Error code to troubleshoot problems.">>). +-spec(error_codes(nonempty_list(atom()), binary()) -> hocon_schema:fields()). error_codes(Codes = [_ | _], MsgExample) -> [ {code, hoconsc:mk(hoconsc:enum(Codes))}, @@ -102,9 +102,45 @@ error_codes(Codes = [_ | _], MsgExample) -> })} ]. -support_check_schema(#{check_schema := true}) -> ?DEFAULT_FILTER; -support_check_schema(#{check_schema := Func}) when is_function(Func, 2) -> #{filter => Func}; -support_check_schema(_) -> #{filter => undefined}. +%%------------------------------------------------------------------------------ +%% Private functions +%%------------------------------------------------------------------------------ + +filter_check_request_and_translate_body(Request, RequestMeta) -> + translate_req(Request, RequestMeta, fun check_and_translate/3). + +filter_check_request(Request, RequestMeta) -> + translate_req(Request, RequestMeta, fun check_only/3). + +translate_req(Request, #{module := Module, path := Path, method := Method}, CheckFun) -> + #{Method := Spec} = apply(Module, schema, [Path]), + try + Params = maps:get(parameters, Spec, []), + Body = maps:get(requestBody, Spec, []), + {Bindings, QueryStr} = check_parameters(Request, Params, Module), + NewBody = check_requestBody(Request, Body, Module, CheckFun, hoconsc:is_schema(Body)), + {ok, Request#{bindings => Bindings, query_string => QueryStr, body => NewBody}} + catch throw:Error -> + {_, [{validation_error, ValidErr}]} = Error, + #{path := Key, reason := Reason} = ValidErr, + {400, 'BAD_REQUEST', iolist_to_binary(io_lib:format("~ts : ~p", [Key, Reason]))} + end. + +check_and_translate(Schema, Map, Opts) -> + hocon_schema:check_plain(Schema, Map, Opts). + +check_only(Schema, Map, Opts) -> + _ = hocon_schema:check_plain(Schema, Map, Opts), + Map. + +support_check_schema(#{check_schema := true, translate_body := true}) -> + #{filter => fun filter_check_request_and_translate_body/2}; +support_check_schema(#{check_schema := true}) -> + #{filter => fun filter_check_request/2}; +support_check_schema(#{check_schema := Filter}) when is_function(Filter, 2) -> + #{filter => Filter}; +support_check_schema(_) -> + #{filter => undefined}. parse_spec_ref(Module, Path) -> Schema = @@ -143,10 +179,10 @@ check_parameter([{Name, Type} | Spec], Bindings, QueryStr, Module, BindingsAcc, query -> NewQueryStr = hocon_schema:check_plain(Schema, QueryStr, #{override_env => false}), NewQueryStrAcc = maps:merge(QueryStrAcc, NewQueryStr), - check_parameter(Spec, Bindings, QueryStr, Module, BindingsAcc, NewQueryStrAcc) + check_parameter(Spec, Bindings, QueryStr, Module,BindingsAcc, NewQueryStrAcc) end. -check_requestBody(#{body := Body}, Schema, Module, true) -> +check_requestBody(#{body := Body}, Schema, Module, CheckFun, true) -> Type0 = hocon_schema:field_schema(Schema, type), Type = case Type0 of @@ -154,7 +190,7 @@ check_requestBody(#{body := Body}, Schema, Module, true) -> _ -> Type0 end, NewSchema = ?INIT_SCHEMA#{roots => [{root, Type}]}, - #{<<"root">> := NewBody} = hocon_schema:check_plain(NewSchema, #{<<"root">> => Body}, #{override_env => false}), + #{<<"root">> := NewBody} = CheckFun(NewSchema, #{<<"root">> => Body}, #{override_env => false}), NewBody; %% TODO not support nest object check yet, please use ref! %% RequestBody = [ {per_page, mk(integer(), #{}}, @@ -163,10 +199,10 @@ check_requestBody(#{body := Body}, Schema, Module, true) -> %% {good_nest_2, mk(ref(?MODULE, good_ref), #{})} %% ]} %% ] -check_requestBody(#{body := Body}, Spec, _Module, false) -> +check_requestBody(#{body := Body}, Spec, _Module, CheckFun, false) -> lists:foldl(fun({Name, Type}, Acc) -> Schema = ?INIT_SCHEMA#{roots => [{Name, Type}]}, - maps:merge(Acc, hocon_schema:check_plain(Schema, Body)) + maps:merge(Acc, CheckFun(Schema, Body, #{})) end, #{}, Spec). %% tags, description, summary, security, deprecated @@ -244,14 +280,15 @@ trans_desc(Spec, Hocon) -> requestBody([], _Module) -> {[], []}; requestBody(Schema, Module) -> - {Props, Refs} = + {{Props, Refs}, Examples} = case hoconsc:is_schema(Schema) of true -> HoconSchema = hocon_schema:field_schema(Schema, type), - hocon_schema_to_spec(HoconSchema, Module); - false -> parse_object(Schema, Module) + SchemaExamples = hocon_schema:field_schema(Schema, examples), + {hocon_schema_to_spec(HoconSchema, Module), SchemaExamples}; + false -> {parse_object(Schema, Module), undefined} end, - {#{<<"content">> => #{<<"application/json">> => #{<<"schema">> => Props}}}, + {#{<<"content">> => content(Props, Examples)}, Refs}. responses(Responses, Module) -> @@ -264,19 +301,20 @@ response(Status, ?REF(StructName), {Acc, RefsAcc, Module}) -> response(Status, ?R_REF(Module, StructName), {Acc, RefsAcc, Module}); response(Status, ?R_REF(_Mod, _Name) = RRef, {Acc, RefsAcc, Module}) -> {Spec, Refs} = hocon_schema_to_spec(RRef, Module), - Content = #{<<"application/json">> => #{<<"schema">> => Spec}}, + Content = content(Spec), {Acc#{integer_to_binary(Status) => #{<<"content">> => Content}}, Refs ++ RefsAcc, Module}; response(Status, Schema, {Acc, RefsAcc, Module}) -> case hoconsc:is_schema(Schema) of true -> Hocon = hocon_schema:field_schema(Schema, type), + Examples = hocon_schema:field_schema(Schema, examples), {Spec, Refs} = hocon_schema_to_spec(Hocon, Module), Init = trans_desc(#{}, Schema), - Content = #{<<"application/json">> => #{<<"schema">> => Spec}}, + Content = content(Spec, Examples), {Acc#{integer_to_binary(Status) => Init#{<<"content">> => Content}}, Refs ++ RefsAcc, Module}; false -> {Props, Refs} = parse_object(Schema, Module), - Content = #{<<"content">> => #{<<"application/json">> => #{<<"schema">> => Props}}}, + Content = #{<<"content">> => content(Props)}, {Acc#{integer_to_binary(Status) => Content}, Refs ++ RefsAcc, Module} end. @@ -467,3 +505,11 @@ parse_object(Other, Module) -> is_required(Hocon) -> hocon_schema:field_schema(Hocon, required) =:= true orelse hocon_schema:field_schema(Hocon, nullable) =:= false. + +content(ApiSpec) -> + content(ApiSpec, undefined). + +content(ApiSpec, undefined) -> + #{<<"application/json">> => #{<<"schema">> => ApiSpec}}; +content(ApiSpec, Examples) when is_map(Examples) -> + #{<<"application/json">> => Examples#{<<"schema">> => ApiSpec}}. diff --git a/apps/emqx_dashboard/test/emqx_swagger_parameter_SUITE.erl b/apps/emqx_dashboard/test/emqx_swagger_parameter_SUITE.erl index 9c9958880..c938788e4 100644 --- a/apps/emqx_dashboard/test/emqx_swagger_parameter_SUITE.erl +++ b/apps/emqx_dashboard/test/emqx_swagger_parameter_SUITE.erl @@ -209,14 +209,32 @@ t_in_mix_trans_error(_Config) -> ok. t_api_spec(_Config) -> - {Spec, _Components} = emqx_dashboard_swagger:spec(?MODULE), - Filter = fun(V, S) -> lists:all(fun({_, _, _, #{filter := Filter}}) -> Filter =:= V end, S) end, - ?assertEqual(true, Filter(undefined, Spec)), - {Spec1, _Components1} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}), - ?assertEqual(true, Filter(fun emqx_dashboard_swagger:translate_req/2, Spec1)), - {Spec2, _Components2} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => fun emqx_dashboard_swagger:translate_req/2}), - ?assertEqual(true, Filter(fun emqx_dashboard_swagger:translate_req/2, Spec2)), - ok. + {Spec0, _} = emqx_dashboard_swagger:spec(?MODULE), + assert_all_filters_equal(Spec0, undefined), + + {Spec1, _} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => false}), + assert_all_filters_equal(Spec1, undefined), + + CustomFilter = fun(Request, _RequestMeta) -> {ok, Request} end, + {Spec2, _} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => CustomFilter}), + assert_all_filters_equal(Spec2, CustomFilter), + + {Spec3, _} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}), + Path = "/test/in/:filter", + + Filter = filter(Spec3, Path), + Bindings = #{filter => <<"created">>}, + + ?assertMatch( + {ok, #{bindings := #{filter := created}}}, + trans_parameters(Path, Bindings, #{}, Filter)). + +assert_all_filters_equal(Spec, Filter) -> + lists:foreach( + fun({_, _, _, #{filter := F}}) -> + ?assertEqual(Filter, F) + end, + Spec). validate(Path, ExpectParams) -> {OperationId, Spec, Refs} = emqx_dashboard_swagger:parse_spec_ref(?MODULE, Path), @@ -226,10 +244,17 @@ validate(Path, ExpectParams) -> ?assertEqual([], Refs), Spec. +filter(ApiSpec, Path) -> + [Filter] = [F || {P, _, _, #{filter := F}} <- ApiSpec, P =:= Path], + Filter. + trans_parameters(Path, Bindings, QueryStr) -> + trans_parameters(Path, Bindings, QueryStr, fun emqx_dashboard_swagger:filter_check_request/2). + +trans_parameters(Path, Bindings, QueryStr, Filter) -> Meta = #{module => ?MODULE, method => post, path => Path}, Request = #{bindings => Bindings, query_string => QueryStr, body => #{}}, - emqx_dashboard_swagger:translate_req(Request, Meta). + Filter(Request, Meta). api_spec() -> emqx_dashboard_swagger:spec(?MODULE). diff --git a/apps/emqx_dashboard/test/emqx_swagger_requestBody_SUITE.erl b/apps/emqx_dashboard/test/emqx_swagger_requestBody_SUITE.erl index 7aa986d1d..768942776 100644 --- a/apps/emqx_dashboard/test/emqx_swagger_requestBody_SUITE.erl +++ b/apps/emqx_dashboard/test/emqx_swagger_requestBody_SUITE.erl @@ -10,7 +10,7 @@ t_ref_array_with_key/1, t_ref_array_without_key/1 ]). -export([ - t_object_trans/1, t_nest_object_trans/1, t_local_ref_trans/1, + t_object_trans/1, t_object_notrans/1, t_nest_object_trans/1, t_local_ref_trans/1, t_remote_ref_trans/1, t_nest_ref_trans/1, t_ref_array_with_key_trans/1, t_ref_array_without_key_trans/1, t_ref_trans_error/1, t_object_trans_error/1 @@ -32,7 +32,7 @@ groups() -> [ t_ref_array_with_key, t_ref_array_without_key, t_nest_ref]}, {validation, [parallel], [ - t_object_trans, t_local_ref_trans, t_remote_ref_trans, + t_object_trans, t_object_notrans, t_local_ref_trans, t_remote_ref_trans, t_ref_array_with_key_trans, t_ref_array_without_key_trans, t_nest_ref_trans, t_ref_trans_error, t_object_trans_error %% t_nest_object_trans, @@ -173,8 +173,29 @@ t_ref_array_without_key(_Config) -> ok. t_api_spec(_Config) -> - emqx_dashboard_swagger:spec(?MODULE), - ok. + {Spec0, _} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}), + Path = "/object", + Body = #{ + <<"per_page">> => 1, + <<"timeout">> => <<"infinity">>, + <<"inner_ref">> => #{ + <<"webhook-host">> => <<"127.0.0.1:80">>, + <<"log_dir">> => <<"var/log/test">>, + <<"tag">> => <<"god_tag">> + } + }, + + Filter0 = filter(Spec0, Path), + ?assertMatch( + {ok, #{body := ActualBody}}, + trans_requestBody(Path, Body, Filter0)), + + {Spec1, _} = emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true, translate_body => true}), + Filter1 = filter(Spec1, Path), + ?assertMatch( + {ok, #{body := #{<<"timeout">> := infinity}}}, + trans_requestBody(Path, Body, Filter1)). + t_object_trans(_Config) -> Path = "/object", @@ -205,6 +226,21 @@ t_object_trans(_Config) -> ?assertEqual(Expect, ActualBody), ok. +t_object_notrans(_Config) -> + Path = "/object", + Body = #{ + <<"per_page">> => 1, + <<"timeout">> => <<"infinity">>, + <<"inner_ref">> => #{ + <<"webhook-host">> => <<"127.0.0.1:80">>, + <<"log_dir">> => <<"var/log/test">>, + <<"tag">> => <<"god_tag">> + } + }, + {ok, #{body := ActualBody}} = trans_requestBody(Path, Body, fun emqx_dashboard_swagger:filter_check_request/2), + ?assertEqual(Body, ActualBody), + ok. + t_nest_object_trans(_Config) -> Path = "/nest/object", Body = #{ @@ -337,6 +373,7 @@ t_ref_array_with_key_trans(_Config) -> {ok, NewRequest} = trans_requestBody(Path, Body), ?assertEqual(Expect, NewRequest), ok. + t_ref_array_without_key_trans(_Config) -> Path = "/ref/array/without/key", Body = [#{ @@ -401,10 +438,18 @@ validate(Path, ExpectSpec, ExpectRefs) -> ?assertEqual(ExpectRefs, Refs), {Spec, emqx_dashboard_swagger:components(Refs)}. + +filter(ApiSpec, Path) -> + [Filter] = [F || {P, _, _, #{filter := F}} <- ApiSpec, P =:= Path], + Filter. + trans_requestBody(Path, Body) -> + trans_requestBody(Path, Body, fun emqx_dashboard_swagger:filter_check_request_and_translate_body/2). + +trans_requestBody(Path, Body, Filter) -> Meta = #{module => ?MODULE, method => post, path => Path}, Request = #{bindings => #{}, query_string => #{}, body => Body}, - emqx_dashboard_swagger:translate_req(Request, Meta). + Filter(Request, Meta). api_spec() -> emqx_dashboard_swagger:spec(?MODULE). paths() -> diff --git a/apps/emqx_modules/src/emqx_rewrite_api.erl b/apps/emqx_modules/src/emqx_rewrite_api.erl index 1fa5e9467..3f92cd11f 100644 --- a/apps/emqx_modules/src/emqx_rewrite_api.erl +++ b/apps/emqx_modules/src/emqx_rewrite_api.erl @@ -33,7 +33,7 @@ ]). api_spec() -> - emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true}). + emqx_dashboard_swagger:spec(?MODULE, #{check_schema => true, translate_body => true}). paths() -> ["/mqtt/topic_rewrite"].