Merge pull request #5427 from tigercl/feat/redis-authn
feat(authn redis): support authn with redis
This commit is contained in:
commit
40a9932167
|
@ -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 ::"
|
||||
|
|
|
@ -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
|
||||
# }
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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}) ->
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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, <<Salt/binary, Password/binary>>);
|
||||
hash(Algorithm, Password, Salt, suffix) ->
|
||||
emqx_passwd:hash(Algorithm, <<Password/binary, Salt/binary>>).
|
||||
|
||||
gen_salt() ->
|
||||
<<X:128/big-unsigned-integer>> = crypto:strong_rand_bytes(16),
|
||||
|
|
|
@ -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() ->
|
||||
|
|
|
@ -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, <<Salt/binary, Password/binary>>);
|
||||
suffix -> emqx_passwd:hash(Algorithm, <<Password/binary, Salt/binary>>)
|
||||
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.
|
||||
|
|
|
@ -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, <<Salt/binary, Password/binary>>);
|
||||
suffix -> emqx_passwd:hash(Algorithm, <<Password/binary, Salt/binary>>)
|
||||
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.
|
||||
|
|
|
@ -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.
|
|
@ -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"}}}
|
||||
|
|
Loading…
Reference in New Issue