diff --git a/.ci/docker-compose-file/docker-compose-redis-single-tcp.yaml b/.ci/docker-compose-file/docker-compose-redis-single-tcp.yaml index 92a3fcf7d..5fa9f0749 100644 --- a/.ci/docker-compose-file/docker-compose-redis-single-tcp.yaml +++ b/.ci/docker-compose-file/docker-compose-redis-single-tcp.yaml @@ -4,6 +4,8 @@ services: redis_server: container_name: redis image: redis:${REDIS_TAG} + ports: + - "6379:6379" command: - redis-server - "--bind 0.0.0.0 ::" diff --git a/apps/emqx_authn/etc/emqx_authn.conf b/apps/emqx_authn/etc/emqx_authn.conf index 5ddeb7a32..bc2036fea 100644 --- a/apps/emqx_authn/etc/emqx_authn.conf +++ b/apps/emqx_authn/etc/emqx_authn.conf @@ -21,6 +21,17 @@ authentication: { # salt_field: salt # password_hash_algorithm: sha256 # salt_position: prefix + # }, + # { + # name: "authenticator 3" + # mechanism: password-based + # server_type: redis + # server: "127.0.0.1:6379" + # password: "public" + # 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.erl b/apps/emqx_authn/src/emqx_authn.erl index 0f734fb30..034e06b89 100644 --- a/apps/emqx_authn/src/emqx_authn.erl +++ b/apps/emqx_authn/src/emqx_authn.erl @@ -325,6 +325,8 @@ authenticator_provider(#{mechanism := 'password-based', server_type := 'pgsql'}) emqx_authn_pgsql; authenticator_provider(#{mechanism := 'password-based', server_type := 'mongodb'}) -> emqx_authn_mongodb; +authenticator_provider(#{mechanism := 'password-based', server_type := 'redis'}) -> + emqx_authn_redis; authenticator_provider(#{mechanism := 'password-based', server_type := 'http-server'}) -> emqx_authn_http; authenticator_provider(#{mechanism := jwt}) -> diff --git a/apps/emqx_authn/src/emqx_authn_api.erl b/apps/emqx_authn/src/emqx_authn_api.erl index 63c536d5e..78ef5fd35 100644 --- a/apps/emqx_authn/src/emqx_authn_api.erl +++ b/apps/emqx_authn/src/emqx_authn_api.erl @@ -32,7 +32,7 @@ -define(EXAMPLE_1, #{name => <<"example 1">>, mechanism => <<"password-based">>, - server_type => <<"built-in-example">>, + server_type => <<"built-in-database">>, user_id_type => <<"username">>, password_hash_algorithm => #{ name => <<"sha256">> @@ -76,6 +76,16 @@ salt_position => <<"prefix">> }). +-define(EXAMPLE_5, #{name => <<"example 5">>, + mechanism => <<"password-based">>, + server_type => <<"redis">>, + server => <<"127.0.0.1:6379">>, + database => 0, + query => <<"HMGET ${mqtt-username} password_hash salt">>, + password_hash_algorithm => <<"sha256">>, + salt_position => <<"prefix">> + }). + -define(ERR_RESPONSE(Desc), #{description => Desc, content => #{ 'application/json' => #{ @@ -180,6 +190,10 @@ authenticators_api() -> mongodb => #{ summary => <<"Authentication with MongoDB">>, value => emqx_json:encode(?EXAMPLE_4) + }, + redis => #{ + summary => <<"Authentication with Redis">>, + value => emqx_json:encode(?EXAMPLE_5) } } } @@ -192,6 +206,7 @@ authenticators_api() -> 'application/json' => #{ schema => minirest:ref(<<"returned_authenticator">>), examples => #{ + %% TODO: return full content example1 => #{ summary => <<"Example 1">>, value => emqx_json:encode(maps:put(id, <<"example 1">>, ?EXAMPLE_1)) @@ -207,6 +222,10 @@ authenticators_api() -> example4 => #{ summary => <<"Example 4">>, value => emqx_json:encode(maps:put(id, <<"example 4">>, ?EXAMPLE_4)) + }, + example5 => #{ + summary => <<"Example 4">>, + value => emqx_json:encode(maps:put(id, <<"example 5">>, ?EXAMPLE_5)) } } } @@ -234,6 +253,7 @@ authenticators_api() -> , maps:put(id, <<"example 2">>, ?EXAMPLE_2) , maps:put(id, <<"example 3">>, ?EXAMPLE_3) , maps:put(id, <<"example 4">>, ?EXAMPLE_4) + , maps:put(id, <<"example 5">>, ?EXAMPLE_5) ]) } } @@ -281,6 +301,10 @@ authenticators_api2() -> example4 => #{ summary => <<"Example 4">>, value => emqx_json:encode(maps:put(id, <<"example 4">>, ?EXAMPLE_4)) + }, + example5 => #{ + summary => <<"Example 5">>, + value => emqx_json:encode(maps:put(id, <<"example 5">>, ?EXAMPLE_5)) } } } @@ -345,6 +369,10 @@ authenticators_api2() -> example4 => #{ summary => <<"Example 4">>, value => emqx_json:encode(maps:put(id, <<"example 4">>, ?EXAMPLE_4)) + }, + example5 => #{ + summary => <<"Example 5">>, + value => emqx_json:encode(maps:put(id, <<"example 5">>, ?EXAMPLE_5)) } } } @@ -1024,6 +1052,66 @@ definitions() -> } }, + PasswordBasedRedisDef = #{ + type => object, + required => [], + properties => #{ + server_type => #{ + 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, + exmaple => 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 => [ server_type @@ -1155,6 +1243,7 @@ definitions() -> , #{<<"password_based_mysql">> => PasswordBasedMySQLDef} , #{<<"password_based_pgsql">> => PasswordBasedPgSQLDef} , #{<<"password_based_mongodb">> => PasswordBasedMongoDBDef} + , #{<<"password_based_redis">> => PasswordBasedRedisDef} , #{<<"password_based_http_server">> => PasswordBasedHTTPServerDef} , #{<<"password_hash_algorithm">> => PasswordHashAlgorithmDef} , #{<<"ssl">> => SSLDef} diff --git a/apps/emqx_authn/src/emqx_authn_schema.erl b/apps/emqx_authn/src/emqx_authn_schema.erl index ea1639b76..6a834df1f 100644 --- a/apps/emqx_authn/src/emqx_authn_schema.erl +++ b/apps/emqx_authn/src/emqx_authn_schema.erl @@ -53,7 +53,10 @@ authenticators(type) -> , hoconsc:ref(emqx_authn_pgsql, config) , hoconsc:ref(emqx_authn_mongodb, standalone) , hoconsc:ref(emqx_authn_mongodb, 'replica-set') - , hoconsc:ref(emqx_authn_mongodb, sharded) + , hoconsc:ref(emqx_authn_mongodb, 'sharded-cluster') + , hoconsc:ref(emqx_authn_redis, standalone) + , hoconsc:ref(emqx_authn_redis, cluster) + , hoconsc:ref(emqx_authn_redis, sentinel) , hoconsc:ref(emqx_authn_http, get) , hoconsc:ref(emqx_authn_http, post) , hoconsc:ref(emqx_authn_jwt, 'hmac-based') diff --git a/apps/emqx_authn/src/emqx_authn_utils.erl b/apps/emqx_authn/src/emqx_authn_utils.erl index c035278cc..c0ba8a549 100644 --- a/apps/emqx_authn/src/emqx_authn_utils.erl +++ b/apps/emqx_authn/src/emqx_authn_utils.erl @@ -18,6 +18,7 @@ -export([ replace_placeholders/2 , replace_placeholder/2 + , hash/4 , gen_salt/0 , bin/1 ]). @@ -54,6 +55,10 @@ replace_placeholder(<<"${cert-common-name}">>, Credential) -> replace_placeholder(Constant, _) -> Constant. +hash(Algorithm, Password, Salt, prefix) -> + emqx_passwd:hash(Algorithm, <>); +hash(Algorithm, Password, Salt, suffix) -> + emqx_passwd:hash(Algorithm, <>). gen_salt() -> <> = crypto:strong_rand_bytes(16), diff --git a/apps/emqx_authn/src/simple_authn/emqx_authn_mongodb.erl b/apps/emqx_authn/src/simple_authn/emqx_authn_mongodb.erl index 4b9fab2be..ff1b2161a 100644 --- a/apps/emqx_authn/src/simple_authn/emqx_authn_mongodb.erl +++ b/apps/emqx_authn/src/simple_authn/emqx_authn_mongodb.erl @@ -41,7 +41,7 @@ structs() -> [""]. fields("") -> [ {config, {union, [ hoconsc:t(standalone) , hoconsc:t('replica-set') - , hoconsc:t(sharded) + , hoconsc:t('sharded-cluster') ]}} ]; @@ -51,7 +51,7 @@ fields(standalone) -> fields('replica-set') -> common_fields() ++ emqx_connector_mongo:fields(rs); -fields(sharded) -> +fields('sharded-cluster') -> common_fields() ++ emqx_connector_mongo:fields(sharded). common_fields() -> diff --git a/apps/emqx_authn/src/simple_authn/emqx_authn_mysql.erl b/apps/emqx_authn/src/simple_authn/emqx_authn_mysql.erl index 62c5c49e7..f2a01e7e1 100644 --- a/apps/emqx_authn/src/simple_authn/emqx_authn_mysql.erl +++ b/apps/emqx_authn/src/simple_authn/emqx_authn_mysql.erl @@ -146,11 +146,7 @@ check_password(Password, #{password_hash_algorithm := Algorithm, salt_position := SaltPosition}) -> Salt = maps:get(salt, Selected, <<>>), - Hash0 = case SaltPosition of - prefix -> emqx_passwd:hash(Algorithm, <>); - suffix -> emqx_passwd:hash(Algorithm, <>) - end, - case Hash0 =:= Hash of + case Hash =:= emqx_authn_utils:hash(Algorithm, Password, Salt, SaltPosition) of true -> ok; false -> {error, bad_username_or_password} end. diff --git a/apps/emqx_authn/src/simple_authn/emqx_authn_pgsql.erl b/apps/emqx_authn/src/simple_authn/emqx_authn_pgsql.erl index a4d00be29..b83e111c3 100644 --- a/apps/emqx_authn/src/simple_authn/emqx_authn_pgsql.erl +++ b/apps/emqx_authn/src/simple_authn/emqx_authn_pgsql.erl @@ -132,11 +132,7 @@ check_password(Password, #{password_hash_algorithm := Algorithm, salt_position := SaltPosition}) -> Salt = maps:get(salt, Selected, <<>>), - Hash0 = case SaltPosition of - prefix -> emqx_passwd:hash(Algorithm, <>); - suffix -> emqx_passwd:hash(Algorithm, <>) - end, - case Hash0 =:= Hash of + case Hash =:= emqx_authn_utils:hash(Algorithm, Password, Salt, SaltPosition) of true -> ok; false -> {error, bad_username_or_password} end. diff --git a/apps/emqx_authn/src/simple_authn/emqx_authn_redis.erl b/apps/emqx_authn/src/simple_authn/emqx_authn_redis.erl new file mode 100644 index 000000000..5d6e579ac --- /dev/null +++ b/apps/emqx_authn/src/simple_authn/emqx_authn_redis.erl @@ -0,0 +1,222 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020-2021 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_authn_redis). + +-include("emqx_authn.hrl"). +-include_lib("emqx/include/logger.hrl"). +-include_lib("typerefl/include/types.hrl"). + +-behaviour(hocon_schema). + +-export([ structs/0 + , fields/1 + ]). + +-export([ create/1 + , update/2 + , authenticate/2 + , destroy/1 + ]). + +%%------------------------------------------------------------------------------ +%% Hocon Schema +%%------------------------------------------------------------------------------ + +structs() -> [""]. + +fields("") -> + [ {config, {union, [ hoconsc:t(standalone) + , hoconsc:t(cluster) + , hoconsc:t(sentinel) + ]}} + ]; + +fields(standalone) -> + common_fields() ++ emqx_connector_redis:fields(single); + +fields(cluster) -> + common_fields() ++ emqx_connector_redis:fields(cluster); + +fields(sentinel) -> + common_fields() ++ emqx_connector_redis:fields(sentinel). + +common_fields() -> + [ {name, fun emqx_authn_schema:authenticator_name/1} + , {mechanism, {enum, ['password-based']}} + , {server_type, {enum, [redis]}} + , {query, fun query/1} + , {password_hash_algorithm, fun password_hash_algorithm/1} + , {salt_position, fun salt_position/1} + ]. + +query(type) -> string(); +query(nullable) -> false; +query(_) -> undefined. + +password_hash_algorithm(type) -> {enum, [plain, md5, sha, sha256, sha512, bcrypt]}; +password_hash_algorithm(default) -> sha256; +password_hash_algorithm(_) -> undefined. + +salt_position(type) -> {enum, [prefix, suffix]}; +salt_position(default) -> prefix; +salt_position(_) -> undefined. + +%%------------------------------------------------------------------------------ +%% APIs +%%------------------------------------------------------------------------------ + +create(#{ query := Query + , '_unique' := Unique + } = Config) -> + try + NQuery = parse_query(Query), + State = maps:with([ password_hash_algorithm + , salt_position + , '_unique'], Config), + NState = State#{query => NQuery}, + case emqx_resource:create_local(Unique, emqx_connector_redis, Config) of + {ok, _} -> + {ok, NState}; + {error, already_created} -> + {ok, NState}; + {error, Reason} -> + {error, Reason} + end + catch + error:{unsupported_query, Query} -> + {error, {unsupported_query, Query}}; + error:missing_password_hash -> + {error, missing_password_hash}; + error:{unsupported_field, Field} -> + {error, {unsupported_field, Field}} + end. + +update(Config, State) -> + case create(Config) of + {ok, NewState} -> + ok = destroy(State), + {ok, NewState}; + {error, Reason} -> + {error, Reason} + end. + +authenticate(#{auth_method := _}, _) -> + ignore; +authenticate(#{password := Password} = Credential, + #{ query := {Command, Key, Fields} + , '_unique' := Unique + } = State) -> + try + NKey = binary_to_list(iolist_to_binary(replace_placeholders(Key, Credential))), + case emqx_resource:query(Unique, {cmd, [Command, NKey | Fields]}) of + {ok, Values} -> + check_password(Password, merge(Fields, Values), State); + {error, Reason} -> + ?LOG(error, "['~s'] Query failed: ~p", [Unique, Reason]), + ignore + end + catch + error:{cannot_get_variable, Placeholder} -> + ?LOG(warning, "The following error occurred in '~s' during authentication: ~p", [Unique, {cannot_get_variable, Placeholder}]), + ignore + end. + +destroy(#{'_unique' := Unique}) -> + _ = emqx_resource:remove_local(Unique), + ok. + +%%------------------------------------------------------------------------------ +%% Internal functions +%%------------------------------------------------------------------------------ + +%% Only support HGET and HMGET +parse_query(Query) -> + case string:tokens(Query, " ") of + [Command, Key, Field | Fields] when Command =:= "HGET" orelse Command =:= "HMGET" -> + NFields = [Field | Fields], + check_fields(NFields), + NKey = parse_key(Key), + {Command, NKey, NFields}; + _ -> + error({unsupported_query, Query}) + end. + +check_fields(Fields) -> + check_fields(Fields, false). + +check_fields([], false) -> + error(missing_password_hash); +check_fields([], true) -> + ok; +check_fields(["password_hash" | More], false) -> + check_fields(More, true); +check_fields(["salt" | More], HasPassHash) -> + check_fields(More, HasPassHash); +% check_fields(["is_superuser" | More], HasPassHash) -> +% check_fields(More, HasPassHash); +check_fields([Field | _], _) -> + error({unsupported_field, Field}). + +parse_key(Key) -> + Tokens = re:split(Key, "(" ++ ?RE_PLACEHOLDER ++ ")", [{return, binary}, group, trim]), + parse_key(Tokens, []). + +parse_key([], Acc) -> + lists:reverse(Acc); +parse_key([[Constant, Placeholder] | Tokens], Acc) -> + parse_key(Tokens, [{placeholder, Placeholder}, {constant, Constant} | Acc]); +parse_key([[Constant] | Tokens], Acc) -> + parse_key(Tokens, [{constant, Constant} | Acc]). + +replace_placeholders(Key, Credential) -> + lists:map(fun({constant, Constant}) -> + Constant; + ({placeholder, Placeholder}) -> + case emqx_authn_utils:replace_placeholder(Placeholder, Credential) of + undefined -> error({cannot_get_variable, Placeholder}); + Value -> Value + end + end, Key). + +merge(Fields, Value) when not is_list(Value) -> + merge(Fields, [Value]); +merge(Fields, Values) -> + maps:from_list( + lists:filter(fun({_, V}) -> + V =/= undefined + end, lists:zip(Fields, Values))). + +check_password(undefined, _Selected, _State) -> + {error, bad_username_or_password}; +check_password(Password, + #{"password_hash" := PasswordHash}, + #{password_hash_algorithm := bcrypt}) -> + case {ok, PasswordHash} =:= bcrypt:hashpw(Password, PasswordHash) of + true -> ok; + false -> {error, bad_username_or_password} + end; +check_password(Password, + #{"password_hash" := PasswordHash} = Selected, + #{password_hash_algorithm := Algorithm, + salt_position := SaltPosition}) -> + Salt = maps:get("salt", Selected, <<>>), + case PasswordHash =:= emqx_authn_utils:hash(Algorithm, Password, Salt, SaltPosition) of + true -> ok; + false -> {error, bad_username_or_password} + end; +check_password(_Password, _Selected, _State) -> + ignore. diff --git a/rebar.config b/rebar.config index e0c675026..0f70bed4d 100644 --- a/rebar.config +++ b/rebar.config @@ -43,7 +43,7 @@ {deps, [ {gpb, "4.11.2"} %% gpb only used to build, but not for release, pin it here to avoid fetching a wrong version due to rebar plugins scattered in all the deps - , {ehttpc, {git, "https://github.com/emqx/ehttpc", {tag, "0.1.8"}}} + , {ehttpc, {git, "https://github.com/emqx/ehttpc", {tag, "0.1.9"}}} , {gproc, {git, "https://github.com/uwiger/gproc", {tag, "0.8.0"}}} , {jiffy, {git, "https://github.com/emqx/jiffy", {tag, "1.0.5"}}} , {cowboy, {git, "https://github.com/emqx/cowboy", {tag, "2.8.2"}}}