diff --git a/Makefile b/Makefile index 500daad..233a542 100644 --- a/Makefile +++ b/Makefile @@ -289,8 +289,9 @@ docker-swarm-deploy: ## Запустить кластер @set JWT_SECRET=$$(grep JWT_SECRET docker/.env | cut -d '=' -f2) @docker stack deploy -c docker/docker-compose.swarm.yml eventhub --detach=true @echo "✅ Кластер запущен" + @echo "Admin UI: https://admin-ui.eventhub.local" -docker-swarm-stop: ## Запустить кластер +docker-swarm-stop: ## Остановить кластер @docker stack rm eventhub ##@docker volume prune -f @echo "✅ Кластер удален" diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 672a225..f9d21b4 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -109,6 +109,7 @@ start_admin_http() -> {"/admin/health", admin_handler_health, []}, {"/v1/admin/stats", admin_handler_stats, []}, {"/v1/admin/login", admin_handler_login, []}, + {"/v1/admin/refresh", admin_handler_refresh, []}, % ================== ПОЛЬЗОВАТЕЛИ ================== {"/v1/admin/users", admin_handler_users, []}, {"/v1/admin/users/:id", admin_handler_user_by_id, []}, diff --git a/src/handlers/admin/admin_handler_refresh.erl b/src/handlers/admin/admin_handler_refresh.erl new file mode 100644 index 0000000..830b1b6 --- /dev/null +++ b/src/handlers/admin/admin_handler_refresh.erl @@ -0,0 +1,102 @@ +%%%------------------------------------------------------------------- +%%% @doc Обработчик обновления административного токена. +%%% @end +%%%------------------------------------------------------------------- +-module(admin_handler_refresh). +-behaviour(cowboy_handler). +-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) -> + case cowboy_req:method(Req) of + <<"POST">> -> refresh(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/admin/refresh">>, + method => <<"POST">>, + description => <<"Refresh admin access token using refresh token">>, + tags => [<<"Admin">>], + requestBody => #{ + required => true, + content => #{ + <<"application/json">> => #{ + schema => #{ + type => object, + required => [<<"refresh_token">>], + properties => #{refresh_token => #{type => string}} + } + } + } + }, + responses => #{ + 200 => #{ + description => <<"New admin token pair (access + refresh)">>, + content => #{ + <<"application/json">> => #{ + schema => token_pair_schema() + } + } + }, + 400 => #{description => <<"Missing refresh_token field or invalid JSON">>}, + 401 => #{description => <<"Refresh token expired or not found">>} + } + } + ]. + +token_pair_schema() -> + #{ + type => object, + properties => #{ + token => #{type => string, description => <<"JWT access token">>}, + refresh_token => #{type => string, description => <<"Refresh token">>} + } + }. + +%%%=================================================================== +%%% HTTP-методы +%%%=================================================================== + +%% @doc POST /v1/admin/refresh — обновление административного токена. +-spec refresh(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +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); + {error, not_found} -> + handler_utils:send_error(Req1, 401, <<"Refresh token not found">>); + {error, expired} -> + handler_utils:send_error(Req1, 401, <<"Refresh token expired">>) + 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 diff --git a/src/handlers/handler_refresh.erl b/src/handlers/handler_refresh.erl index 9e28952..70a886d 100644 --- a/src/handlers/handler_refresh.erl +++ b/src/handlers/handler_refresh.erl @@ -5,7 +5,6 @@ %%%------------------------------------------------------------------- -module(handler_refresh). -behaviour(cowboy_handler). - -export([init/2]). -export([trails/0]). @@ -16,7 +15,7 @@ init(Req, _Opts) -> case cowboy_req:method(Req) of <<"POST">> -> refresh(Req); - _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. %%% Swagger metadata @@ -24,43 +23,63 @@ init(Req, _Opts) -> trails() -> [ #{ - path => <<"/v1/refresh">>, - method => <<"POST">>, - description => <<"Refresh access token using refresh token">>, - tags => [<<"Auth">>], + path => <<"/v1/auth/refresh">>, + method => <<"POST">>, + description => <<"Refresh user access token using refresh token">>, + tags => [<<"Auth">>], requestBody => #{ required => true, - content => #{<<"application/json">> => #{schema => #{ - type => object, - required => [<<"refresh_token">>], - properties => #{ - refresh_token => #{type => string} + content => #{ + <<"application/json">> => #{ + schema => #{ + type => object, + required => [<<"refresh_token">>], + properties => #{refresh_token => #{type => string}} + } } - }}} + } }, responses => #{ - 200 => #{description => <<"New token pair (access + refresh)">>}, + 200 => #{ + description => <<"New user token pair (access + refresh)">>, + content => #{ + <<"application/json">> => #{ + schema => token_pair_schema() + } + } + }, 400 => #{description => <<"Missing refresh_token field or invalid JSON">>}, 401 => #{description => <<"Refresh token expired or not found">>} } } ]. +token_pair_schema() -> + #{ + type => object, + properties => #{ + token => #{type => string, description => <<"JWT access token">>}, + refresh_token => #{type => string, description => <<"Refresh token">>} + } + }. + %%%=================================================================== %%% HTTP-методы %%%=================================================================== -%% @doc POST /v1/refresh — обновление токена. +%% @doc POST /v1/auth/refresh — обновление пользовательского токена. -spec refresh(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. refresh(Req) -> {ok, Body, Req1} = cowboy_req:read_body(Req), try jsx:decode(Body, [return_maps]) of #{<<"refresh_token">> := RefreshToken} -> - case find_and_refresh(RefreshToken) of - {ok, NewTokenPair} -> - handler_utils:send_json(Req1, 200, NewTokenPair); - {error, Reason} -> - handler_utils:send_error(Req1, 401, Reason) + case core_session:validate(RefreshToken) of + {ok, UserId, User} -> + user_refresh(UserId, User, RefreshToken, Req1); + {error, not_found} -> + handler_utils:send_error(Req1, 401, <<"Refresh token not found">>); + {error, expired} -> + handler_utils:send_error(Req1, 401, <<"Refresh token expired">>) end; _ -> handler_utils:send_error(Req1, 400, <<"Missing refresh_token field">>) @@ -72,42 +91,12 @@ refresh(Req) -> %%% Внутренние функции %%%=================================================================== -%% @private Проверяет refresh-токен в таблицах пользовательских и админских сессий. --spec find_and_refresh(binary()) -> {ok, map()} | {error, binary()}. -find_and_refresh(RefreshToken) -> - case core_session:validate(RefreshToken) of - {ok, UserId, User} -> - user_refresh(UserId, User, RefreshToken); - {error, not_found} -> - case core_admin_session:validate(RefreshToken) of - {ok, AdminId} -> - admin_refresh(AdminId, RefreshToken); - {error, not_found} -> - {error, <<"Refresh token not found">>}; - {error, expired} -> - {error, <<"Refresh token expired">>} - end; - {error, expired} -> - {error, <<"Refresh token expired">>} - end. - %% @private Обновляет токен для обычного пользователя. --spec user_refresh(binary(), #user{}, binary()) -> {ok, map()}. -user_refresh(UserId, User, OldToken) -> +-spec user_refresh(binary(), #user{}, binary(), cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +user_refresh(UserId, User, OldToken, Req) -> core_session:delete(OldToken), Role = atom_to_binary(User#user.role, utf8), NewToken = eventhub_auth:generate_user_token(UserId, Role), {NewRefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(UserId), core_session:create(UserId, NewRefreshToken), - {ok, #{token => NewToken, refresh_token => NewRefreshToken}}. - -%% @private Обновляет токен для администратора. --spec admin_refresh(binary(), binary()) -> {ok, map()}. -admin_refresh(AdminId, OldToken) -> - 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), - {ok, #{token => NewToken, refresh_token => NewRefreshToken}}. \ No newline at end of file + handler_utils:send_json(Req, 200, #{token => NewToken, refresh_token => NewRefreshToken}). \ No newline at end of file diff --git a/test/api/admins/admin_refresh_tests.erl b/test/api/admins/admin_refresh_tests.erl new file mode 100644 index 0000000..7a253c1 --- /dev/null +++ b/test/api/admins/admin_refresh_tests.erl @@ -0,0 +1,48 @@ +%%%------------------------------------------------------------------- +%%% @doc Тесты административного API для обновления токена. +%%% Покрывает эндпоинт: +%%% POST /v1/admin/refresh +%%% @end +%%%------------------------------------------------------------------- +-module(admin_refresh_tests). +-include_lib("eunit/include/eunit.hrl"). +-export([test/0]). + +-spec test() -> ok. +test() -> + ct:pal("=== Admin Refresh Tests ==="), + RefreshToken = api_test_runner:get_admin_refresh_token(), + test_successful_refresh(RefreshToken), + test_invalid_refresh_token(), + test_missing_refresh_token(), + ct:pal("=== All admin refresh tests passed ==="), + ok. + +-spec test_successful_refresh(binary()) -> ok. +test_successful_refresh(RefreshToken) -> + ct:pal(" TEST: Successful admin token refresh"), + Resp = api_test_runner:admin_request(post, <<"/v1/admin/refresh">>, <<>>, + jsx:encode(#{refresh_token => RefreshToken})), + {ok, 200, _, Body} = Resp, + #{<<"token">> := NewToken, <<"refresh_token">> := NewRefresh} = + jsx:decode(list_to_binary(Body), [return_maps]), + ?assert(is_binary(NewToken)), + ?assert(is_binary(NewRefresh)), + ?assertNotEqual(RefreshToken, NewRefresh), + ct:pal(" OK: got new admin token pair"). + +-spec test_invalid_refresh_token() -> ok. +test_invalid_refresh_token() -> + ct:pal(" TEST: Invalid admin refresh token"), + Resp = api_test_runner:admin_request(post, <<"/v1/admin/refresh">>, <<>>, + jsx:encode(#{refresh_token => <<"bad">>})), + {ok, 401, _, _} = Resp, + ct:pal(" OK: got 401"). + +-spec test_missing_refresh_token() -> ok. +test_missing_refresh_token() -> + ct:pal(" TEST: Missing refresh_token field for admin"), + Resp = api_test_runner:admin_request(post, <<"/v1/admin/refresh">>, <<>>, + jsx:encode(#{})), + ?assertMatch({ok, 400, _, _}, Resp), + ct:pal(" OK: got 400"). \ No newline at end of file diff --git a/test/api/api_test_runner.erl b/test/api/api_test_runner.erl index ec744cd..ee44343 100644 --- a/test/api/api_test_runner.erl +++ b/test/api/api_test_runner.erl @@ -23,7 +23,7 @@ register_and_login/2, create_calendar/2, create_event/3 -]). + , get_admin_refresh_token/0]). -export([ admin_request/3, admin_request/4, @@ -133,6 +133,15 @@ get_moderator_token() -> get_support_token() -> get_or_login(support, admin_support_email(), admin_support_password()). +-spec get_admin_refresh_token() -> binary(). +get_admin_refresh_token() -> + Email = admin_email(), + Password = admin_password(), + Body = jsx:encode(#{email => Email, password => Password}), + {ok, 200, _, RespBody} = admin_request(post, <<"/v1/admin/login">>, <<>>, Body), + #{<<"refresh_token">> := RefreshToken} = jsx:decode(list_to_binary(RespBody), [return_maps]), + RefreshToken. + -spec get_or_login(atom(), binary(), binary()) -> binary(). get_or_login(Role, Email, Password) -> Key = {?MODULE, admin_token, Role}, diff --git a/test/api_admins_SUITE.erl b/test/api_admins_SUITE.erl index 5539d47..412080e 100644 --- a/test/api_admins_SUITE.erl +++ b/test/api_admins_SUITE.erl @@ -36,6 +36,7 @@ all() -> admin_test_audit, admin_test_stats, admin_test_me, + admin_test_refresh, admin_test_admins, admin_test_websocket ]. @@ -118,6 +119,9 @@ admin_test_stats(_Config) -> admin_test_me(_Config) -> admin_me_tests:test(). +admin_test_refresh(_Config) -> + admin_refresh_tests:test(). + admin_test_admins(_Config) -> admin_admins_tests:test().