From 9de57bd52c92f8b4ed78d0c87c20fc09ede4daa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Tue, 7 Jul 2026 15:30:27 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BA=D0=BB=D0=B0=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D1=80-=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20admin=20refresh-=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8?= =?UTF-8?q?=20(=D1=84=D0=B0=D0=B7=D0=B0=201,=20#23).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единая auth_session с refresh JWT, rotation и reuse detection; CI/CD временно на workflow_dispatch. Co-authored-by: Cursor --- .gitea/workflows/build-and-test.yml | 10 +- .gitea/workflows/deploy-stage.yml | 10 +- include/records.hrl | 14 ++ src/core/core_auth_session.erl | 135 +++++++++++++++++++ src/handlers/admin/admin_handler_login.erl | 6 +- src/handlers/admin/admin_handler_refresh.erl | 36 ++--- src/infra/eventhub_auth.erl | 95 ++++++++++++- src/infra/infra_mnesia.erl | 5 +- src/logic/logic_auth_session.erl | 62 +++++++++ test/unit/admin_handler_login_tests.erl | 27 +++- test/unit/auth_test.erl | 38 +++++- test/unit/core_auth_session_tests.erl | 63 +++++++++ test/unit/logic_auth_session_tests.erl | 69 ++++++++++ 13 files changed, 525 insertions(+), 45 deletions(-) create mode 100644 src/core/core_auth_session.erl create mode 100644 src/logic/logic_auth_session.erl create mode 100644 test/unit/core_auth_session_tests.erl create mode 100644 test/unit/logic_auth_session_tests.erl diff --git a/.gitea/workflows/build-and-test.yml b/.gitea/workflows/build-and-test.yml index 4922f2d..f338544 100644 --- a/.gitea/workflows/build-and-test.yml +++ b/.gitea/workflows/build-and-test.yml @@ -1,7 +1,11 @@ +# ВРЕМЕННО ОТКЛЮЧЕНО — CI/CD требует донастройки (см. EventHubBack#23). +# Ручной запуск: Actions → Run workflow name: Test & Deploy to Snapdeploy on: - push: - branches: [ master ] + workflow_dispatch: +# on: +# push: +# branches: [ master ] jobs: build-test-deploy: @@ -41,7 +45,7 @@ jobs: with: registry: git.sabilin.com username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_PASSWORD }} + password: ${{ secrets.REGISTRY_PASSWORD }} # 6fc03959f69bb530f77c4d9173903f52532acdc0 - name: Tag and push eventhub image if: success() diff --git a/.gitea/workflows/deploy-stage.yml b/.gitea/workflows/deploy-stage.yml index 667fd3d..53a4719 100644 --- a/.gitea/workflows/deploy-stage.yml +++ b/.gitea/workflows/deploy-stage.yml @@ -1,8 +1,12 @@ +# ВРЕМЕННО ОТКЛЮЧЕНО — CI/CD требует донастройки. +# Ручной запуск: Actions → Run workflow name: Deploy to Stage on: - push: - tags: - - 'v*' + workflow_dispatch: +# on: +# push: +# tags: +# - 'v*' jobs: deploy-stage: diff --git a/include/records.hrl b/include/records.hrl index a69a7db..e54d2a0 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -61,6 +61,20 @@ type :: refresh }). +%% ------------------- ЕДИНАЯ МОДЕЛЬ СЕССИЙ (refresh JWT) ---------------- +-record(auth_session, { + session_id :: binary(), + family_id :: binary(), + subject_id :: binary(), + subject_type :: user | admin, + client_type :: binary(), + current_jti :: binary(), + expires_at :: calendar:datetime(), + revoked :: boolean(), + created_at :: calendar:datetime(), + updated_at :: calendar:datetime() +}). + %% ------------------- Календари --------------------------------------- -record(calendar, { id :: binary(), diff --git a/src/core/core_auth_session.erl b/src/core/core_auth_session.erl new file mode 100644 index 0000000..435a2b4 --- /dev/null +++ b/src/core/core_auth_session.erl @@ -0,0 +1,135 @@ +%%%------------------------------------------------------------------- +%%% @doc Единая модель сессий (refresh JWT) в Mnesia. +%%% Таблица `auth_session` реплицируется через disc_copies — безопасна в кластере. +%%% @end +%%%------------------------------------------------------------------- +-module(core_auth_session). +-include("records.hrl"). +-export([create/3, get/1, rotate/2, revoke/1, revoke_family/1]). + +-define(REFRESH_TTL_SECONDS, 30 * 24 * 3600). + +%%%------------------------------------------------------------------- +%%% @doc Создать новую сессию устройства/клиента. +%%% @end +%%%------------------------------------------------------------------- +-spec create(SubjectId :: binary(), SubjectType :: user | admin, ClientType :: binary()) -> + {ok, #auth_session{}}. +create(SubjectId, SubjectType, ClientType) -> + Now = calendar:universal_time(), + ExpiresAt = calendar:gregorian_seconds_to_datetime( + calendar:datetime_to_gregorian_seconds(Now) + ?REFRESH_TTL_SECONDS + ), + Session = #auth_session{ + session_id = infra_utils:generate_id(16), + family_id = infra_utils:generate_id(16), + subject_id = SubjectId, + subject_type = SubjectType, + client_type = ClientType, + current_jti = infra_utils:generate_id(16), + expires_at = ExpiresAt, + revoked = false, + created_at = Now, + updated_at = Now + }, + mnesia:dirty_write(Session), + inc_counter(SubjectType), + {ok, Session}. + +%%%------------------------------------------------------------------- +%%% @doc Получить сессию по идентификатору. +%%% @end +%%%------------------------------------------------------------------- +-spec get(SessionId :: binary()) -> {ok, #auth_session{}} | {error, not_found}. +get(SessionId) -> + case mnesia:dirty_read({auth_session, SessionId}) of + [Session] -> {ok, Session}; + [] -> {error, not_found} + end. + +%%%------------------------------------------------------------------- +%%% @doc Ротация refresh-токена: проверяет jti и выдаёт новый. +%%% Несовпадение jti после ротации означает reuse — возвращает `{error, reuse_detected}`. +%%% @end +%%%------------------------------------------------------------------- +-spec rotate(SessionId :: binary(), PresentedJti :: binary()) -> + {ok, NewJti :: binary(), #auth_session{}} | + {error, not_found | expired | revoked | reuse_detected}. +rotate(SessionId, PresentedJti) -> + case mnesia:dirty_read({auth_session, SessionId}) of + [Session] -> + rotate_session(Session, PresentedJti); + [] -> + {error, not_found} + end. + +%%%------------------------------------------------------------------- +%%% @doc Отозвать одну сессию. +%%% @end +%%%------------------------------------------------------------------- +-spec revoke(SessionId :: binary()) -> ok | {error, not_found}. +revoke(SessionId) -> + case mnesia:dirty_read({auth_session, SessionId}) of + [Session] when Session#auth_session.revoked =:= true -> + ok; + [Session] -> + Now = calendar:universal_time(), + mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}), + dec_counter(Session#auth_session.subject_type), + ok; + [] -> + {error, not_found} + end. + +%%%------------------------------------------------------------------- +%%% @doc Отозвать все сессии семейства (reuse attack / принудительный logout). +%%% @end +%%%------------------------------------------------------------------- +-spec revoke_family(FamilyId :: binary()) -> ok. +revoke_family(FamilyId) -> + Sessions = mnesia:dirty_index_read(auth_session, FamilyId, #auth_session.family_id), + Now = calendar:universal_time(), + lists:foreach(fun(Session) -> + case Session#auth_session.revoked of + true -> ok; + false -> + mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}), + dec_counter(Session#auth_session.subject_type) + end + end, Sessions), + ok. + +%%%=================================================================== +%%% Internal +%%%=================================================================== + +rotate_session(Session, PresentedJti) -> + case Session#auth_session.revoked of + true -> + {error, revoked}; + false -> + Now = calendar:universal_time(), + case Session#auth_session.expires_at > Now of + false -> + {error, expired}; + true -> + case Session#auth_session.current_jti =:= PresentedJti of + true -> + NewJti = infra_utils:generate_id(16), + Updated = Session#auth_session{ + current_jti = NewJti, + updated_at = Now + }, + mnesia:dirty_write(Updated), + {ok, NewJti, Updated}; + false -> + {error, reuse_detected} + end + end + end. + +inc_counter(admin) -> core_counters:inc(admin_active_sessions); +inc_counter(user) -> core_counters:inc(active_sessions). + +dec_counter(admin) -> core_counters:dec(admin_active_sessions); +dec_counter(user) -> core_counters:dec(active_sessions). diff --git a/src/handlers/admin/admin_handler_login.erl b/src/handlers/admin/admin_handler_login.erl index 24392b3..b7de801 100644 --- a/src/handlers/admin/admin_handler_login.erl +++ b/src/handlers/admin/admin_handler_login.erl @@ -19,10 +19,10 @@ init(Req0, _State) -> try jsx:decode(Body, [return_maps]) of #{<<"email">> := Email, <<"password">> := Password} -> case eventhub_auth:authenticate_admin_request(Req1, Email, Password) of - {ok, Token, User} -> + {ok, _Token, User} -> UserId = maps:get(id, User), - {RefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(UserId), - core_admin_session:create(UserId, RefreshToken), + Role = maps:get(role, User), + {ok, Token, RefreshToken} = logic_auth_session:issue_admin_tokens(UserId, Role), core_admin:update_last_login(UserId), % Аудит успешного входа admin_utils:log_admin_action(UserId, <<"login">>, <<"admin">>, Email, <<>>, Req1), diff --git a/src/handlers/admin/admin_handler_refresh.erl b/src/handlers/admin/admin_handler_refresh.erl index 830b1b6..a476ce7 100644 --- a/src/handlers/admin/admin_handler_refresh.erl +++ b/src/handlers/admin/admin_handler_refresh.erl @@ -7,8 +7,6 @@ -export([init/2]). -export([trails/0]). --include("records.hrl"). - %%% cowboy_handler callback -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, _Opts) -> @@ -72,31 +70,25 @@ refresh(Req) -> {ok, Body, Req1} = cowboy_req:read_body(Req), try jsx:decode(Body, [return_maps]) of #{<<"refresh_token">> := RefreshToken} -> - case core_admin_session:validate(RefreshToken) of - {ok, AdminId} -> - admin_refresh(AdminId, RefreshToken, Req1); + case logic_auth_session:refresh_admin(RefreshToken) of + {ok, NewToken, NewRefreshToken} -> + handler_utils:send_json(Req1, 200, #{ + token => NewToken, + refresh_token => NewRefreshToken + }); + {error, reuse_detected} -> + handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>); {error, not_found} -> handler_utils:send_error(Req1, 401, <<"Refresh token not found">>); {error, expired} -> - handler_utils:send_error(Req1, 401, <<"Refresh token expired">>) + handler_utils:send_error(Req1, 401, <<"Refresh token expired">>); + {error, revoked} -> + handler_utils:send_error(Req1, 401, <<"Refresh token revoked">>); + {error, _} -> + handler_utils:send_error(Req1, 401, <<"Invalid refresh token">>) end; _ -> handler_utils:send_error(Req1, 400, <<"Missing refresh_token field">>) catch _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) - end. - -%%%=================================================================== -%%% Внутренние функции -%%%=================================================================== - -%% @private Обновляет токен для администратора. --spec admin_refresh(binary(), binary(), cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. -admin_refresh(AdminId, OldToken, Req) -> - core_admin_session:delete(OldToken), - {ok, Admin} = core_admin:get_by_id(AdminId), - Role = atom_to_binary(Admin#admin.role, utf8), - NewToken = eventhub_auth:generate_admin_token(AdminId, Role), - {NewRefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(AdminId), - core_admin_session:create(AdminId, NewRefreshToken), - handler_utils:send_json(Req, 200, #{token => NewToken, refresh_token => NewRefreshToken}). \ No newline at end of file + end. \ No newline at end of file diff --git a/src/infra/eventhub_auth.erl b/src/infra/eventhub_auth.erl index a175dbd..f790b88 100644 --- a/src/infra/eventhub_auth.erl +++ b/src/infra/eventhub_auth.erl @@ -6,7 +6,9 @@ verify_admin_token/1, authenticate_user_request/3, authenticate_admin_request/3, - generate_refresh_token/1 + generate_refresh_token/1, + generate_admin_refresh_token/5, + verify_admin_refresh_token/1 ]). %% ========== КОНФИГУРАЦИЯ СЕКРЕТОВ ========== @@ -146,13 +148,98 @@ authenticate_admin_request(_Req, Email, Password) -> is_admin_role(Role) -> lists:member(Role, [<<"admin">>, <<"superadmin">>, <<"moderator">>, <<"support">>]). -%% ========== REFRESH TOKEN ========== +-define(REFRESH_TTL_SECONDS, 30 * 24 * 3600). + +%% ========== REFRESH TOKEN (opaque, legacy user flow — фаза 2) ========== -spec generate_refresh_token(UserId :: binary()) -> {binary(), calendar:datetime()}. generate_refresh_token(_UserId) -> RefreshToken = base64:encode(crypto:strong_rand_bytes(32)), Now = calendar:universal_time(), ExpiresAt = calendar:gregorian_seconds_to_datetime( - calendar:datetime_to_gregorian_seconds(Now) + 30 * 24 * 3600 + calendar:datetime_to_gregorian_seconds(Now) + ?REFRESH_TTL_SECONDS ), - {RefreshToken, ExpiresAt}. \ No newline at end of file + {RefreshToken, ExpiresAt}. + +%% ========== REFRESH JWT (единая session-модель) ========== + +-spec generate_admin_refresh_token( + SessionId :: binary(), + FamilyId :: binary(), + Jti :: binary(), + SubjectId :: binary(), + ClientType :: binary() +) -> {binary(), calendar:datetime()}. +generate_admin_refresh_token(SessionId, FamilyId, Jti, SubjectId, ClientType) -> + generate_refresh_jwt( + get_admin_jwk(), SubjectId, <<"admin">>, SessionId, FamilyId, Jti, ClientType + ). + +-spec verify_admin_refresh_token(Token :: binary()) -> + {ok, map()} | {error, atom()}. +verify_admin_refresh_token(Token) -> + verify_refresh_jwt(get_admin_jwk(), Token, <<"admin">>). + +generate_refresh_jwt(JWK, SubjectId, Audience, SessionId, FamilyId, Jti, ClientType) -> + ExpTime = erlang:system_time(second) + ?REFRESH_TTL_SECONDS, + Claims = #{ + <<"typ">> => <<"refresh">>, + <<"sub">> => SubjectId, + <<"aud">> => Audience, + <<"sid">> => SessionId, + <<"fid">> => FamilyId, + <<"jti">> => Jti, + <<"client">> => ClientType, + <<"exp">> => ExpTime, + <<"iat">> => erlang:system_time(second) + }, + JWT = jose_jwt:sign(JWK, #{<<"alg">> => <<"HS256">>}, Claims), + {_, Token} = jose_jws:compact(JWT), + ExpiresAt = calendar:gregorian_seconds_to_datetime( + calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + ?REFRESH_TTL_SECONDS + ), + {Token, ExpiresAt}. + +verify_refresh_jwt(JWK, Token, ExpectedAud) -> + try + case jose_jwt:verify(JWK, Token) of + {true, {jose_jwt, Claims}, _} -> + validate_refresh_claims(Claims, ExpectedAud); + {true, Claims, _} when is_map(Claims) -> + validate_refresh_claims(Claims, ExpectedAud); + _ -> + {error, invalid_signature} + end + catch + _:_ -> {error, invalid_token} + end. + +validate_refresh_claims(Claims, ExpectedAud) -> + case maps:get(<<"typ">>, Claims, undefined) of + <<"refresh">> -> + case maps:get(<<"aud">>, Claims, undefined) of + ExpectedAud -> + validate_refresh_exp(Claims); + _ -> + {error, invalid_audience} + end; + _ -> + {error, invalid_token_type} + end. + +validate_refresh_exp(Claims) -> + Required = [<<"sid">>, <<"fid">>, <<"jti">>, <<"sub">>, <<"exp">>], + case [K || K <- Required, not maps:is_key(K, Claims)] of + [] -> + case maps:get(<<"exp">>, Claims) of + Exp when is_integer(Exp) -> + case Exp > erlang:system_time(second) of + true -> {ok, Claims}; + false -> {error, expired} + end; + _ -> + {error, expired} + end; + _ -> + {error, invalid_token} + end. \ No newline at end of file diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index 000dea5..0497c65 100644 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -12,7 +12,7 @@ terminate/2, code_change/3]). -define(TABLES, [ - user, session, verification, admin, admin_session, + user, session, verification, admin, admin_session, auth_session, calendar, calendar_share, calendar_specialist, event, recurrence_exception, booking, @@ -239,6 +239,7 @@ table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_in table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}]; table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}]; table_opts(admin_session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, admin_session)}]; +table_opts(auth_session) -> [{disc_copies, [node()]}, {attributes, record_info(fields, auth_session)}]; table_opts(node_metric) -> [{disc_copies, [node()]}, {local_content, true}, {attributes, record_info(fields, node_metric)}]. %% =================================================================== @@ -267,4 +268,6 @@ create_indices() -> mnesia:add_table_index(user, email), mnesia:add_table_index(notification, user_id), mnesia:add_table_index(notification, is_read), + mnesia:add_table_index(auth_session, family_id), + mnesia:add_table_index(auth_session, subject_id), ok. \ No newline at end of file diff --git a/src/logic/logic_auth_session.erl b/src/logic/logic_auth_session.erl new file mode 100644 index 0000000..c03c717 --- /dev/null +++ b/src/logic/logic_auth_session.erl @@ -0,0 +1,62 @@ +%%%------------------------------------------------------------------- +%%% @doc Бизнес-логика единой session-модели (refresh JWT + rotation). +%%% Фаза 1: admin API. Фазы 2–3: user / client web / mobile. +%%% @end +%%%------------------------------------------------------------------- +-module(logic_auth_session). +-include("records.hrl"). +-export([issue_admin_tokens/2, refresh_admin/1]). + +%%%------------------------------------------------------------------- +%%% @doc Выдать пару access + refresh JWT при входе администратора. +%%% @end +%%%------------------------------------------------------------------- +-spec issue_admin_tokens(AdminId :: binary(), Role :: binary()) -> + {ok, AccessToken :: binary(), RefreshToken :: binary()}. +issue_admin_tokens(AdminId, Role) -> + {ok, Session} = core_auth_session:create(AdminId, admin, <<"admin">>), + AccessToken = eventhub_auth:generate_admin_token(AdminId, Role), + {RefreshToken, _} = eventhub_auth:generate_admin_refresh_token( + Session#auth_session.session_id, + Session#auth_session.family_id, + Session#auth_session.current_jti, + AdminId, + <<"admin">> + ), + {ok, AccessToken, RefreshToken}. + +%%%------------------------------------------------------------------- +%%% @doc Обновить пару токенов администратора по refresh JWT. +%%% @end +%%%------------------------------------------------------------------- +-spec refresh_admin(RefreshToken :: binary()) -> + {ok, AccessToken :: binary(), NewRefreshToken :: binary()} | + {error, atom()}. +refresh_admin(RefreshToken) -> + case eventhub_auth:verify_admin_refresh_token(RefreshToken) of + {ok, Claims} -> + do_refresh_admin(Claims); + Error -> + Error + end. + +do_refresh_admin(Claims) -> + SessionId = maps:get(<<"sid">>, Claims), + Jti = maps:get(<<"jti">>, Claims), + AdminId = maps:get(<<"sub">>, Claims), + FamilyId = maps:get(<<"fid">>, Claims), + case core_auth_session:rotate(SessionId, Jti) of + {ok, NewJti, _Session} -> + {ok, Admin} = core_admin:get_by_id(AdminId), + Role = atom_to_binary(Admin#admin.role, utf8), + AccessToken = eventhub_auth:generate_admin_token(AdminId, Role), + {NewRefresh, _} = eventhub_auth:generate_admin_refresh_token( + SessionId, FamilyId, NewJti, AdminId, <<"admin">> + ), + {ok, AccessToken, NewRefresh}; + {error, reuse_detected} -> + core_auth_session:revoke_family(FamilyId), + {error, reuse_detected}; + Error -> + Error + end. \ No newline at end of file diff --git a/test/unit/admin_handler_login_tests.erl b/test/unit/admin_handler_login_tests.erl index 71a4240..a95f6d1 100644 --- a/test/unit/admin_handler_login_tests.erl +++ b/test/unit/admin_handler_login_tests.erl @@ -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), diff --git a/test/unit/auth_test.erl b/test/unit/auth_test.erl index 181dab2..36a5e8e 100644 --- a/test/unit/auth_test.erl +++ b/test/unit/auth_test.erl @@ -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">>). \ No newline at end of file + {_, _} = 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} + ]}. \ No newline at end of file diff --git a/test/unit/core_auth_session_tests.erl b/test/unit/core_auth_session_tests.erl new file mode 100644 index 0000000..1036f3f --- /dev/null +++ b/test/unit/core_auth_session_tests.erl @@ -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)). diff --git a/test/unit/logic_auth_session_tests.erl b/test/unit/logic_auth_session_tests.erl new file mode 100644 index 0000000..0c35c2f --- /dev/null +++ b/test/unit/logic_auth_session_tests.erl @@ -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)).