343 lines
11 KiB
Erlang
343 lines
11 KiB
Erlang
%%--------------------------------------------------------------------
|
|
%% Copyright (c) 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_authentication_mnesia).
|
|
|
|
-include("emqx_authentication.hrl").
|
|
|
|
-export([ create/3
|
|
, update/4
|
|
, authenticate/2
|
|
, destroy/1
|
|
]).
|
|
|
|
-export([ import_users/2
|
|
, add_user/2
|
|
, delete_user/2
|
|
, update_user/3
|
|
, lookup_user/2
|
|
, list_users/1
|
|
]).
|
|
|
|
%% TODO: support bcrypt
|
|
-service_type(#{
|
|
name => mnesia,
|
|
params_spec => #{
|
|
user_id_type => #{
|
|
order => 1,
|
|
type => string,
|
|
enum => [<<"username">>, <<"clientid">>, <<"ip">>, <<"common name">>, <<"issuer">>],
|
|
default => <<"username">>
|
|
},
|
|
password_hash_algorithm => #{
|
|
order => 2,
|
|
type => string,
|
|
enum => [<<"plain">>, <<"md5">>, <<"sha">>, <<"sha256">>, <<"sha512">>, <<"bcrypt">>],
|
|
default => <<"sha256">>
|
|
},
|
|
salt_rounds => #{
|
|
order => 3,
|
|
type => number,
|
|
default => 10
|
|
}
|
|
}
|
|
}).
|
|
|
|
-record(user_info,
|
|
{ user_id :: {user_group(), user_id()}
|
|
, password_hash :: binary()
|
|
, salt :: binary()
|
|
}).
|
|
|
|
-type(user_group() :: {chain_id(), service_name()}).
|
|
-type(user_id() :: binary()).
|
|
|
|
-export([mnesia/1]).
|
|
|
|
-boot_mnesia({mnesia, [boot]}).
|
|
-copy_mnesia({mnesia, [copy]}).
|
|
|
|
-define(TAB, mnesia_basic_auth).
|
|
|
|
%%------------------------------------------------------------------------------
|
|
%% Mnesia bootstrap
|
|
%%------------------------------------------------------------------------------
|
|
|
|
%% @doc Create or replicate tables.
|
|
-spec(mnesia(boot | copy) -> ok).
|
|
mnesia(boot) ->
|
|
ok = ekka_mnesia:create_table(?TAB, [
|
|
{disc_copies, [node()]},
|
|
{record_name, user_info},
|
|
{attributes, record_info(fields, user_info)},
|
|
{storage_properties, [{ets, [{read_concurrency, true}]}]}]);
|
|
|
|
mnesia(copy) ->
|
|
ok = ekka_mnesia:copy_table(?TAB, disc_copies).
|
|
|
|
create(ChainID, ServiceName, #{<<"user_id_type">> := Type,
|
|
<<"password_hash_algorithm">> := Algorithm,
|
|
<<"salt_rounds">> := SaltRounds}) ->
|
|
Algorithm =:= <<"bcrypt">> andalso ({ok, _} = application:ensure_all_started(bcrypt)),
|
|
State = #{user_group => {ChainID, ServiceName},
|
|
user_id_type => binary_to_atom(Type, utf8),
|
|
password_hash_algorithm => binary_to_atom(Algorithm, utf8),
|
|
salt_rounds => SaltRounds},
|
|
{ok, State}.
|
|
|
|
update(ChainID, ServiceName, Params, _State) ->
|
|
create(ChainID, ServiceName, Params).
|
|
|
|
authenticate(ClientInfo = #{password := Password},
|
|
#{user_group := UserGroup,
|
|
user_id_type := Type,
|
|
password_hash_algorithm := Algorithm}) ->
|
|
UserID = get_user_identity(ClientInfo, Type),
|
|
case mnesia:dirty_read(?TAB, {UserGroup, UserID}) of
|
|
[] ->
|
|
ignore;
|
|
[#user_info{password_hash = PasswordHash, salt = Salt}] ->
|
|
case PasswordHash =:= hash(Algorithm, Password, Salt) of
|
|
true -> ok;
|
|
false -> {stop, bad_password}
|
|
end
|
|
end.
|
|
|
|
destroy(#{user_group := UserGroup}) ->
|
|
trans(
|
|
fun() ->
|
|
MatchSpec = [{{user_info, {UserGroup, '_'}, '_', '_'}, [], ['$_']}],
|
|
ok = lists:foreach(fun delete_user2/1, mnesia:select(?TAB, MatchSpec, write))
|
|
end).
|
|
|
|
import_users(Filename0, State) ->
|
|
Filename = to_binary(Filename0),
|
|
case filename:extension(Filename) of
|
|
<<".json">> ->
|
|
import_users_from_json(Filename, State);
|
|
<<".csv">> ->
|
|
import_users_from_csv(Filename, State);
|
|
<<>> ->
|
|
{error, unknown_file_format};
|
|
Extension ->
|
|
{error, {unsupported_file_format, Extension}}
|
|
end.
|
|
|
|
add_user(#{<<"user_id">> := UserID,
|
|
<<"password">> := Password},
|
|
#{user_group := UserGroup} = State) ->
|
|
trans(
|
|
fun() ->
|
|
case mnesia:read(?TAB, {UserGroup, UserID}, write) of
|
|
[] ->
|
|
add(UserID, Password, State),
|
|
{ok, #{user_id => UserID}};
|
|
[_] ->
|
|
{error, already_exist}
|
|
end
|
|
end).
|
|
|
|
delete_user(UserID, #{user_group := UserGroup}) ->
|
|
trans(
|
|
fun() ->
|
|
case mnesia:read(?TAB, {UserGroup, UserID}, write) of
|
|
[] ->
|
|
{error, not_found};
|
|
[_] ->
|
|
mnesia:delete(?TAB, {UserGroup, UserID}, write)
|
|
end
|
|
end).
|
|
|
|
update_user(UserID, #{<<"password">> := Password},
|
|
#{user_group := UserGroup} = State) ->
|
|
trans(
|
|
fun() ->
|
|
case mnesia:read(?TAB, {UserGroup, UserID}, write) of
|
|
[] ->
|
|
{error, not_found};
|
|
[_] ->
|
|
add(UserID, Password, State),
|
|
{ok, #{user_id => UserID}}
|
|
end
|
|
end).
|
|
|
|
lookup_user(UserID, #{user_group := UserGroup}) ->
|
|
case mnesia:dirty_read(?TAB, {UserGroup, UserID}) of
|
|
[#user_info{user_id = {_, UserID}}] ->
|
|
{ok, #{user_id => UserID}};
|
|
[] ->
|
|
{error, not_found}
|
|
end.
|
|
|
|
list_users(#{user_group := UserGroup}) ->
|
|
Users = [#{user_id => UserID} || #user_info{user_id = {UserGroup0, UserID}} <- ets:tab2list(?TAB), UserGroup0 =:= UserGroup],
|
|
{ok, Users}.
|
|
|
|
%%------------------------------------------------------------------------------
|
|
%% Internal functions
|
|
%%------------------------------------------------------------------------------
|
|
|
|
%% Example: data/user-credentials.json
|
|
import_users_from_json(Filename, #{user_group := UserGroup}) ->
|
|
case file:read_file(Filename) of
|
|
{ok, Bin} ->
|
|
case emqx_json:safe_decode(Bin, [return_maps]) of
|
|
{ok, List} ->
|
|
trans(fun import/2, [UserGroup, List]);
|
|
{error, Reason} ->
|
|
{error, Reason}
|
|
end;
|
|
{error, Reason} ->
|
|
{error, Reason}
|
|
end.
|
|
|
|
%% Example: data/user-credentials.csv
|
|
import_users_from_csv(Filename, #{user_group := UserGroup}) ->
|
|
case file:open(Filename, [read, binary]) of
|
|
{ok, File} ->
|
|
case get_csv_header(File) of
|
|
{ok, Seq} ->
|
|
Result = trans(fun import/3, [UserGroup, File, Seq]),
|
|
_ = file:close(File),
|
|
Result;
|
|
{error, Reason} ->
|
|
{error, Reason}
|
|
end;
|
|
{error, Reason} ->
|
|
{error, Reason}
|
|
end.
|
|
|
|
import(_UserGroup, []) ->
|
|
ok;
|
|
import(UserGroup, [#{<<"user_id">> := UserID,
|
|
<<"password_hash">> := PasswordHash} = UserInfo | More])
|
|
when is_binary(UserID) andalso is_binary(PasswordHash) ->
|
|
Salt = maps:get(<<"salt">>, UserInfo, <<>>),
|
|
insert_user(UserGroup, UserID, PasswordHash, Salt),
|
|
import(UserGroup, More);
|
|
import(_UserGroup, [_ | _More]) ->
|
|
{error, bad_format}.
|
|
|
|
%% Importing 5w users needs 1.7 seconds
|
|
import(UserGroup, File, Seq) ->
|
|
case file:read_line(File) of
|
|
{ok, Line} ->
|
|
Fields = binary:split(Line, [<<",">>, <<" ">>, <<"\n">>], [global, trim_all]),
|
|
case get_user_info_by_seq(Fields, Seq) of
|
|
{ok, #{user_id := UserID,
|
|
password_hash := PasswordHash} = UserInfo} ->
|
|
Salt = maps:get(salt, UserInfo, <<>>),
|
|
insert_user(UserGroup, UserID, PasswordHash, Salt),
|
|
import(UserGroup, File, Seq);
|
|
{error, Reason} ->
|
|
{error, Reason}
|
|
end;
|
|
eof ->
|
|
ok;
|
|
{error, Reason} ->
|
|
{error, Reason}
|
|
end.
|
|
|
|
get_csv_header(File) ->
|
|
case file:read_line(File) of
|
|
{ok, Line} ->
|
|
Seq = binary:split(Line, [<<",">>, <<" ">>, <<"\n">>], [global, trim_all]),
|
|
{ok, Seq};
|
|
eof ->
|
|
{error, empty_file};
|
|
{error, Reason} ->
|
|
{error, Reason}
|
|
end.
|
|
|
|
get_user_info_by_seq(Fields, Seq) ->
|
|
get_user_info_by_seq(Fields, Seq, #{}).
|
|
|
|
get_user_info_by_seq([], [], #{user_id := _, password_hash := _, salt := _} = Acc) ->
|
|
{ok, Acc};
|
|
get_user_info_by_seq([], [], #{user_id := _, password_hash := _} = Acc) ->
|
|
{ok, Acc};
|
|
get_user_info_by_seq(_, [], _) ->
|
|
{error, bad_format};
|
|
get_user_info_by_seq([UserID | More1], [<<"user_id">> | More2], Acc) ->
|
|
get_user_info_by_seq(More1, More2, Acc#{user_id => UserID});
|
|
get_user_info_by_seq([PasswordHash | More1], [<<"password_hash">> | More2], Acc) ->
|
|
get_user_info_by_seq(More1, More2, Acc#{password_hash => PasswordHash});
|
|
get_user_info_by_seq([Salt | More1], [<<"salt">> | More2], Acc) ->
|
|
get_user_info_by_seq(More1, More2, Acc#{salt => Salt});
|
|
get_user_info_by_seq(_, _, _) ->
|
|
{error, bad_format}.
|
|
|
|
-compile({inline, [add/3]}).
|
|
add(UserID, Password, #{user_group := UserGroup,
|
|
password_hash_algorithm := Algorithm} = State) ->
|
|
Salt = gen_salt(State),
|
|
PasswordHash = hash(Algorithm, Password, Salt),
|
|
case Algorithm of
|
|
bcrypt -> insert_user(UserGroup, UserID, PasswordHash);
|
|
_ -> insert_user(UserGroup, UserID, PasswordHash, Salt)
|
|
end.
|
|
|
|
gen_salt(#{password_hash_algorithm := plain}) ->
|
|
<<>>;
|
|
gen_salt(#{password_hash_algorithm := bcrypt,
|
|
salt_rounds := Rounds}) ->
|
|
{ok, Salt} = bcrypt:gen_salt(Rounds),
|
|
Salt;
|
|
gen_salt(_) ->
|
|
<<X:128/big-unsigned-integer>> = crypto:strong_rand_bytes(16),
|
|
iolist_to_binary(io_lib:format("~32.16.0b", [X])).
|
|
|
|
hash(bcrypt, Password, Salt) ->
|
|
{ok, Hash} = bcrypt:hashpw(Password, Salt),
|
|
list_to_binary(Hash);
|
|
hash(Algorithm, Password, Salt) ->
|
|
emqx_passwd:hash(Algorithm, <<Salt/binary, Password/binary>>).
|
|
|
|
insert_user(UserGroup, UserID, PasswordHash) ->
|
|
insert_user(UserGroup, UserID, PasswordHash, <<>>).
|
|
|
|
insert_user(UserGroup, UserID, PasswordHash, Salt) ->
|
|
Credential = #user_info{user_id = {UserGroup, UserID},
|
|
password_hash = PasswordHash,
|
|
salt = Salt},
|
|
mnesia:write(?TAB, Credential, write).
|
|
|
|
delete_user2(UserInfo) ->
|
|
mnesia:delete_object(?TAB, UserInfo, write).
|
|
|
|
%% TODO: Support other type
|
|
get_user_identity(#{username := Username}, username) ->
|
|
Username;
|
|
get_user_identity(#{clientid := ClientID}, clientid) ->
|
|
ClientID;
|
|
get_user_identity(_, Type) ->
|
|
{error, {bad_user_identity_type, Type}}.
|
|
|
|
trans(Fun) ->
|
|
trans(Fun, []).
|
|
|
|
trans(Fun, Args) ->
|
|
case mnesia:transaction(Fun, Args) of
|
|
{atomic, Res} -> Res;
|
|
{aborted, Reason} -> {error, Reason}
|
|
end.
|
|
|
|
|
|
to_binary(B) when is_binary(B) ->
|
|
B;
|
|
to_binary(L) when is_list(L) ->
|
|
iolist_to_binary(L).
|