Реализовать кластер-безопасные admin refresh-сессии (фаза 1, #23).

Единая auth_session с refresh JWT, rotation и reuse detection; CI/CD временно на workflow_dispatch.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 15:30:27 +03:00
parent efd67ef1e3
commit 9de57bd52c
13 changed files with 525 additions and 45 deletions
+23 -4
View File
@@ -1,5 +1,6 @@
-module(admin_handler_login_tests).
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
-define(JWT_SECRET, <<"test-user-secret-key-32-byt!">>).
-define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>).
@@ -7,6 +8,16 @@
setup() ->
ok = meck:new(logic_auth, [non_strict]),
ok = meck:new(cowboy_req, [non_strict]),
ok = meck:new(core_admin, [non_strict]),
ok = meck:new(admin_utils, [non_strict]),
mnesia:start(),
catch ets:new(eventhub_counters, [named_table, public, set, {write_concurrency, true}]),
mnesia:create_table(auth_session, [
{attributes, record_info(fields, auth_session)},
{ram_copies, [node()]}
]),
mnesia:add_table_index(auth_session, #auth_session.family_id),
mnesia:add_table_index(auth_session, #auth_session.subject_id),
application:set_env(eventhub, jwt_secret, ?JWT_SECRET),
application:set_env(eventhub, admin_jwt_secret, ?ADMIN_JWT_SECRET),
{ok, _} = application:ensure_all_started(jose).
@@ -15,6 +26,11 @@ cleanup(_) ->
application:unset_env(eventhub, jwt_secret),
application:unset_env(eventhub, admin_jwt_secret),
application:stop(jose),
catch mnesia:delete_table(auth_session),
catch ets:delete(eventhub_counters),
mnesia:stop(),
meck:unload(admin_utils),
meck:unload(core_admin),
meck:unload(cowboy_req),
meck:unload(logic_auth),
ok.
@@ -52,8 +68,10 @@ prepare_req(Method, HasBody, Body) ->
test_valid_admin_login() ->
UserMap = #{id => <<"adm1">>, email => <<"admin@test.com">>, role => <<"superadmin">>},
ok = meck:expect(logic_auth, authenticate_user,
ok = meck:expect(logic_auth, authenticate_admin,
fun(<<"admin@test.com">>, <<"pass">>) -> {ok, UserMap} end),
ok = meck:expect(core_admin, update_last_login, fun(_) -> ok end),
ok = meck:expect(admin_utils, log_admin_action, fun(_, _, _, _, _, _) -> ok end),
Req0 = prepare_req(<<"POST">>, true, jsx:encode(#{email => <<"admin@test.com">>, password => <<"pass">>})),
{ok, _, _} = admin_handler_login:init(Req0, []),
{Code, Headers, Body} = get(test_reply),
@@ -61,10 +79,11 @@ test_valid_admin_login() ->
?assertEqual(<<"application/json">>, maps:get(<<"content-type">>, Headers)),
Resp = jsx:decode(Body, [return_maps]),
?assert(is_map_key(<<"token">>, Resp)),
?assert(is_map_key(<<"refresh_token">>, Resp)),
?assertEqual(<<"superadmin">>, maps:get(<<"role">>, maps:get(<<"user">>, Resp))).
test_invalid_credentials() ->
ok = meck:expect(logic_auth, authenticate_user,
ok = meck:expect(logic_auth, authenticate_admin,
fun(_, _) -> {error, bad_credentials} end),
Req0 = prepare_req(<<"POST">>, true, jsx:encode(#{email => <<"bad@test.com">>, password => <<"wrong">>})),
{ok, _, _} = admin_handler_login:init(Req0, []),
@@ -74,7 +93,7 @@ test_invalid_credentials() ->
test_insufficient_permissions() ->
UserMap = #{id => <<"user1">>, email => <<"user@test.com">>, role => <<"user">>},
ok = meck:expect(logic_auth, authenticate_user,
ok = meck:expect(logic_auth, authenticate_admin,
fun(_, _) -> {ok, UserMap} end),
Req0 = prepare_req(<<"POST">>, true, jsx:encode(#{email => <<"user@test.com">>, password => <<"pass">>})),
{ok, _, _} = admin_handler_login:init(Req0, []),
@@ -87,7 +106,7 @@ test_malformed_json() ->
{ok, _, _} = admin_handler_login:init(Req0, []),
{Code, _, Body} = get(test_reply),
?assertEqual(400, Code),
#{<<"error">> := <<"invalid_request">>} = jsx:decode(Body, [return_maps]).
#{<<"error">> := <<"Invalid JSON">>} = jsx:decode(Body, [return_maps]).
test_missing_body() ->
Req0 = prepare_req(<<"POST">>, false, undefined),
+33 -5
View File
@@ -104,7 +104,7 @@ authenticate_admin_request_test_() ->
{"Successful admin login returns admin token",
fun() ->
AdminMap = #{id => <<"adm1">>, email => <<"admin@test.com">>, role => <<"superadmin">>},
ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, AdminMap} end),
ok = meck:expect(logic_auth, authenticate_admin, fun(_Email, _Password) -> {ok, AdminMap} end),
Req = undefined,
{ok, Token, ReturnedUser} = eventhub_auth:authenticate_admin_request(Req, <<"admin@test.com">>, <<"pass">>),
?assert(is_binary(Token)),
@@ -116,7 +116,7 @@ authenticate_admin_request_test_() ->
{"Non-admin role is rejected with insufficient_permissions",
fun() ->
UserMap = #{id => <<"simpleuser">>, email => <<"u@test.com">>, role => <<"user">>},
ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, UserMap} end),
ok = meck:expect(logic_auth, authenticate_admin, fun(_Email, _Password) -> {ok, UserMap} end),
Req = undefined,
?assertEqual({error, insufficient_permissions},
eventhub_auth:authenticate_admin_request(Req, <<"u@test.com">>, <<"pwd">>))
@@ -124,7 +124,7 @@ authenticate_admin_request_test_() ->
{"Moderator role is accepted as admin",
fun() ->
ModMap = #{id => <<"moder1">>, email => <<"mod@test.com">>, role => <<"moderator">>},
ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, ModMap} end),
ok = meck:expect(logic_auth, authenticate_admin, fun(_Email, _Password) -> {ok, ModMap} end),
Req = undefined,
{ok, Token, _} = eventhub_auth:authenticate_admin_request(Req, <<"mod@test.com">>, <<"pwd">>),
{ok, _, Role} = eventhub_auth:verify_admin_token(Token),
@@ -133,7 +133,35 @@ authenticate_admin_request_test_() ->
]}.
%% ------------------------------------------------------------------
%% Тест generate_refresh_token/1
%% Тест generate_refresh_token/1 (legacy opaque, user flow)
%% ------------------------------------------------------------------
generate_refresh_token_test() ->
{_, _} = eventhub_auth:generate_refresh_token(<<"anyuser">>).
{_, _} = eventhub_auth:generate_refresh_token(<<"anyuser">>).
%% ------------------------------------------------------------------
%% Тесты refresh JWT (admin)
%% ------------------------------------------------------------------
admin_refresh_jwt_test_() ->
{setup, fun setup/0, fun cleanup/1, [
{"Generate and verify admin refresh JWT",
fun() ->
Sid = <<"sess123">>, Fid = <<"fam456">>, Jti = <<"jti789">>,
{Token, _} = eventhub_auth:generate_admin_refresh_token(
Sid, Fid, Jti, <<"admin1">>, <<"admin">>
),
?assert(is_binary(Token)),
{ok, Claims} = eventhub_auth:verify_admin_refresh_token(Token),
?assertEqual(Sid, maps:get(<<"sid">>, Claims)),
?assertEqual(Fid, maps:get(<<"fid">>, Claims)),
?assertEqual(Jti, maps:get(<<"jti">>, Claims)),
?assertEqual(<<"admin1">>, maps:get(<<"sub">>, Claims))
end},
{"User refresh JWT rejected by admin verifier",
fun() ->
{Token, _} = eventhub_auth:generate_admin_refresh_token(
<<"s">>, <<"f">>, <<"j">>, <<"u">>, <<"admin">>
),
?assertMatch({error, invalid_signature},
eventhub_auth:verify_user_token(Token))
end}
]}.
+63
View File
@@ -0,0 +1,63 @@
-module(core_auth_session_tests).
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
-define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>).
setup() ->
mnesia:start(),
catch ets:new(eventhub_counters, [named_table, public, set, {write_concurrency, true}]),
mnesia:create_table(auth_session, [
{attributes, record_info(fields, auth_session)},
{ram_copies, [node()]}
]),
mnesia:add_table_index(auth_session, #auth_session.family_id),
mnesia:add_table_index(auth_session, #auth_session.subject_id),
application:set_env(eventhub, admin_jwt_secret, ?ADMIN_JWT_SECRET),
{ok, _} = application:ensure_all_started(jose),
ok.
cleanup(_) ->
mnesia:delete_table(auth_session),
catch ets:delete(eventhub_counters),
application:unset_env(eventhub, admin_jwt_secret),
application:stop(jose),
mnesia:stop(),
ok.
core_auth_session_test_() ->
{foreach, fun setup/0, fun cleanup/1, [
{"Create session", fun test_create/0},
{"Rotate session updates jti", fun test_rotate/0},
{"Reuse detection", fun test_reuse/0},
{"Revoke family", fun test_revoke_family/0}
]}.
test_create() ->
{ok, Session} = core_auth_session:create(<<"admin1">>, admin, <<"admin">>),
?assertEqual(<<"admin1">>, Session#auth_session.subject_id),
?assertEqual(admin, Session#auth_session.subject_type),
?assertEqual(false, Session#auth_session.revoked),
{ok, Loaded} = core_auth_session:get(Session#auth_session.session_id),
?assertEqual(Session#auth_session.session_id, Loaded#auth_session.session_id).
test_rotate() ->
{ok, Session} = core_auth_session:create(<<"admin1">>, admin, <<"admin">>),
OldJti = Session#auth_session.current_jti,
{ok, NewJti, _} = core_auth_session:rotate(Session#auth_session.session_id, OldJti),
?assertNotEqual(OldJti, NewJti),
?assertMatch({error, reuse_detected},
core_auth_session:rotate(Session#auth_session.session_id, OldJti)).
test_reuse() ->
{ok, Session} = core_auth_session:create(<<"admin1">>, admin, <<"admin">>),
Jti = Session#auth_session.current_jti,
{ok, _, _} = core_auth_session:rotate(Session#auth_session.session_id, Jti),
?assertMatch({error, reuse_detected},
core_auth_session:rotate(Session#auth_session.session_id, Jti)).
test_revoke_family() ->
{ok, S1} = core_auth_session:create(<<"admin1">>, admin, <<"admin">>),
ok = core_auth_session:revoke_family(S1#auth_session.family_id),
?assertMatch({error, revoked},
core_auth_session:rotate(S1#auth_session.session_id, S1#auth_session.current_jti)).
+69
View File
@@ -0,0 +1,69 @@
-module(logic_auth_session_tests).
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
-define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>).
setup() ->
ok = meck:new(core_admin, [non_strict]),
mnesia:start(),
catch ets:new(eventhub_counters, [named_table, public, set, {write_concurrency, true}]),
mnesia:create_table(auth_session, [
{attributes, record_info(fields, auth_session)},
{ram_copies, [node()]}
]),
mnesia:add_table_index(auth_session, #auth_session.family_id),
mnesia:add_table_index(auth_session, #auth_session.subject_id),
application:set_env(eventhub, admin_jwt_secret, ?ADMIN_JWT_SECRET),
{ok, _} = application:ensure_all_started(jose),
ok.
cleanup(_) ->
application:unset_env(eventhub, admin_jwt_secret),
application:stop(jose),
meck:unload(core_admin),
catch mnesia:delete_table(auth_session),
catch ets:delete(eventhub_counters),
mnesia:stop(),
ok.
logic_auth_session_test_() ->
{foreach, fun setup/0, fun cleanup/1, [
{"Issue and refresh admin tokens", fun test_issue_and_refresh/0},
{"Refresh reuse revokes family", fun test_reuse_revokes/0}
]}.
test_issue_and_refresh() ->
AdminId = <<"adm1">>,
Admin = #admin{
id = AdminId, email = <<"a@test.com">>, role = superadmin,
password_hash = <<>>, status = active, nickname = <<>>,
avatar_url = default, timezone = <<"UTC">>, language = <<"ru">>,
phone = <<>>, preferences = #{}, last_login = undefined,
created_at = undefined, updated_at = undefined
},
ok = meck:expect(core_admin, get_by_id, fun(Id) ->
?assertEqual(AdminId, Id), {ok, Admin}
end),
{ok, Access1, Refresh1} = logic_auth_session:issue_admin_tokens(AdminId, <<"superadmin">>),
?assert(is_binary(Access1)),
{ok, _, _} = eventhub_auth:verify_admin_token(Access1),
{ok, Access2, Refresh2} = logic_auth_session:refresh_admin(Refresh1),
?assert(is_binary(Access2)),
?assertNotEqual(Refresh1, Refresh2),
{ok, _, _} = eventhub_auth:verify_admin_token(Access2).
test_reuse_revokes() ->
AdminId = <<"adm1">>,
Admin = #admin{
id = AdminId, email = <<"a@test.com">>, role = admin,
password_hash = <<>>, status = active, nickname = <<>>,
avatar_url = default, timezone = <<"UTC">>, language = <<"ru">>,
phone = <<>>, preferences = #{}, last_login = undefined,
created_at = undefined, updated_at = undefined
},
ok = meck:expect(core_admin, get_by_id, fun(_) -> {ok, Admin} end),
{ok, _, Refresh1} = logic_auth_session:issue_admin_tokens(AdminId, <<"admin">>),
{ok, _, Refresh2} = logic_auth_session:refresh_admin(Refresh1),
?assertMatch({error, reuse_detected}, logic_auth_session:refresh_admin(Refresh1)),
?assertMatch({error, revoked}, logic_auth_session:refresh_admin(Refresh2)).