382 lines
12 KiB
Erlang
382 lines
12 KiB
Erlang
%%--------------------------------------------------------------------
|
|
%% Copyright (c) 2021-2024 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_jwt).
|
|
|
|
-include_lib("emqx_auth/include/emqx_authn.hrl").
|
|
-include_lib("emqx/include/logger.hrl").
|
|
-include_lib("emqx/include/emqx_placeholder.hrl").
|
|
|
|
-export([
|
|
create/2,
|
|
update/2,
|
|
authenticate/2,
|
|
destroy/1
|
|
]).
|
|
|
|
-define(ALLOWED_VARS, [
|
|
?VAR_CLIENTID,
|
|
?VAR_USERNAME
|
|
]).
|
|
|
|
%%------------------------------------------------------------------------------
|
|
%% APIs
|
|
%%------------------------------------------------------------------------------
|
|
|
|
create(_AuthenticatorID, Config) ->
|
|
create(Config).
|
|
|
|
create(#{verify_claims := VerifyClaims} = Config) ->
|
|
create2(Config#{verify_claims => handle_verify_claims(VerifyClaims)}).
|
|
|
|
update(
|
|
#{use_jwks := false} = Config,
|
|
#{jwk_resource := ResourceId}
|
|
) ->
|
|
_ = emqx_resource:remove_local(ResourceId),
|
|
create(Config);
|
|
update(#{use_jwks := false} = Config, _State) ->
|
|
create(Config);
|
|
update(
|
|
#{use_jwks := true} = Config,
|
|
#{jwk_resource := ResourceId} = State
|
|
) ->
|
|
case emqx_resource:simple_sync_query(ResourceId, {update, connector_opts(Config)}) of
|
|
ok ->
|
|
case maps:get(verify_claims, Config, undefined) of
|
|
undefined ->
|
|
{ok, State};
|
|
VerifyClaims ->
|
|
{ok, State#{verify_claims => handle_verify_claims(VerifyClaims)}}
|
|
end;
|
|
{error, Reason} ->
|
|
?SLOG(error, #{
|
|
msg => "jwks_client_option_update_failed",
|
|
resource => ResourceId,
|
|
reason => Reason
|
|
})
|
|
end;
|
|
update(#{use_jwks := true} = Config, _State) ->
|
|
create(Config).
|
|
|
|
authenticate(#{auth_method := _}, _) ->
|
|
ignore;
|
|
authenticate(
|
|
Credential,
|
|
#{
|
|
verify_claims := VerifyClaims0,
|
|
jwk := JWK,
|
|
acl_claim_name := AclClaimName,
|
|
from := From
|
|
}
|
|
) ->
|
|
JWT = maps:get(From, Credential),
|
|
JWKs = [JWK],
|
|
VerifyClaims = render_expected(VerifyClaims0, Credential),
|
|
verify(JWT, JWKs, VerifyClaims, AclClaimName);
|
|
authenticate(
|
|
Credential,
|
|
#{
|
|
verify_claims := VerifyClaims0,
|
|
jwk_resource := ResourceId,
|
|
acl_claim_name := AclClaimName,
|
|
from := From
|
|
}
|
|
) ->
|
|
case emqx_resource:simple_sync_query(ResourceId, get_jwks) of
|
|
{error, Reason} ->
|
|
?TRACE_AUTHN_PROVIDER(error, "get_jwks_failed", #{
|
|
resource => ResourceId,
|
|
reason => Reason
|
|
}),
|
|
ignore;
|
|
{ok, JWKs} ->
|
|
JWT = maps:get(From, Credential),
|
|
VerifyClaims = render_expected(VerifyClaims0, Credential),
|
|
verify(JWT, JWKs, VerifyClaims, AclClaimName)
|
|
end.
|
|
|
|
destroy(#{jwk_resource := ResourceId}) ->
|
|
_ = emqx_resource:remove_local(ResourceId),
|
|
ok;
|
|
destroy(_) ->
|
|
ok.
|
|
|
|
%%--------------------------------------------------------------------
|
|
%% Internal functions
|
|
%%--------------------------------------------------------------------
|
|
|
|
create2(#{
|
|
use_jwks := false,
|
|
algorithm := 'hmac-based',
|
|
secret := Secret0,
|
|
secret_base64_encoded := Base64Encoded,
|
|
verify_claims := VerifyClaims,
|
|
acl_claim_name := AclClaimName,
|
|
from := From
|
|
}) ->
|
|
case may_decode_secret(Base64Encoded, Secret0) of
|
|
{error, Reason} ->
|
|
{error, Reason};
|
|
Secret ->
|
|
JWK = jose_jwk:from_oct(Secret),
|
|
{ok, #{
|
|
jwk => JWK,
|
|
verify_claims => VerifyClaims,
|
|
acl_claim_name => AclClaimName,
|
|
from => From
|
|
}}
|
|
end;
|
|
create2(#{
|
|
use_jwks := false,
|
|
algorithm := 'public-key',
|
|
public_key := PublicKey,
|
|
verify_claims := VerifyClaims,
|
|
acl_claim_name := AclClaimName,
|
|
from := From
|
|
}) ->
|
|
JWK = create_jwk_from_public_key(PublicKey),
|
|
{ok, #{
|
|
jwk => JWK,
|
|
verify_claims => VerifyClaims,
|
|
acl_claim_name => AclClaimName,
|
|
from => From
|
|
}};
|
|
create2(
|
|
#{
|
|
use_jwks := true,
|
|
verify_claims := VerifyClaims,
|
|
acl_claim_name := AclClaimName,
|
|
from := From
|
|
} = Config
|
|
) ->
|
|
ResourceId = emqx_authn_utils:make_resource_id(?MODULE),
|
|
{ok, _Data} = emqx_resource:create_local(
|
|
ResourceId,
|
|
?AUTHN_RESOURCE_GROUP,
|
|
emqx_authn_jwks_connector,
|
|
connector_opts(Config)
|
|
),
|
|
{ok, #{
|
|
jwk_resource => ResourceId,
|
|
verify_claims => VerifyClaims,
|
|
acl_claim_name => AclClaimName,
|
|
from => From
|
|
}}.
|
|
|
|
create_jwk_from_public_key(PublicKey) when
|
|
is_binary(PublicKey); is_list(PublicKey)
|
|
->
|
|
case filelib:is_file(PublicKey) of
|
|
true ->
|
|
jose_jwk:from_pem_file(PublicKey);
|
|
false ->
|
|
jose_jwk:from_pem(iolist_to_binary(PublicKey))
|
|
end.
|
|
|
|
connector_opts(#{ssl := #{enable := Enable} = SSL} = Config) ->
|
|
SSLOpts =
|
|
case Enable of
|
|
true -> maps:without([enable], SSL);
|
|
false -> #{}
|
|
end,
|
|
Config#{ssl_opts => SSLOpts}.
|
|
|
|
may_decode_secret(false, Secret) ->
|
|
Secret;
|
|
may_decode_secret(true, Secret) ->
|
|
try
|
|
base64:decode(Secret)
|
|
catch
|
|
error:_ ->
|
|
{error, {invalid_parameter, secret}}
|
|
end.
|
|
|
|
render_expected([], _Variables) ->
|
|
[];
|
|
render_expected([{Name, ExpectedTemplate} | More], Variables) ->
|
|
Expected = emqx_authn_utils:render_str(ExpectedTemplate, Variables),
|
|
[{Name, Expected} | render_expected(More, Variables)].
|
|
|
|
verify(undefined, _, _, _) ->
|
|
ignore;
|
|
verify(JWT, JWKs, VerifyClaims, AclClaimName) ->
|
|
case do_verify(JWT, JWKs, VerifyClaims) of
|
|
{ok, Extra} ->
|
|
IsSuperuser = emqx_authn_utils:is_superuser(Extra),
|
|
Attrs = emqx_authn_utils:client_attrs(Extra),
|
|
try
|
|
ACL = acl(Extra, AclClaimName),
|
|
Result = maps:merge(IsSuperuser, maps:merge(ACL, Attrs)),
|
|
{ok, Result}
|
|
catch
|
|
throw:{bad_acl_rule, Reason} ->
|
|
%% it's a invalid token, so ok to log
|
|
?TRACE_AUTHN_PROVIDER("bad_acl_rule", Reason#{jwt => JWT}),
|
|
{error, bad_username_or_password}
|
|
end;
|
|
{error, {missing_claim, Claim}} ->
|
|
%% it's a invalid token, so it's ok to log
|
|
?TRACE_AUTHN_PROVIDER("missing_jwt_claim", #{jwt => JWT, claim => Claim}),
|
|
{error, bad_username_or_password};
|
|
{error, invalid_signature} ->
|
|
%% it's a invalid token, so it's ok to log
|
|
?TRACE_AUTHN_PROVIDER("invalid_jwt_signature", #{jwks => JWKs, jwt => JWT}),
|
|
ignore;
|
|
{error, {claims, Claims}} ->
|
|
%% it's a invalid token, so it's ok to log
|
|
?TRACE_AUTHN_PROVIDER("invalid_jwt_claims", #{jwt => JWT, claims => Claims}),
|
|
{error, bad_username_or_password}
|
|
end.
|
|
|
|
acl(Claims, AclClaimName) ->
|
|
case Claims of
|
|
#{AclClaimName := Rules} ->
|
|
#{
|
|
acl => #{
|
|
rules => parse_rules(Rules),
|
|
source_for_logging => jwt,
|
|
expire => maps:get(<<"exp">>, Claims, undefined)
|
|
}
|
|
};
|
|
_ ->
|
|
#{}
|
|
end.
|
|
|
|
do_verify(_JWT, [], _VerifyClaims) ->
|
|
{error, invalid_signature};
|
|
do_verify(JWT, [JWK | More], VerifyClaims) ->
|
|
try jose_jws:verify(JWK, JWT) of
|
|
{true, Payload, _JWT} ->
|
|
Claims0 = emqx_utils_json:decode(Payload, [return_maps]),
|
|
Claims = try_convert_to_num(Claims0, [<<"exp">>, <<"iat">>, <<"nbf">>]),
|
|
case verify_claims(Claims, VerifyClaims) of
|
|
ok ->
|
|
{ok, Claims};
|
|
{error, Reason} ->
|
|
{error, Reason}
|
|
end;
|
|
{false, _, _} ->
|
|
do_verify(JWT, More, VerifyClaims)
|
|
catch
|
|
_:Reason ->
|
|
?TRACE_AUTHN_PROVIDER("jwt_verify_error", #{jwk => JWK, jwt => JWT, reason => Reason}),
|
|
do_verify(JWT, More, VerifyClaims)
|
|
end.
|
|
|
|
verify_claims(Claims, VerifyClaims0) ->
|
|
Now = erlang:system_time(seconds),
|
|
VerifyClaims =
|
|
[
|
|
{<<"exp">>, fun(ExpireTime) ->
|
|
is_number(ExpireTime) andalso Now < ExpireTime
|
|
end},
|
|
{<<"iat">>, fun(IssueAt) ->
|
|
is_number(IssueAt) andalso IssueAt =< Now
|
|
end},
|
|
{<<"nbf">>, fun(NotBefore) ->
|
|
is_number(NotBefore) andalso NotBefore =< Now
|
|
end}
|
|
] ++ VerifyClaims0,
|
|
do_verify_claims(Claims, VerifyClaims).
|
|
|
|
try_convert_to_num(Claims, [Name | Names]) ->
|
|
case Claims of
|
|
#{Name := Value} ->
|
|
case Value of
|
|
Int when is_number(Int) ->
|
|
try_convert_to_num(Claims#{Name => Int}, Names);
|
|
Bin when is_binary(Bin) ->
|
|
case binary_to_number(Bin) of
|
|
{ok, Num} ->
|
|
try_convert_to_num(Claims#{Name => Num}, Names);
|
|
_ ->
|
|
try_convert_to_num(Claims, Names)
|
|
end;
|
|
_ ->
|
|
try_convert_to_num(Claims, Names)
|
|
end;
|
|
_ ->
|
|
try_convert_to_num(Claims, Names)
|
|
end;
|
|
try_convert_to_num(Claims, []) ->
|
|
Claims.
|
|
|
|
do_verify_claims(_Claims, []) ->
|
|
ok;
|
|
do_verify_claims(Claims, [{Name, Fun} | More]) when is_function(Fun) ->
|
|
case maps:take(Name, Claims) of
|
|
error ->
|
|
do_verify_claims(Claims, More);
|
|
{Value, NClaims} ->
|
|
case Fun(Value) of
|
|
true ->
|
|
do_verify_claims(NClaims, More);
|
|
_ ->
|
|
{error, {claims, {Name, Value}}}
|
|
end
|
|
end;
|
|
do_verify_claims(Claims, [{Name, Value} | More]) ->
|
|
case maps:take(Name, Claims) of
|
|
error ->
|
|
{error, {missing_claim, Name}};
|
|
{Value, NClaims} ->
|
|
do_verify_claims(NClaims, More);
|
|
{Value0, _} ->
|
|
{error, {claims, {Name, Value0}}}
|
|
end.
|
|
|
|
handle_verify_claims(VerifyClaims) ->
|
|
handle_verify_claims(VerifyClaims, []).
|
|
|
|
handle_verify_claims([], Acc) ->
|
|
Acc;
|
|
handle_verify_claims([{Name, Expected0} | More], Acc) ->
|
|
Expected1 = emqx_authn_utils:parse_str(Expected0, ?ALLOWED_VARS),
|
|
handle_verify_claims(More, [{Name, Expected1} | Acc]).
|
|
|
|
binary_to_number(Bin) ->
|
|
case string:to_integer(Bin) of
|
|
{Val, <<>>} ->
|
|
{ok, Val};
|
|
_ ->
|
|
case string:to_float(Bin) of
|
|
{Val, <<>>} -> {ok, Val};
|
|
_ -> false
|
|
end
|
|
end.
|
|
|
|
%% Parse rules which can be in two different formats:
|
|
%% 1. #{<<"pub">> => [<<"a/b">>, <<"c/d">>], <<"sub">> => [...], <<"all">> => [...]}
|
|
%% 2. [#{<<"permission">> => <<"allow">>, <<"action">> => <<"publish">>, <<"topic">> => <<"a/b">>}, ...]
|
|
parse_rules(Rules) when is_map(Rules) ->
|
|
Rules;
|
|
parse_rules(Rules) when is_list(Rules) ->
|
|
lists:map(fun parse_rule/1, Rules).
|
|
|
|
parse_rule(Rule) ->
|
|
case emqx_authz_rule_raw:parse_rule(Rule) of
|
|
{ok, {Permission, Action, Topics}} ->
|
|
try
|
|
emqx_authz_rule:compile({Permission, all, Action, Topics})
|
|
catch
|
|
throw:Reason ->
|
|
throw({bad_acl_rule, Reason})
|
|
end;
|
|
{error, Reason} ->
|
|
throw({bad_acl_rule, Reason})
|
|
end.
|