Universal auth control (#1)

This commit is contained in:
Alexey 2019-02-28 15:05:14 +03:00 committed by GitHub
parent 0b8044f19a
commit 18c76d54ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1605 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# general
log
/_build/
*~
erl_crash.dump
.tags*
*.sublime-*
.DS_Store
# rebar
/_checkouts/
*.beam
# generated
apps/swag_server/*
apps/swag_client/*
# containerization
\#*
.\#*
Dockerfile
docker-compose.yml

4
.gitmodules vendored Normal file
View File

@ -0,0 +1,4 @@
[submodule "build_utils"]
path = build_utils
url = git@github.com:rbkmoney/build_utils.git
branch = master

45
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,45 @@
#!groovy
// -*- mode: groovy -*-
def finalHook = {
runStage('store CT logs') {
archive '_build/test/logs/'
}
}
build('erlang_uac', 'docker-host', finalHook) {
checkoutRepo()
loadBuildUtils()
def pipeDefault
def withWsCache
runStage('load pipeline') {
env.JENKINS_LIB = "build_utils/jenkins_lib"
pipeDefault = load("${env.JENKINS_LIB}/pipeDefault.groovy")
withWsCache = load("${env.JENKINS_LIB}/withWsCache.groovy")
}
pipeDefault() {
runStage('compile') {
withGithubPrivkey {
sh 'make wc_compile'
}
}
runStage('lint') {
sh 'make wc_lint'
}
runStage('xref') {
sh 'make wc_xref'
}
runStage('dialyze') {
withWsCache("_build/default/rebar3_19.3_plt") {
sh 'make wc_dialyze'
}
}
runStage('test') {
sh "make wc_test"
}
}
}

60
Makefile Normal file
View File

@ -0,0 +1,60 @@
REBAR := $(shell which rebar3 2>/dev/null || which ./rebar3)
SUBMODULES = build_utils
SUBTARGETS = $(patsubst %,%/.git,$(SUBMODULES))
UTILS_PATH := build_utils
TEMPLATES_PATH := .
# Name of the service
SERVICE_NAME := erlang_uac
BUILD_IMAGE_TAG := 562313697353c29d4b34fb081a8b70e8c2207134
CALL_ANYWHERE := \
submodules \
all compile xref lint dialyze test cover \
start clean distclean
CALL_W_CONTAINER := $(CALL_ANYWHERE)
.PHONY: $(CALL_W_CONTAINER) all
all: compile
-include $(UTILS_PATH)/make_lib/utils_container.mk
$(SUBTARGETS): %/.git: %
git submodule update --init $<
touch $@
submodules: $(SUBTARGETS)
compile:
$(REBAR) compile
xref:
$(REBAR) xref
lint:
elvis rock
dialyze:
$(REBAR) dialyzer
start: submodules
$(REBAR) run
clean:
$(REBAR) cover -r
$(REBAR) clean
distclean:
$(REBAR) clean
rm -rf _build
cover:
$(REBAR) cover
# CALL_W_CONTAINER
test:
$(REBAR) ct

View File

@ -0,0 +1,3 @@
# Erlang UAC
Вспомогательное приложение для авторизации и работы с jwt-токенами на capi/wapi

1
build_utils Submodule

@ -0,0 +1 @@
Subproject commit 39039dfc60249c8631b996cf60d4f8c4d64c30cd

57
elvis.config Normal file
View File

@ -0,0 +1,57 @@
[
{elvis, [
{config, [
#{
dirs => [
"src",
"test"
],
filter => "*.erl",
ignore => ["_SUITE.erl$"],
rules => [
{elvis_style, line_length, #{limit => 120, skip_comments => false}},
{elvis_style, no_tabs},
{elvis_style, no_trailing_whitespace},
{elvis_style, macro_module_names},
{elvis_style, operator_spaces, #{rules => [{right, ","}, {right, "++"}, {left, "++"}]}},
{elvis_style, nesting_level, #{level => 4}},
{elvis_style, god_modules, #{limit => 25}},
{elvis_style, no_if_expression},
{elvis_style, invalid_dynamic_call, #{ignore => [capi_swagger_server]}},
{elvis_style, used_ignored_variable},
{elvis_style, no_behavior_info},
{elvis_style, module_naming_convention, #{regex => "^[a-z]([a-z0-9]*_?)*(_SUITE)?$"}},
{elvis_style, function_naming_convention, #{regex => "^[a-z]([a-z0-9]*_?)*$"}},
{elvis_style, state_record_and_type},
{elvis_style, no_spec_with_records},
{elvis_style, dont_repeat_yourself, #{
min_complexity => 30,
ignore => [
capi_tests_SUITE
]
}},
{elvis_style, no_debug_call, #{}}
]
},
#{
dirs => ["."],
filter => "Makefile",
ruleset => makefiles
},
#{
dirs => ["."],
filter => "elvis.config",
ruleset => elvis_config
},
#{
dirs => ["."],
filter => "rebar.config",
rules => [
{elvis_style, line_length, #{limit => 120, skip_comments => false}},
{elvis_style, no_tabs},
{elvis_style, no_trailing_whitespace}
]
}
]}
]}
].

75
rebar.config Normal file
View File

@ -0,0 +1,75 @@
%% Common project erlang options.
{erl_opts, [
% mandatory
debug_info,
warnings_as_errors,
warn_export_all,
warn_missing_spec,
warn_untyped_record,
warn_export_vars,
% by default
warn_unused_record,
warn_bif_clash,
warn_obsolete_guard,
warn_unused_vars,
warn_shadow_vars,
warn_unused_import,
warn_unused_function,
warn_deprecated_function
% at will
% bin_opt_info
% no_auto_import
% warn_missing_spec_all
]}.
%% Common project dependencies.
{deps, [
{rfc3339, "0.2.2"},
{jsx, "2.8.2"},
{jose, "1.7.9"},
{base64url, "0.0.1"},
{genlib,
{git, "https://github.com/rbkmoney/genlib.git",
{branch, "master"}
}
}
]}.
%% XRef checks
{xref_checks, [
undefined_function_calls,
undefined_functions,
deprecated_functions_calls,
deprecated_functions
]}.
% at will
% {xref_warnings, true}.
%% Tests
{cover_enabled, true}.
{dialyzer, [
{warnings, [
% mandatory
unmatched_returns,
error_handling,
race_conditions,
unknown
]},
{plt_apps, all_deps}
]}.
{profiles, [
{test, [
{deps, [
{snowflake,
{git, "https://github.com/rbkmoney/snowflake.git",
{branch, "master"}
}
}
]}
]}
]}.

16
rebar.lock Normal file
View File

@ -0,0 +1,16 @@
{"1.1.0",
[{<<"base64url">>,{pkg,<<"base64url">>,<<"0.0.1">>},0},
{<<"genlib">>,
{git,"https://github.com/rbkmoney/genlib.git",
{ref,"41920d7774d119c294f3aaba4043ced12da2a815"}},
0},
{<<"jose">>,{pkg,<<"jose">>,<<"1.7.9">>},0},
{<<"jsx">>,{pkg,<<"jsx">>,<<"2.8.2">>},0},
{<<"rfc3339">>,{pkg,<<"rfc3339">>,<<"0.2.2">>},0}]}.
[
{pkg_hash,[
{<<"base64url">>, <<"36A90125F5948E3AFD7BE97662A1504B934DD5DAC78451CA6E9ABF85A10286BE">>},
{<<"jose">>, <<"9DC5A14AB62DB4E41677FCC97993752562FB57AD0B8BA062589682EDD3ACB91F">>},
{<<"jsx">>, <<"7ACC7D785B5ABE8A6E9ADBDE926A24E481F29956DD8B4DF49E3E4E7BCC92A018">>},
{<<"rfc3339">>, <<"1552DF616ACA368D982E9F085A0E933B6688A3F4938A671798978EC2C0C58730">>}]}
].

23
src/uac.app.src Normal file
View File

@ -0,0 +1,23 @@
{application, uac, [
{description, "Universal authorizer"},
{vsn, "0.1.0"},
{registered, []},
{mod, {uac, []}},
{applications, [
kernel,
stdlib,
public_key,
genlib,
jose,
rfc3339,
base64url,
jsx
]},
{env, []},
{modules, []},
{maintainers, [
""
]},
{licenses, []},
{links, []}
]}.

129
src/uac.erl Normal file
View File

@ -0,0 +1,129 @@
-module(uac).
%% App
-behaviour(application).
-export([start/2, stop/1]).
%% Supervisor
-behaviour(supervisor).
-export([init/1]).
%%API
-export([configure/1]).
-export([authorize_api_key/2]).
-export([authorize_operation/2]).
-type context() :: uac_authorizer_jwt:t().
-type claims() :: uac_authorizer_jwt:claims().
-type configuration() :: #{
jwt := uac_authorizer_jwt:options(),
access := uac_conf:options()
}.
-type verification_opts() :: #{
check_expired_as_of => genlib_time:ts()
}.
-type api_key() :: binary().
-export_type([context/0]).
-export_type([claims/0]).
-export_type([verification_opts/0]).
%%
% API
%%
-spec configure(configuration()) ->
ok.
configure(Config) ->
AuthorizerConfig = maps:get(jwt, Config),
AccessConfig = maps:get(access, Config),
ok = uac_authorizer_jwt:configure(AuthorizerConfig),
ok = uac_conf:configure(AccessConfig).
-spec authorize_api_key(
ApiKey :: api_key(),
VerificationOpts :: verification_opts()
) -> {ok, Context :: context()} | {error, Reason :: atom()}.
authorize_api_key(ApiKey, VerificationOpts) ->
case parse_api_key(ApiKey) of
{ok, {Type, Credentials}} ->
authorize_api_key(Type, Credentials, VerificationOpts);
{error, Error} ->
{error, Error}
end.
-spec parse_api_key(ApiKey :: api_key()) ->
{ok, {bearer, Credentials :: binary()}} | {error, Reason :: atom()}.
parse_api_key(ApiKey) ->
case ApiKey of
<<"Bearer ", Credentials/binary>> ->
{ok, {bearer, Credentials}};
_ ->
{error, unsupported_auth_scheme}
end.
-spec authorize_api_key(
Type :: atom(),
Credentials :: binary(),
VerificationOpts :: verification_opts()
) ->
{ok, Context :: context()} | {error, Reason :: atom()}.
authorize_api_key(bearer, Token, VerificationOpts) ->
uac_authorizer_jwt:verify(Token, VerificationOpts).
%%
-spec authorize_operation(
AccessScope :: uac_conf:operation_access_scopes(),
Auth :: uac_authorizer_jwt:t()
) ->
ok | {error, unauthorized}.
authorize_operation(AccessScope, {_, {_SubjectID, ACL}, _}) ->
case lists:all(
fun ({Scope, Permission}) ->
lists:member(Permission, uac_acl:match(Scope, ACL))
end,
AccessScope
) of
true ->
ok;
false ->
{error, unauthorized}
end.
%%
% App
%%
-spec start(any(), any()) ->
{ok, pid()} | {error, Reason :: term()}.
start(_StartType, _StartArgs) ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
-spec stop(any()) ->
ok.
stop(_State) ->
ok.
%%
% Supervisor
%%
-spec init([]) ->
{ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
init([]) ->
AuthorizerSpec = uac_authorizer_jwt:get_child_spec(),
AccessSpec = uac_conf:get_child_spec(),
SupFlags = #{},
Children = AuthorizerSpec ++ AccessSpec,
{ok, {SupFlags, Children}}.

242
src/uac_acl.erl Normal file
View File

@ -0,0 +1,242 @@
-module(uac_acl).
%%
-opaque t() :: [{{priority(), scope()}, [permission()]}].
-type priority() :: integer().
-type scope() :: [resource() | {resource(), resource_id()}, ...].
-type resource() :: atom().
-type resource_id() :: binary().
-type permission() :: read | write.
-export_type([t/0]).
-export_type([scope/0]).
-export_type([resource/0]).
-export_type([permission/0]).
-export([new/0]).
-export([to_list/1]).
-export([from_list/1]).
-export([insert_scope/3]).
-export([remove_scope/3]).
-export([match/2]).
-export([decode/1]).
-export([encode/1]).
%%
-spec new() ->
t().
new() ->
[].
-spec to_list(t()) ->
[{scope(), permission()}].
to_list(ACL) ->
[{S, P} || {{_, S}, P} <- ACL].
-spec from_list([{scope(), permission()}]) ->
t().
from_list(L) ->
lists:foldl(fun ({S, P}, ACL) -> insert_scope(S, P, ACL) end, new(), L).
-spec insert_scope(scope(), permission(), t()) ->
t().
insert_scope(Scope, Permission, ACL) ->
Priority = compute_priority(Scope, Permission),
insert({{Priority, Scope}, [Permission]}, ACL).
insert({PS, _} = V, [{PS0, _} = V0 | Vs]) when PS < PS0 ->
[V0 | insert(V, Vs)];
insert({PS, Perms}, [{PS, Perms0} | Vs]) ->
% NOTE squashing permissions of entries with the same scope
[{PS, lists:usort(Perms ++ Perms0)} | Vs];
insert({PS, _} = V, [{PS0, _} | _] = Vs) when PS > PS0 ->
[V | Vs];
insert(V, []) ->
[V].
-spec remove_scope(scope(), permission(), t()) ->
t().
remove_scope(Scope, Permission, ACL) ->
Priority = compute_priority(Scope, Permission),
remove({{Priority, Scope}, [Permission]}, ACL).
remove(V, [V | Vs]) ->
Vs;
remove({PS, Perms}, [{PS, Perms0} | Vs]) ->
[{PS, Perms0 -- Perms} | Vs];
remove(V, [V0 | Vs]) ->
[V0 | remove(V, Vs)];
remove(_, []) ->
[].
compute_priority(Scope, Permission) ->
% NOTE
% Scope priority depends on the following attributes, in the order of decreasing
% importance:
% 1. Depth, deeper is more important
% 2. Scope element specificity, element marked with an ID is more important
compute_scope_priority(Scope) + compute_permission_priority(Permission).
compute_scope_priority(Scope) when length(Scope) > 0 ->
compute_scope_priority(Scope, get_resource_hierarchy(), 0);
compute_scope_priority(Scope) ->
error({badarg, {scope, Scope}}).
compute_scope_priority([{Resource, _ID} | Rest], H, P) ->
compute_scope_priority(Rest, delve(Resource, H), P * 10 + 2);
compute_scope_priority([Resource | Rest], H, P) ->
compute_scope_priority(Rest, delve(Resource, H), P * 10 + 1);
compute_scope_priority([], _, P) ->
P * 10.
compute_permission_priority(read) ->
0;
compute_permission_priority(write) ->
0;
compute_permission_priority(V) ->
error({badarg, {permission, V}}).
%%
-spec match(scope(), t()) ->
[permission()].
match(Scope, ACL) when length(Scope) > 0 ->
match_rules(Scope, ACL);
match(Scope, _) ->
error({badarg, {scope, Scope}}).
match_rules(Scope, [{{_Priority, ScopePrefix}, Permissions} | Rest]) ->
% NOTE
% The `Scope` matches iff `ScopePrefix` is scope prefix of the `Scope`.
% An element of a scope matches corresponding element of a scope prefix
% according to the following rules:
% 1. Scope prefix element marked with resource and ID matches exactly the same
% scope element.
% 2. Scope prefix element marked with only resource matches any scope element
% marked with the same resource.
case match_scope(Scope, ScopePrefix) of
true ->
Permissions;
false ->
match_rules(Scope, Rest)
end;
match_rules(_Scope, []) ->
[].
match_scope([V | Ss], [V | Ss0]) ->
match_scope(Ss, Ss0);
match_scope([{V, _ID} | Ss], [V | Ss0]) ->
match_scope(Ss, Ss0);
match_scope(_, []) ->
true;
match_scope(_, _) ->
false.
%%
-spec decode([binary()]) ->
t().
decode(V) ->
lists:foldl(fun decode_entry/2, new(), V).
decode_entry(V, ACL) ->
case binary:split(V, <<":">>, [global]) of
[V1, V2] ->
Scope = decode_scope(V1),
Permission = decode_permission(V2),
insert_scope(Scope, Permission, ACL);
_ ->
error({badarg, {role, V}})
end.
decode_scope(V) ->
Hierarchy = get_resource_hierarchy(),
decode_scope_frags(binary:split(V, <<".">>, [global]), Hierarchy).
decode_scope_frags([V1, V2 | Vs], H) ->
{Resource, H1} = decode_scope_frag_resource(V1, V2, H),
[Resource | decode_scope_frags(Vs, H1)];
decode_scope_frags([V], H) ->
decode_scope_frags([V, <<"*">>], H);
decode_scope_frags([], _) ->
[].
decode_scope_frag_resource(V, <<"*">>, H) ->
R = decode_resource(V),
{R, delve(R, H)};
decode_scope_frag_resource(V, ID, H) ->
R = decode_resource(V),
{{R, ID}, delve(R, H)}.
decode_resource(V) ->
try binary_to_existing_atom(V, utf8) catch
error:badarg ->
error({badarg, {resource, V}})
end.
decode_permission(<<"read">>) ->
read;
decode_permission(<<"write">>) ->
write;
decode_permission(V) ->
error({badarg, {permission, V}}).
%%
-spec encode(t()) ->
[binary()].
encode(ACL) ->
lists:flatmap(fun encode_entry/1, ACL).
encode_entry({{_Priority, Scope}, Permissions}) ->
S = encode_scope(Scope),
[begin P = encode_permission(Permission), <<S/binary, ":", P/binary>> end
|| Permission <- Permissions].
encode_scope(Scope) ->
Hierarchy = get_resource_hierarchy(),
genlib_string:join($., encode_scope_frags(Scope, Hierarchy)).
encode_scope_frags([{Resource, ID} | Rest], H) ->
[encode_resource(Resource), ID | encode_scope_frags(Rest, delve(Resource, H))];
encode_scope_frags([Resource], H) ->
_ = delve(Resource, H),
[encode_resource(Resource)];
encode_scope_frags([Resource | Rest], H) ->
[encode_resource(Resource), <<"*">> | encode_scope_frags(Rest, delve(Resource, H))];
encode_scope_frags([], _) ->
[].
encode_resource(V) ->
atom_to_binary(V, utf8).
encode_permission(read) ->
<<"read">>;
encode_permission(write) ->
<<"write">>.
%%
get_resource_hierarchy() ->
uac_conf:get_resource_hierarchy().
delve(Resource, Hierarchy) ->
case maps:find(Resource, Hierarchy) of
{ok, Sub} ->
Sub;
error ->
error({badarg, {resource, Resource}})
end.

388
src/uac_authorizer_jwt.erl Normal file
View File

@ -0,0 +1,388 @@
-module(uac_authorizer_jwt).
%%
-export([init/1]).
-export([get_child_spec/0]).
% TODO
% Extend interface to support proper keystore manipulation
-export([configure/1]).
-export([issue/4]).
-export([verify/2]).
%%
-include_lib("jose/include/jose_jwk.hrl").
-include_lib("jose/include/jose_jwt.hrl").
-type keyname() :: term().
-type kid() :: binary().
-type key() :: #jose_jwk{}.
-type token() :: binary().
-type claims() :: #{binary() => term()}.
-type subject() :: {subject_id(), uac_acl:t()}.
-type subject_id() :: binary().
-type t() :: {id(), subject(), claims()}.
-type expiration() ::
{lifetime, Seconds :: pos_integer()} |
{deadline, UnixTs :: pos_integer()} |
unlimited.
-type id() :: binary().
-export_type([t/0]).
-export_type([subject/0]).
-export_type([claims/0]).
-export_type([token/0]).
-export_type([expiration/0]).
%%
-type options() :: #{
%% The set of keys used to sign issued tokens and verify signatures on such
%% tokens.
keyset => keyset()
}.
-export_type([options/0]).
-type keyset() :: #{
keyname() => keysource()
}.
-type keysource() ::
{pem_file, file:filename()}.
-spec get_child_spec() ->
[supervisor:child_spec()].
get_child_spec() ->
[#{
id => ?MODULE,
start => {supervisor, start_link, [?MODULE, []]},
type => supervisor
}].
-spec init([]) ->
{ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
init([]) ->
ok = create_table(),
{ok, {#{}, []}}.
%%
-spec configure(options()) ->
ok.
configure(Options) ->
Keyset = parse_options(Options),
_ = maps:map(fun ensure_store_key/2, Keyset),
ok.
parse_options(Options) ->
Keyset = maps:get(keyset, Options, #{}),
_ = is_map(Keyset) orelse exit({invalid_option, keyset, Keyset}),
_ = genlib_map:foreach(
fun (K, V) ->
is_keysource(V) orelse exit({invalid_option, K, V})
end,
Keyset
),
Keyset.
is_keysource({pem_file, Fn}) ->
is_list(Fn) orelse is_binary(Fn);
is_keysource(_) ->
false.
ensure_store_key(Keyname, Source) ->
case store_key(Keyname, Source) of
ok ->
ok;
{error, Reason} ->
exit({import_error, Keyname, Source, Reason})
end.
%%
-spec store_key(keyname(), {pem_file, file:filename()}) ->
ok | {error, file:posix() | {unknown_key, _}}.
store_key(Keyname, {pem_file, Filename}) ->
store_key(Keyname, {pem_file, Filename}, #{
kid => fun derive_kid_from_public_key_pem_entry/1
}).
derive_kid_from_public_key_pem_entry(JWK) ->
JWKPublic = jose_jwk:to_public(JWK),
{_Module, PublicKey} = JWKPublic#jose_jwk.kty,
{_PemEntry, Data, _} = public_key:pem_entry_encode('SubjectPublicKeyInfo', PublicKey),
base64url:encode(crypto:hash(sha256, Data)).
-type store_opts() :: #{
kid => fun ((key()) -> kid())
}.
-spec store_key(keyname(), {pem_file, file:filename()}, store_opts()) ->
ok | {error, file:posix() | {unknown_key, _}}.
store_key(Keyname, {pem_file, Filename}, Opts) ->
case jose_jwk:from_pem_file(Filename) of
JWK = #jose_jwk{} ->
Key = construct_key(derive_kid(JWK, Opts), JWK),
ok = insert_key(Keyname, Key);
Error = {error, _} ->
Error
end.
derive_kid(JWK, #{kid := DeriveFun}) when is_function(DeriveFun, 1) ->
DeriveFun(JWK).
construct_key(KID, JWK) ->
Signer = try jose_jwk:signer(JWK) catch error:_ -> undefined end,
Verifier = try jose_jwk:verifier(JWK) catch error:_ -> undefined end,
#{
jwk => JWK,
kid => KID,
signer => Signer,
can_sign => Signer /= undefined,
verifier => Verifier,
can_verify => Verifier /= undefined
}.
%%
-spec issue(id(), expiration(), t(), keyname()) ->
{ok, token()} |
{error, nonexistent_key} |
{error, {invalid_signee, Reason :: atom()}}.
issue(JTI, Expiration, Auth, Signee) ->
case try_get_key_for_sign(Signee) of
{ok, Key} ->
Claims = construct_final_claims(Auth, Expiration, JTI),
sign(Key, Claims);
{error, Error} ->
{error, Error}
end.
try_get_key_for_sign(Keyname) ->
case get_key_by_name(Keyname) of
#{can_sign := true} = Key ->
{ok, Key};
#{} ->
{error, {invalid_signee, signing_not_allowed}};
undefined ->
{error, nonexistent_key}
end.
construct_final_claims({{Subject, ACL}, Claims}, Expiration, JTI) ->
maps:merge(
Claims#{
<<"jti">> => JTI,
<<"sub">> => Subject,
<<"exp">> => get_expires_at(Expiration)
},
encode_roles(uac_acl:encode(ACL))
).
get_expires_at({lifetime, Lt}) ->
genlib_time:unow() + Lt;
get_expires_at({deadline, Dl}) ->
Dl;
get_expires_at(unlimited) ->
0.
sign(#{kid := KID, jwk := JWK, signer := #{} = JWS}, Claims) ->
JWT = jose_jwt:sign(JWK, JWS#{<<"kid">> => KID}, Claims),
{_Modules, Token} = jose_jws:compact(JWT),
{ok, Token}.
%%
-spec verify(token(), uac:verification_opts()) ->
{ok, t()} |
{error,
{invalid_token,
badarg |
{badarg, term()} |
{missing, atom()} |
expired |
{malformed_acl, term()}
} |
{nonexistent_key, kid()} |
invalid_operation |
invalid_signature
}.
verify(Token, VerificationOpts) ->
try
{_, ExpandedToken} = jose_jws:expand(Token),
#{<<"protected">> := ProtectedHeader} = ExpandedToken,
Header = base64url_to_map(ProtectedHeader),
Alg = get_alg(Header),
KID = get_kid(Header),
verify(KID, Alg, ExpandedToken, VerificationOpts)
catch
%% from get_alg and get_kid
throw:Reason ->
{error, Reason};
%% TODO we're losing error information here, e.g. stacktrace
error:badarg = Reason ->
{error, {invalid_token, Reason}};
error:{badarg, _} = Reason ->
{error, {invalid_token, Reason}};
error:Reason ->
{error, {invalid_token, Reason}}
end.
verify(KID, Alg, ExpandedToken, VerificationOpts) ->
case get_key_by_kid(KID) of
#{jwk := JWK, verifier := Algs} ->
_ = lists:member(Alg, Algs) orelse throw(invalid_operation),
verify(JWK, ExpandedToken, VerificationOpts);
undefined ->
{error, {nonexistent_key, KID}}
end.
verify(JWK, ExpandedToken, VerificationOpts) ->
case jose_jwt:verify(JWK, ExpandedToken) of
{true, #jose_jwt{fields = Claims}, _JWS} ->
{KeyMeta, Claims1} = validate_claims(Claims, VerificationOpts),
get_result(KeyMeta, decode_roles(Claims1));
{false, _JWT, _JWS} ->
{error, invalid_signature}
end.
validate_claims(Claims, VerificationOpts) ->
validate_claims(Claims, get_validators(), VerificationOpts, #{}).
validate_claims(Claims, [{Name, Claim, Validator} | Rest], VerificationOpts, Acc) ->
V = Validator(Name, maps:get(Claim, Claims, undefined), VerificationOpts),
validate_claims(maps:without([Claim], Claims), Rest, VerificationOpts, Acc#{Name => V});
validate_claims(Claims, [], _, Acc) ->
{Acc, Claims}.
get_result(KeyMeta, {Roles, Claims}) ->
#{token_id := TokenID, subject_id := SubjectID} = KeyMeta,
try
Subject = {SubjectID, uac_acl:decode(Roles)},
{ok, {TokenID, Subject, Claims}}
catch
error:{badarg, _} = Reason ->
throw({invalid_token, {malformed_acl, Reason}})
end.
get_kid(#{<<"kid">> := KID}) when is_binary(KID) ->
KID;
get_kid(#{}) ->
throw({invalid_token, {missing, kid}}).
get_alg(#{<<"alg">> := Alg}) when is_binary(Alg) ->
Alg;
get_alg(#{}) ->
throw({invalid_token, {missing, alg}}).
%%
get_validators() ->
[
{token_id , <<"jti">> , fun check_presence/3},
{subject_id , <<"sub">> , fun check_presence/3},
{expires_at , <<"exp">> , fun check_expiration/3}
].
check_presence(_, V, _) when is_binary(V) ->
V;
check_presence(C, undefined, _) ->
throw({invalid_token, {missing, C}}).
check_expiration(_, Exp = 0, _) ->
Exp;
check_expiration(_, Exp, Opts) when is_integer(Exp) ->
case get_check_expiry(Opts) of
{true, Now} when Exp > Now ->
Exp;
false when Exp > 0 ->
Exp;
_ ->
throw({invalid_token, expired})
end;
check_expiration(C, undefined, _) ->
throw({invalid_token, {missing, C}});
check_expiration(C, V, _) ->
throw({invalid_token, {badarg, {C, V}}}).
get_check_expiry(Opts) ->
case maps:get(check_expired_as_of, Opts, undefined) of
Now when is_integer(Now) ->
{true, Now};
undefined ->
false
end.
%%
encode_roles(Roles) ->
IssuerResource = uac_conf:get_service_name(),
#{
<<"resource_access">> => #{
IssuerResource => #{
<<"roles">> => Roles
}
}
}.
decode_roles(Claims = #{
<<"resource_access">> := Resources
}) when is_map(Resources) andalso map_size(Resources) > 0 ->
Accepted = uac_conf:get_service_name(),
Roles = try_get_roles(Resources, Accepted),
{Roles, maps:remove(<<"resource_access">>, Claims)};
decode_roles(_) ->
throw({invalid_token, {missing, acl}}).
try_get_roles(Resources, Accepted) ->
case maps:get(Accepted, Resources, undefined) of
#{<<"roles">> := Roles} ->
Roles;
undefined ->
[]
end.
%%
insert_key(Keyname, KeyInfo = #{kid := KID}) ->
insert_values(#{
{keyname, Keyname} => KeyInfo,
{kid, KID} => KeyInfo
}).
get_key_by_name(Keyname) ->
lookup_value({keyname, Keyname}).
get_key_by_kid(KID) ->
lookup_value({kid, KID}).
base64url_to_map(Base64) when is_binary(Base64) ->
jsx:decode(base64url:decode(Base64), [return_maps]).
%%
-define(TABLE, ?MODULE).
create_table() ->
_ = ets:new(?TABLE, [set, public, named_table, {read_concurrency, true}]),
ok.
insert_values(Values) ->
true = ets:insert(?TABLE, maps:to_list(Values)),
ok.
lookup_value(Key) ->
case ets:lookup(?TABLE, Key) of
[{Key, Value}] ->
Value;
[] ->
undefined
end.

86
src/uac_conf.erl Normal file
View File

@ -0,0 +1,86 @@
-module(uac_conf).
%%
-export([get_child_spec/0]).
-export([init/1]).
%% API
-export([configure/1]).
-export([get_service_name/0]).
-export([get_resource_hierarchy/0]).
-type operation_access_scopes() :: [{uac_acl:scope(), uac_acl:permission()}].
-type service_name() :: binary().
-type resource_hierarchy() :: #{uac_acl:resource() => resource_hierarchy() | #{}}.
-type options() :: #{
service_name := service_name(),
resource_hierarchy := resource_hierarchy()
}.
-export_type([options/0]).
-export_type([operation_access_scopes/0]).
%%
-spec get_child_spec() ->
[supervisor:child_spec()].
get_child_spec() ->
[#{
id => ?MODULE,
start => {supervisor, start_link, [?MODULE, []]},
type => supervisor
}].
-spec init([]) ->
{ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
init([]) ->
ok = create_table(),
{ok, {#{}, []}}.
%%
%% API
%%
-spec get_service_name() ->
service_name().
get_service_name() ->
lookup_value(service_name).
-spec get_resource_hierarchy() ->
resource_hierarchy().
get_resource_hierarchy() ->
lookup_value(resource_hierarchy).
%%
-spec configure(options()) ->
ok.
configure(Config) ->
ok = insert_values(Config).
%%
-define(TABLE, ?MODULE).
create_table() ->
_ = ets:new(?TABLE, [set, public, named_table, {read_concurrency, true}]),
ok.
insert_values(Values) ->
true = ets:insert(?TABLE, maps:to_list(Values)),
ok.
lookup_value(Key) ->
lookup_value(Key, undefined).
lookup_value(Key, Default) ->
case ets:lookup(?TABLE, Key) of
[{Key, Value}] ->
Value;
[] ->
Default
end.

181
test/uac_acl_SUITE.erl Normal file
View File

@ -0,0 +1,181 @@
-module(uac_acl_SUITE).
-include_lib("stdlib/include/assert.hrl").
-include_lib("common_test/include/ct.hrl").
-export([all/0]).
-export([init_per_suite/1]).
-export([end_per_suite/1]).
-export([init_per_group/2]).
-export([end_per_group/2]).
-export([init_per_testcase/2]).
-export([end_per_testcase/2]).
-export([
illegal_input_test/1,
empty_test/1,
stable_encoding_test/1,
remove_scopes_test/1,
redundancy_test/1,
match_scope_test/1
]).
-spec illegal_input_test(config()) -> _.
-spec empty_test(config()) -> _.
-spec stable_encoding_test(config()) -> _.
-spec remove_scopes_test(config()) -> _.
-spec redundancy_test(config()) -> _.
-spec match_scope_test(config()) -> _.
-type test_case_name() :: atom().
-type config() :: [{atom(), any()}].
-type group_name() :: atom().
-spec all() ->
[test_case_name()].
all() ->
[
illegal_input_test,
empty_test,
stable_encoding_test,
remove_scopes_test,
redundancy_test,
match_scope_test
].
-spec init_per_suite(config()) ->
config().
init_per_suite(Config) ->
Apps = genlib_app:start_application(uac),
uac:configure(#{
jwt => #{
keyset => #{
test => {pem_file, get_keysource("keys/local/private.pem", Config)}
}
},
access => #{
service_name => <<"test">>,
resource_hierarchy => #{
party => #{invoice_templates => #{invoice_template_invoices => #{}}},
customers => #{bindings => #{}},
invoices => #{payments => #{}},
payment_resources => #{}
}
}
}),
[{apps, Apps}] ++ Config.
-spec init_per_group(group_name(), config()) ->
config().
init_per_group(_Name, Config) ->
Config.
-spec init_per_testcase(group_name(), config()) ->
config().
init_per_testcase(_Name, Config) ->
Config.
-spec end_per_suite(config()) ->
config().
end_per_suite(Config) ->
Config.
-spec end_per_group(group_name(), config()) ->
config().
end_per_group(_Name, Config) ->
Config.
-spec end_per_testcase(test_case_name(), config()) ->
config().
end_per_testcase(_Name, Config) ->
Config.
illegal_input_test(_C) ->
?assertError({badarg, {scope , _}}, from_list([{[], read}])),
?assertError({badarg, {permission, _}}, from_list([{[invoices], wread}])),
?assertError({badarg, {resource , _}}, from_list([{[payments], read}])).
empty_test(_C) ->
[] = encode(from_list([])),
[] = to_list(decode([])).
stable_encoding_test(_C) ->
ACL1 = from_list([
{[party], read},
{[party], write},
{[invoices], read},
{[invoices, payments], read},
{[{invoices, <<"42">>}, payments], write}
]),
Enc1 = [
<<"invoices.42.payments:write">>,
<<"invoices.*.payments:read">>,
<<"party:read">>,
<<"party:write">>,
<<"invoices:read">>
],
Enc1 = encode(ACL1),
ACL1 = decode(Enc1),
ACL1 = decode(encode(ACL1)).
redundancy_test(_C) ->
[<<"party:read">>] = encode(from_list([{[party], read}, {[party], read}])).
remove_scopes_test(_C) ->
?assertEqual(new(), remove([party], read, new())),
?assertEqual(
from_list([{[party], write}]),
remove([invoices], read, from_list([{[party], write}, {[invoices], read}]))
),
?assertEqual(
new(),
remove([party], read,
remove([party], write,
remove([party], read,
from_list([{[party], read}, {[party], write}])
)
)
)
).
match_scope_test(_C) ->
ACL = from_list([
{[party], read},
{[party], write},
{[invoices], read},
{[invoices, payments], write},
{[{invoices, <<"42">>}], write},
{[{invoices, <<"42">>}, payments], read}
]),
?assertError({badarg, _} , match([], ACL)),
?assertEqual([write] , match([{invoices, <<"42">>}], ACL)),
?assertEqual([read] , match([{invoices, <<"43">>}], ACL)),
?assertEqual([read] , match([{invoices, <<"42">>}, {payments, <<"1">>}], ACL)),
?assertEqual([write] , match([{invoices, <<"43">>}, {payments, <<"1">>}], ACL)),
?assertEqual([read, write] , match([{party, <<"BLARGH">>}], ACL)),
?assertEqual([] , match([payment_resources], ACL)).
new() ->
uac_acl:new().
from_list(L) ->
uac_acl:from_list(L).
to_list(L) ->
uac_acl:to_list(L).
remove(S, P, ACL) ->
uac_acl:remove_scope(S, P, ACL).
match(S, ACL) ->
uac_acl:match(S, ACL).
encode(ACL) ->
uac_acl:encode(ACL).
decode(Bin) ->
uac_acl:decode(Bin).
get_keysource(Key, Config) ->
filename:join(?config(data_dir, Config), Key).

View File

@ -0,0 +1,9 @@
-----BEGIN RSA PRIVATE KEY-----
MIIBOwIBAAJBAK9fx7qOJT7Aoseu7KKgaLagBh3wvDzg7F/ZMtGbPFikJnnvRWvF
B5oEGbMPblvtF0/fjqfu+eqjP3Z1tUSn7TkCAwEAAQJABUY5KIgr4JZEjwLYxQ9T
9uIbLP1Xe/E7yqoqmBk2GGhSrPY0OeRkYnUVLcP96UPQhF63iuG8VF6uZ7oAPsq+
gQIhANZy3jSCzPjXYHRU1kRqQzpt2S+OqoEiqQ6YG1HrC/VxAiEA0Vq6JlQK2tOX
37SS00dK0Qog4Qi8dN73GliFQNP18EkCIQC4epSA48zkfJMzQBAbRraSuxDNApPX
BzQbo+pMrEDbYQIgY4AncQgIkLB4Qk5kah48JNYXglzQlQtTjiX8Ty9ueGECIQCM
GD3UbQKiA0gf5plBA24I4wFVKxxa4wXbW/7SfP6XmQ==
-----END RSA PRIVATE KEY-----

235
test/uac_tests_SUITE.erl Normal file
View File

@ -0,0 +1,235 @@
-module(uac_tests_SUITE).
-include_lib("common_test/include/ct.hrl").
-include_lib("jose/include/jose_jwk.hrl").
-export([all/0]).
-export([groups/0]).
-export([init_per_suite/1]).
-export([end_per_suite/1]).
-export([init_per_group/2]).
-export([end_per_group/2]).
-export([init_per_testcase/2]).
-export([end_per_testcase/2]).
-export([
successful_auth_test/1,
invalid_permissions_test/1,
bad_token_test/1,
no_token_test/1,
force_expiration_test/1,
force_expiration_fail_test/1,
bad_signee_test/1,
different_issuers_test/1
]).
-type test_case_name() :: atom().
-type config() :: [{atom(), any()}].
-type group_name() :: atom().
-define(expire_as_of_now, #{
check_expired_as_of => genlib_time:unow()
}).
-define(test_service_acl(Access), [{[test_resource], Access}]).
-spec all() ->
[test_case_name()].
all() ->
[
{group, general_tests},
{group, different_issuers}
].
-spec groups() ->
[{group_name(), list(), [test_case_name()]}].
groups() ->
[
{general_tests, [],
[
successful_auth_test,
invalid_permissions_test,
bad_token_test,
no_token_test,
force_expiration_test,
force_expiration_fail_test,
bad_signee_test
]
},
{different_issuers, [],
[
different_issuers_test
]
}
].
-spec init_per_suite(config()) ->
config().
init_per_suite(Config) ->
Config.
-spec init_per_group(group_name(), config()) ->
config().
init_per_group(general_tests, Config) ->
Apps = [
genlib_app:start_application(snowflake),
genlib_app:start_application(uac)
],
uac:configure(#{
jwt => #{
keyset => #{
test => {pem_file, get_keysource("keys/local/private.pem", Config)}
}
},
access => #{
service_name => <<"test">>,
resource_hierarchy => #{
test_resource => #{}
}
}
}),
[{apps, Apps}] ++ Config;
init_per_group(different_issuers, Config) ->
Apps = [
genlib_app:start_application(snowflake),
genlib_app:start_application(uac)
],
uac:configure(#{
jwt => #{
keyset => #{
test => {pem_file, get_keysource("keys/local/private.pem", Config)}
}
},
access => #{
service_name => <<"test">>,
resource_hierarchy => #{
test_resource => #{}
}
}
}),
[{apps, Apps}] ++ Config.
-spec init_per_testcase(test_case_name(), config()) ->
config().
init_per_testcase(_Name, Config) ->
Config.
-spec end_per_suite(config()) ->
_.
end_per_suite(Config) ->
Config.
-spec end_per_group(group_name(), config()) ->
_.
end_per_group(_Name, Config) ->
[application:stop(App) || App <- ?config(apps, Config)].
-spec end_per_testcase(test_case_name(), config()) ->
_.
end_per_testcase(_Name, Config) ->
Config.
%%
-spec successful_auth_test(config()) ->
_.
successful_auth_test(_) ->
{ok, Token} = issue_token(?test_service_acl(write), unlimited),
{ok, AccessContext} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, #{}),
ok = uac:authorize_operation(?test_service_acl(write), AccessContext).
-spec invalid_permissions_test(config()) ->
_.
invalid_permissions_test(_) ->
{ok, Token} = issue_token(?test_service_acl(read), unlimited),
{ok, AccessContext} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, #{}),
{error, _} = uac:authorize_operation(?test_service_acl(write), AccessContext).
-spec bad_token_test(config()) ->
_.
bad_token_test(Config) ->
{ok, Token} = issue_dummy_token(?test_service_acl(write), Config),
{error, _} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, #{}).
-spec no_token_test(config()) ->
_.
no_token_test(_) ->
Token = <<"">>,
{error, _} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, #{}).
-spec force_expiration_test(config()) ->
_.
force_expiration_test(_) ->
{ok, Token} = issue_token(?test_service_acl(write), {deadline, 1}),
{ok, AccessContext} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, #{}),
ok = uac:authorize_operation(?test_service_acl(write), AccessContext).
-spec force_expiration_fail_test(config()) ->
_.
force_expiration_fail_test(_) ->
{ok, Token} = issue_token(?test_service_acl(write), {deadline, 1}),
{error, _} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, ?expire_as_of_now).
-spec bad_signee_test(config()) ->
_.
bad_signee_test(_) ->
ACL = ?test_service_acl(write),
{error, nonexistent_key} =
uac_authorizer_jwt:issue(unique_id(), unlimited, {{<<"TEST">>, uac_acl:from_list(ACL)}, #{}}, random).
%%
-spec different_issuers_test(config()) ->
_.
different_issuers_test(_) ->
{ok, Token} = issue_token(?test_service_acl(write), unlimited),
uac:configure(#{
jwt => #{},
access => #{
service_name => <<"SOME_OTHER_SERVICE">>,
resource_hierarchy => #{
test_resource => #{}
}
}
}),
{ok, {_, {_, []}, _}} = uac:authorize_api_key(<<"Bearer ", Token/binary>>, #{}).
%%
issue_token(ACL, LifeTime) ->
PartyID = <<"TEST">>,
Claims = #{<<"TEST">> => <<"TEST">>},
uac_authorizer_jwt:issue(unique_id(), LifeTime, {{PartyID, uac_acl:from_list(ACL)}, Claims}, test).
issue_dummy_token(ACL, Config) ->
Claims = #{
<<"jti">> => unique_id(),
<<"sub">> => <<"TEST">>,
<<"exp">> => 0,
<<"resource_access">> => #{
<<"common-api">> => #{
<<"roles">> => uac_acl:encode(uac_acl:from_list(ACL))
}
}
},
BadPemFile = get_keysource("keys/local/dummy.pem", Config),
BadJWK = jose_jwk:from_pem_file(BadPemFile),
GoodPemFile = get_keysource("keys/local/private.pem", Config),
GoodJWK = jose_jwk:from_pem_file(GoodPemFile),
JWKPublic = jose_jwk:to_public(GoodJWK),
{_Module, PublicKey} = JWKPublic#jose_jwk.kty,
{_PemEntry, Data, _} = public_key:pem_entry_encode('SubjectPublicKeyInfo', PublicKey),
KID = base64url:encode(crypto:hash(sha256, Data)),
JWT = jose_jwt:sign(BadJWK, #{<<"alg">> => <<"RS256">>, <<"kid">> => KID}, Claims),
{_Modules, Token} = jose_jws:compact(JWT),
{ok, Token}.
get_keysource(Key, Config) ->
filename:join(?config(data_dir, Config), Key).
unique_id() ->
<<ID:64>> = snowflake:new(),
genlib_format:format_int_base(ID, 62).

View File

@ -0,0 +1,13 @@
-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQCqGKukO1De7zhZj6+H0qtjTkVxwTCpvKe4eCZ0FPqri0cb2JZfXJ/DgYSF6vUp
wmJG8wVQZKjeGcjDOL5UlsuusFncCzWBQ7RKNUSesmQRMSGkVb1/3j+skZ6UtW+5u09lHNsj6tQ5
1s1SPrCBkedbNf0Tp0GbMJDyR4e9T04ZZwIDAQABAoGAFijko56+qGyN8M0RVyaRAXz++xTqHBLh
3tx4VgMtrQ+WEgCjhoTwo23KMBAuJGSYnRmoBZM3lMfTKevIkAidPExvYCdm5dYq3XToLkkLv5L2
pIIVOFMDG+KESnAFV7l2c+cnzRMW0+b6f8mR1CJzZuxVLL6Q02fvLi55/mbSYxECQQDeAw6fiIQX
GukBI4eMZZt4nscy2o12KyYner3VpoeE+Np2q+Z3pvAMd/aNzQ/W9WaI+NRfcxUJrmfPwIGm63il
AkEAxCL5HQb2bQr4ByorcMWm/hEP2MZzROV73yF41hPsRC9m66KrheO9HPTJuo3/9s5p+sqGxOlF
L0NDt4SkosjgGwJAFklyR1uZ/wPJjj611cdBcztlPdqoxssQGnh85BzCj/u3WqBpE2vjvyyvyI5k
X6zk7S0ljKtt2jny2+00VsBerQJBAJGC1Mg5Oydo5NwD6BiROrPxGo2bpTbu/fhrT8ebHkTz2epl
U9VQQSQzY1oZMVX8i1m5WUTLPz2yLJIBQVdXqhMCQBGoiuSoSjafUhV7i1cEGpb88h5NBYZzWXGZ
37sJ5QsW+sJyoNde3xH8vdXhzU7eT82D6X/scw9RZz+/6rCJ4p0=
-----END RSA PRIVATE KEY-----

View File

@ -0,0 +1,9 @@
-----BEGIN RSA PRIVATE KEY-----
MIIBOwIBAAJBAK9fx7qOJT7Aoseu7KKgaLagBh3wvDzg7F/ZMtGbPFikJnnvRWvF
B5oEGbMPblvtF0/fjqfu+eqjP3Z1tUSn7TkCAwEAAQJABUY5KIgr4JZEjwLYxQ9T
9uIbLP1Xe/E7yqoqmBk2GGhSrPY0OeRkYnUVLcP96UPQhF63iuG8VF6uZ7oAPsq+
gQIhANZy3jSCzPjXYHRU1kRqQzpt2S+OqoEiqQ6YG1HrC/VxAiEA0Vq6JlQK2tOX
37SS00dK0Qog4Qi8dN73GliFQNP18EkCIQC4epSA48zkfJMzQBAbRraSuxDNApPX
BzQbo+pMrEDbYQIgY4AncQgIkLB4Qk5kah48JNYXglzQlQtTjiX8Ty9ueGECIQCM
GD3UbQKiA0gf5plBA24I4wFVKxxa4wXbW/7SfP6XmQ==
-----END RSA PRIVATE KEY-----

View File

@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAK9fx7qOJT7Aoseu7KKgaLagBh3wvDzg
7F/ZMtGbPFikJnnvRWvFB5oEGbMPblvtF0/fjqfu+eqjP3Z1tUSn7TkCAwEAAQ==
-----END PUBLIC KEY-----