From 347a645933b85239ae8fd75474d22f5eb447e149 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Fri, 14 Aug 2026 20:23:16 +0300 Subject: [PATCH] feat(auth): POST /v1/logout and admin logout revoke refresh session. Refs EventHub/EventHubBack#69 --- src/eventhub_app.erl | 2 + src/handlers/admin/admin_handler_logout.erl | 58 +++++++++++++++++++++ src/handlers/handler_logout.erl | 58 +++++++++++++++++++++ src/logic/logic_auth_session.erl | 25 ++++++++- src/swagger/eventhub_trails.erl | 2 + test/unit/logic_auth_session_tests.erl | 38 ++++++++++++-- 6 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 src/handlers/admin/admin_handler_logout.erl create mode 100644 src/handlers/handler_logout.erl diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 9996b1e..2688f6f 100755 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -89,6 +89,7 @@ start_http() -> {"/v1/reset-password", handler_reset_password, []}, {"/v1/login", handler_login, []}, {"/v1/refresh", handler_refresh, []}, + {"/v1/logout", handler_logout, []}, {"/v1/user/me", handler_user_me, []}, {"/v1/user/bookings", handler_user_bookings, []}, {"/v1/user/booking-requests", handler_user_booking_requests, []}, @@ -147,6 +148,7 @@ start_admin_http() -> {"/v1/admin/nodes/metrics", admin_handler_node_metrics, []}, {"/v1/admin/login", admin_handler_login, []}, {"/v1/admin/refresh", admin_handler_refresh, []}, + {"/v1/admin/logout", admin_handler_logout, []}, % ================== ПОЛЬЗОВАТЕЛИ ================== {"/v1/admin/users", admin_handler_users, []}, {"/v1/admin/users/stats", admin_handler_user_stats, []}, diff --git a/src/handlers/admin/admin_handler_logout.erl b/src/handlers/admin/admin_handler_logout.erl new file mode 100644 index 0000000..9bc5f07 --- /dev/null +++ b/src/handlers/admin/admin_handler_logout.erl @@ -0,0 +1,58 @@ +%%%------------------------------------------------------------------- +%%% @doc POST /v1/admin/logout — отзыв admin refresh-сессии (вариант A / Back#69). +%%% @end +%%%------------------------------------------------------------------- +-module(admin_handler_logout). +-behaviour(cowboy_handler). +-export([init/2, trails/0]). + +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> logout(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/admin/logout">>, + method => <<"POST">>, + description => <<"Revoke current admin refresh session">>, + tags => [<<"Admin Auth">>], + requestBody => #{ + required => true, + content => #{ + <<"application/json">> => #{ + schema => #{ + type => object, + required => [<<"refresh_token">>], + properties => #{refresh_token => #{type => string}} + } + } + } + }, + responses => #{ + 200 => #{description => <<"Session revoked">>}, + 400 => #{description => <<"Missing refresh_token or invalid JSON">>}, + 401 => #{description => <<"Invalid refresh token">>} + } + } + ]. + +logout(Req) -> + {ok, Body, Req1} = cowboy_req:read_body(Req), + try jsx:decode(Body, [return_maps]) of + #{<<"refresh_token">> := RefreshToken} -> + case logic_auth_session:logout_admin(RefreshToken) of + ok -> + handler_utils:send_json(Req1, 200, #{ok => true}); + {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. diff --git a/src/handlers/handler_logout.erl b/src/handlers/handler_logout.erl new file mode 100644 index 0000000..4cc74ee --- /dev/null +++ b/src/handlers/handler_logout.erl @@ -0,0 +1,58 @@ +%%%------------------------------------------------------------------- +%%% @doc POST /v1/logout — отзыв user refresh-сессии (вариант A / Back#69). +%%% @end +%%%------------------------------------------------------------------- +-module(handler_logout). +-behaviour(cowboy_handler). +-export([init/2, trails/0]). + +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> logout(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/logout">>, + method => <<"POST">>, + description => <<"Revoke current user refresh session">>, + tags => [<<"Auth">>], + requestBody => #{ + required => true, + content => #{ + <<"application/json">> => #{ + schema => #{ + type => object, + required => [<<"refresh_token">>], + properties => #{refresh_token => #{type => string}} + } + } + } + }, + responses => #{ + 200 => #{description => <<"Session revoked">>}, + 400 => #{description => <<"Missing refresh_token or invalid JSON">>}, + 401 => #{description => <<"Invalid refresh token">>} + } + } + ]. + +logout(Req) -> + {ok, Body, Req1} = cowboy_req:read_body(Req), + try jsx:decode(Body, [return_maps]) of + #{<<"refresh_token">> := RefreshToken} -> + case logic_auth_session:logout_user(RefreshToken) of + ok -> + handler_utils:send_json(Req1, 200, #{ok => true}); + {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. diff --git a/src/logic/logic_auth_session.erl b/src/logic/logic_auth_session.erl index 0f875d0..1556d27 100644 --- a/src/logic/logic_auth_session.erl +++ b/src/logic/logic_auth_session.erl @@ -5,8 +5,8 @@ %%%------------------------------------------------------------------- -module(logic_auth_session). -include("records.hrl"). --export([issue_admin_tokens/2, refresh_admin/1, - issue_user_tokens/2, refresh_user/1]). +-export([issue_admin_tokens/2, refresh_admin/1, logout_admin/1, + issue_user_tokens/2, refresh_user/1, logout_user/1]). -define(USER_CLIENT_WEB, <<"web">>). @@ -28,6 +28,10 @@ refresh_admin(RefreshToken) -> fun core_admin:get_by_id/1, admin, fun eventhub_auth:generate_admin_token/2, fun eventhub_auth:generate_admin_refresh_token/5, <<"admin">>). +-spec logout_admin(RefreshToken :: binary()) -> ok | {error, atom()}. +logout_admin(RefreshToken) -> + logout_subject(RefreshToken, fun eventhub_auth:verify_admin_refresh_token/1). + %%%------------------------------------------------------------------- %%% User (фаза 2) %%%------------------------------------------------------------------- @@ -46,10 +50,27 @@ refresh_user(RefreshToken) -> fun core_user:get_by_id/1, user, fun eventhub_auth:generate_user_token/2, fun eventhub_auth:generate_user_refresh_token/5, ?USER_CLIENT_WEB). +-spec logout_user(RefreshToken :: binary()) -> ok | {error, atom()}. +logout_user(RefreshToken) -> + logout_subject(RefreshToken, fun eventhub_auth:verify_user_refresh_token/1). + %%%=================================================================== %%% Internal %%%=================================================================== +logout_subject(RefreshToken, VerifyFn) -> + case VerifyFn(RefreshToken) of + {ok, Claims} -> + SessionId = maps:get(<<"sid">>, Claims), + case core_auth_session:revoke(SessionId) of + ok -> ok; + {error, not_found} -> ok; + {error, _} = Err -> Err + end; + {error, _} = Err -> + Err + end. + issue_tokens(SubjectId, SubjectType, ClientType, Role, GenAccess, GenRefresh) -> {ok, Session} = core_auth_session:create(SubjectId, SubjectType, ClientType), AccessToken = GenAccess(SubjectId, Role), diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index 8ce5e27..15eb834 100755 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -14,6 +14,7 @@ admin() -> admin_handler_node_metrics, admin_handler_login, admin_handler_refresh, + admin_handler_logout, % ================== ПОЛЬЗОВАТЕЛИ ================== admin_handler_users, admin_handler_user_by_id, @@ -69,6 +70,7 @@ user() -> handler_register, handler_login, handler_refresh, + handler_logout, handler_verify, handler_forgot_password, handler_reset_password, diff --git a/test/unit/logic_auth_session_tests.erl b/test/unit/logic_auth_session_tests.erl index 779d91f..8410528 100644 --- a/test/unit/logic_auth_session_tests.erl +++ b/test/unit/logic_auth_session_tests.erl @@ -34,10 +34,12 @@ cleanup(_) -> logic_auth_session_test_() -> {foreach, fun setup/0, fun cleanup/1, [ - {"Issue and refresh admin tokens", fun test_issue_and_refresh_admin/0}, - {"Refresh admin reuse revokes family", fun test_admin_reuse_revokes/0}, - {"Issue and refresh user tokens", fun test_issue_and_refresh_user/0}, - {"Refresh user reuse revokes family", fun test_user_reuse_revokes/0} + {"Issue and refresh admin tokens", {timeout, 120, fun test_issue_and_refresh_admin/0}}, + {"Refresh admin reuse revokes family", {timeout, 120, fun test_admin_reuse_revokes/0}}, + {"Issue and refresh user tokens", {timeout, 120, fun test_issue_and_refresh_user/0}}, + {"Refresh user reuse revokes family", {timeout, 120, fun test_user_reuse_revokes/0}}, + {"Logout user then refresh fails", {timeout, 120, fun test_logout_user/0}}, + {"Logout admin then refresh fails", {timeout, 120, fun test_logout_admin/0}} ]}. test_issue_and_refresh_admin() -> @@ -112,3 +114,31 @@ test_user_reuse_revokes() -> {ok, _, Refresh2} = logic_auth_session:refresh_user(Refresh1), ?assertMatch({error, reuse_detected}, logic_auth_session:refresh_user(Refresh1)), ?assertMatch({error, revoked}, logic_auth_session:refresh_user(Refresh2)). + +test_logout_user() -> + UserId = <<"usr_logout">>, + User = #user{ + id = UserId, email = <<"ul@test.com">>, role = user, + 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_user, get_by_id, fun(_) -> {ok, User} end), + {ok, _, Refresh} = logic_auth_session:issue_user_tokens(UserId, <<"user">>), + ?assertEqual(ok, logic_auth_session:logout_user(Refresh)), + ?assertMatch({error, revoked}, logic_auth_session:refresh_user(Refresh)). + +test_logout_admin() -> + AdminId = <<"adm_logout">>, + Admin = #admin{ + id = AdminId, email = <<"al@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, _, Refresh} = logic_auth_session:issue_admin_tokens(AdminId, <<"admin">>), + ?assertEqual(ok, logic_auth_session:logout_admin(Refresh)), + ?assertMatch({error, revoked}, logic_auth_session:refresh_admin(Refresh)).