diff --git a/include/records.hrl b/include/records.hrl index f8bcfd0..99865d2 100755 --- a/include/records.hrl +++ b/include/records.hrl @@ -78,7 +78,9 @@ expires_at :: calendar:datetime(), revoked :: boolean(), created_at :: calendar:datetime(), - updated_at :: calendar:datetime() + updated_at :: calendar:datetime(), + device_name :: binary(), + user_agent :: binary() }). %% ------------------- Календари --------------------------------------- diff --git a/src/core/core_auth_session.erl b/src/core/core_auth_session.erl index 864d26e..96f3f6d 100755 --- a/src/core/core_auth_session.erl +++ b/src/core/core_auth_session.erl @@ -6,17 +6,30 @@ -module(core_auth_session). -include("records.hrl"). -include_lib("stdlib/include/ms_transform.hrl"). --export([create/3, get/1, rotate/2, revoke/1, revoke_family/1, revoke_all_for_subject/2]). +-export([ + create/3, create/5, get/1, rotate/2, revoke/1, revoke_family/1, + revoke_all_for_subject/2, list_active_for_subject/2, revoke_others_for_subject/3 +]). -define(REFRESH_TTL_SECONDS, 30 * 24 * 3600). %%%------------------------------------------------------------------- -%%% @doc Создать новую сессию устройства/клиента. +%%% @doc Создать новую сессию устройства/клиента (без device meta). %%% @end %%%------------------------------------------------------------------- -spec create(SubjectId :: binary(), SubjectType :: user | admin, ClientType :: binary()) -> {ok, #auth_session{}}. create(SubjectId, SubjectType, ClientType) -> + create(SubjectId, SubjectType, ClientType, <<>>, <<>>). + +%%%------------------------------------------------------------------- +%%% @doc Создать сессию с device_name и user_agent. +%%% @end +%%%------------------------------------------------------------------- +-spec create(SubjectId :: binary(), SubjectType :: user | admin, ClientType :: binary(), + DeviceName :: binary(), UserAgent :: binary()) -> + {ok, #auth_session{}}. +create(SubjectId, SubjectType, ClientType, DeviceName, UserAgent) -> Now = calendar:universal_time(), ExpiresAt = calendar:gregorian_seconds_to_datetime( calendar:datetime_to_gregorian_seconds(Now) + ?REFRESH_TTL_SECONDS @@ -31,7 +44,9 @@ create(SubjectId, SubjectType, ClientType) -> expires_at = ExpiresAt, revoked = false, created_at = Now, - updated_at = Now + updated_at = Now, + device_name = DeviceName, + user_agent = UserAgent }, %% Transaction ensures atomic write of the new session record. case mnesia:transaction(fun() -> mnesia:write(Session) end) of @@ -169,6 +184,56 @@ revoke_all_for_subject(SubjectId, SubjectType) -> {error, transaction_failed} end. +%%%------------------------------------------------------------------- +%%% @doc Активные (не revoked, не expired) сессии субъекта. +%%% @end +%%%------------------------------------------------------------------- +-spec list_active_for_subject(SubjectId :: binary(), SubjectType :: user | admin) -> + [#auth_session{}]. +list_active_for_subject(SubjectId, SubjectType) -> + Now = calendar:universal_time(), + MS = ets:fun2ms(fun(#auth_session{subject_id = Sid} = S) + when Sid =:= SubjectId -> S end), + Sessions = mnesia:dirty_select(auth_session, MS), + lists:filter(fun(S) -> + S#auth_session.subject_type =:= SubjectType andalso + S#auth_session.revoked =:= false andalso + S#auth_session.expires_at > Now + end, Sessions). + +%%%------------------------------------------------------------------- +%%% @doc Отозвать все активные сессии субъекта, кроме KeepSessionId. +%%% @end +%%%------------------------------------------------------------------- +-spec revoke_others_for_subject(SubjectId :: binary(), SubjectType :: user | admin, + KeepSessionId :: binary()) -> + {ok, non_neg_integer()} | {error, transaction_failed}. +revoke_others_for_subject(SubjectId, SubjectType, KeepSessionId) -> + F = fun() -> + MS = ets:fun2ms(fun(#auth_session{subject_id = Sid} = S) + when Sid =:= SubjectId -> S end), + Sessions = mnesia:select(auth_session, MS, write), + Now = calendar:universal_time(), + lists:foldl(fun(Session, Acc) -> + case Session#auth_session.subject_type =:= SubjectType andalso + Session#auth_session.revoked =:= false andalso + Session#auth_session.session_id =/= KeepSessionId of + true -> + mnesia:write(Session#auth_session{revoked = true, updated_at = Now}), + Acc + 1; + false -> + Acc + end + end, 0, Sessions) + end, + case mnesia:transaction(F) of + {atomic, Count} -> + lists:foreach(fun(_) -> dec_counter(SubjectType) end, lists:seq(1, Count)), + {ok, Count}; + {aborted, _Reason} -> + {error, transaction_failed} + end. + %%%=================================================================== %%% Internal %%%=================================================================== diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index f39767b..c14c3e2 100755 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -91,6 +91,9 @@ start_http() -> {"/v1/login", handler_login, []}, {"/v1/refresh", handler_refresh, []}, {"/v1/logout", handler_logout, []}, + {"/v1/sessions", handler_sessions, []}, + {"/v1/sessions/revoke-others", handler_sessions, []}, + {"/v1/sessions/:id", handler_sessions, []}, {"/v1/user/me", handler_user_me, []}, {"/v1/user/me/avatar", handler_user_avatar, []}, {"/v1/notifications/prefs", handler_notification_prefs, []}, diff --git a/src/handlers/admin/admin_handler_login.erl b/src/handlers/admin/admin_handler_login.erl index b7de801..bf5593c 100644 --- a/src/handlers/admin/admin_handler_login.erl +++ b/src/handlers/admin/admin_handler_login.erl @@ -22,7 +22,7 @@ init(Req0, _State) -> {ok, _Token, User} -> UserId = maps:get(id, User), Role = maps:get(role, User), - {ok, Token, RefreshToken} = logic_auth_session:issue_admin_tokens(UserId, Role), + {ok, Token, RefreshToken, SessionId} = logic_auth_session:issue_admin_tokens(UserId, Role), core_admin:update_last_login(UserId), % Аудит успешного входа admin_utils:log_admin_action(UserId, <<"login">>, <<"admin">>, Email, <<>>, Req1), @@ -33,7 +33,8 @@ init(Req0, _State) -> <<"email">> => maps:get(email, User), <<"role">> => maps:get(role, User) }, - <<"refresh_token">> => RefreshToken + <<"refresh_token">> => RefreshToken, + <<"session_id">> => SessionId }, handler_utils:send_json(Req1, 200, Resp); {error, insufficient_permissions} -> diff --git a/src/handlers/admin/admin_handler_refresh.erl b/src/handlers/admin/admin_handler_refresh.erl index a476ce7..de7ace4 100644 --- a/src/handlers/admin/admin_handler_refresh.erl +++ b/src/handlers/admin/admin_handler_refresh.erl @@ -71,10 +71,11 @@ refresh(Req) -> try jsx:decode(Body, [return_maps]) of #{<<"refresh_token">> := RefreshToken} -> case logic_auth_session:refresh_admin(RefreshToken) of - {ok, NewToken, NewRefreshToken} -> + {ok, NewToken, NewRefreshToken, SessionId} -> handler_utils:send_json(Req1, 200, #{ token => NewToken, - refresh_token => NewRefreshToken + refresh_token => NewRefreshToken, + session_id => SessionId }); {error, reuse_detected} -> handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>); diff --git a/src/handlers/handler_login.erl b/src/handlers/handler_login.erl index 8cea354..ff865a7 100644 --- a/src/handlers/handler_login.erl +++ b/src/handlers/handler_login.erl @@ -32,8 +32,10 @@ trails() -> type => object, required => [<<"email">>, <<"password">>], properties => #{ - email => #{type => string, format => <<"email">>}, - password => #{type => string, format => <<"password">>} + email => #{type => string, format => <<"email">>}, + password => #{type => string, format => <<"password">>}, + client_type => #{type => string, enum => [<<"web">>, <<"mobile">>]}, + device_name => #{type => string} } }}} }, @@ -69,23 +71,35 @@ login(Req) -> handler_utils:send_error(Req1, 400, <<"Empty request body">>); _ -> try jsx:decode(Body, [return_maps]) of - #{<<"email">> := Email, <<"password">> := Password} -> + #{<<"email">> := Email, <<"password">> := Password} = Map -> + ClientType = maps:get(<<"client_type">>, Map, <<"web">>), + DeviceName = maps:get(<<"device_name">>, Map, <<>>), + UserAgent = case cowboy_req:header(<<"user-agent">>, Req1) of + undefined -> <<>>; + UA -> UA + end, case eventhub_auth:authenticate_user_request(Req1, Email, Password) of {ok, _Token, User} -> UserId = maps:get(id, User), Role = maps:get(role, User), - {ok, Token, RefreshToken} = logic_auth_session:issue_user_tokens(UserId, Role), - core_user:update_last_login(UserId), - Response = #{ - <<"token">> => Token, - <<"user">> => #{ - <<"id">> => UserId, - <<"email">> => maps:get(email, User), - <<"role">> => maps:get(role, User) - }, - <<"refresh_token">> => RefreshToken - }, - handler_utils:send_json(Req1, 200, Response); + case logic_auth_session:issue_user_tokens( + UserId, Role, ClientType, DeviceName, UserAgent) of + {ok, Token, RefreshToken, SessionId} -> + core_user:update_last_login(UserId), + Response = #{ + <<"token">> => Token, + <<"user">> => #{ + <<"id">> => UserId, + <<"email">> => maps:get(email, User), + <<"role">> => maps:get(role, User) + }, + <<"refresh_token">> => RefreshToken, + <<"session_id">> => SessionId + }, + handler_utils:send_json(Req1, 200, Response); + {error, invalid_client_type} -> + handler_utils:send_error(Req1, 400, <<"Invalid client_type">>) + end; {error, not_verified} -> handler_utils:send_error(Req1, 403, <<"Account not verified">>); {error, frozen} -> @@ -103,4 +117,4 @@ login(Req) -> end; false -> handler_utils:send_error(Req, 400, <<"Missing request body">>) - end. \ No newline at end of file + end. diff --git a/src/handlers/handler_refresh.erl b/src/handlers/handler_refresh.erl index bd6ac39..959001e 100644 --- a/src/handlers/handler_refresh.erl +++ b/src/handlers/handler_refresh.erl @@ -57,7 +57,8 @@ token_pair_schema() -> type => object, properties => #{ token => #{type => string, description => <<"JWT access token">>}, - refresh_token => #{type => string, description => <<"Refresh token">>} + refresh_token => #{type => string, description => <<"Refresh token">>}, + session_id => #{type => string, description => <<"auth_session id">>} } }. @@ -72,10 +73,11 @@ refresh(Req) -> try jsx:decode(Body, [return_maps]) of #{<<"refresh_token">> := RefreshToken} -> case logic_auth_session:refresh_user(RefreshToken) of - {ok, NewToken, NewRefreshToken} -> + {ok, NewToken, NewRefreshToken, SessionId} -> handler_utils:send_json(Req1, 200, #{ token => NewToken, - refresh_token => NewRefreshToken + refresh_token => NewRefreshToken, + session_id => SessionId }); {error, reuse_detected} -> handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>); diff --git a/src/handlers/handler_sessions.erl b/src/handlers/handler_sessions.erl new file mode 100644 index 0000000..fce8195 --- /dev/null +++ b/src/handlers/handler_sessions.erl @@ -0,0 +1,131 @@ +%%%------------------------------------------------------------------- +%%% @doc User session list / revoke (Back#74). +%%% GET /v1/sessions +%%% DELETE /v1/sessions/:id +%%% POST /v1/sessions/revoke-others +%%% @end +%%%------------------------------------------------------------------- +-module(handler_sessions). +-behaviour(cowboy_handler). + +-export([init/2, trails/0]). + +-include("records.hrl"). + +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +init(Req, Opts) -> + Method = cowboy_req:method(Req), + case handler_utils:auth_user(Req) of + {ok, UserId, Req1} -> + case Method of + <<"GET">> -> + list_sessions(Req1, UserId); + <<"POST">> -> + case cowboy_req:path(Req1) of + <<"/v1/sessions/revoke-others">> -> + revoke_others(Req1, UserId); + _ -> + handler_utils:send_error(Req1, 405, <<"Method not allowed">>) + end; + <<"DELETE">> -> + SessionId = cowboy_req:binding(id, Req1), + revoke_one(Req1, UserId, SessionId); + _ -> + handler_utils:send_error(Req1, 405, <<"Method not allowed">>) + end; + {error, Code, Message, Req1} -> + handler_utils:send_error(Req1, Code, Message) + end, + {ok, Req, Opts}. + +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/sessions">>, + method => <<"GET">>, + description => <<"List active user sessions">>, + tags => [<<"Auth">>], + responses => #{ + 200 => #{description => <<"Session list">>}, + 401 => #{description => <<"Unauthorized">>} + } + }, + #{ + path => <<"/v1/sessions/:id">>, + method => <<"DELETE">>, + description => <<"Revoke one own session">>, + tags => [<<"Auth">>], + responses => #{ + 200 => #{description => <<"Revoked">>}, + 401 => #{description => <<"Unauthorized">>}, + 404 => #{description => <<"Not found">>} + } + }, + #{ + path => <<"/v1/sessions/revoke-others">>, + method => <<"POST">>, + description => <<"Revoke all sessions except current (from refresh_token)">>, + tags => [<<"Auth">>], + requestBody => #{ + required => true, + content => #{ + <<"application/json">> => #{ + schema => #{ + type => object, + required => [<<"refresh_token">>], + properties => #{refresh_token => #{type => string}} + } + } + } + }, + responses => #{ + 200 => #{description => <<"Others revoked">>}, + 400 => #{description => <<"Bad request">>}, + 401 => #{description => <<"Unauthorized">>} + } + } + ]. + +list_sessions(Req, UserId) -> + Items = [session_to_json(S) || S <- logic_auth_session:list_user_sessions(UserId)], + handler_utils:send_json(Req, 200, #{<<"sessions">> => Items}). + +revoke_one(Req, UserId, SessionId) when is_binary(SessionId), SessionId =/= <<>> -> + case logic_auth_session:revoke_user_session(UserId, SessionId) of + ok -> + handler_utils:send_json(Req, 200, #{<<"ok">> => true}); + {error, not_found} -> + handler_utils:send_error(Req, 404, <<"Session not found">>) + end; +revoke_one(Req, _UserId, _) -> + handler_utils:send_error(Req, 404, <<"Session not found">>). + +revoke_others(Req0, UserId) -> + {ok, Body, Req} = cowboy_req:read_body(Req0), + try jsx:decode(Body, [return_maps]) of + #{<<"refresh_token">> := RefreshToken} -> + case logic_auth_session:revoke_other_user_sessions(UserId, RefreshToken) of + {ok, Count} -> + handler_utils:send_json(Req, 200, #{<<"ok">> => true, <<"revoked">> => Count}); + {error, forbidden} -> + handler_utils:send_error(Req, 403, <<"Refresh token subject mismatch">>); + {error, _} -> + handler_utils:send_error(Req, 401, <<"Invalid refresh token">>) + end; + _ -> + handler_utils:send_error(Req, 400, <<"Missing refresh_token field">>) + catch + _:_ -> handler_utils:send_error(Req, 400, <<"Invalid JSON">>) + end. + +session_to_json(#auth_session{} = S) -> + #{ + <<"session_id">> => S#auth_session.session_id, + <<"client_type">> => S#auth_session.client_type, + <<"device_name">> => S#auth_session.device_name, + <<"user_agent">> => S#auth_session.user_agent, + <<"created_at">> => handler_utils:datetime_to_iso8601(S#auth_session.created_at), + <<"updated_at">> => handler_utils:datetime_to_iso8601(S#auth_session.updated_at), + <<"expires_at">> => handler_utils:datetime_to_iso8601(S#auth_session.expires_at) + }. diff --git a/src/infra/migration_engine.erl b/src/infra/migration_engine.erl index 2a098a7..3b80305 100755 --- a/src/infra/migration_engine.erl +++ b/src/infra/migration_engine.erl @@ -31,7 +31,8 @@ '20260730120000_recurrence_exception_bag', '20260814193000_waitlist_entry', '20260815200000_push_subscription', - '20260815220000_calendar_share_invite' + '20260815220000_calendar_share_invite', + '20260816180000_auth_session_device' ]). %% ------------------------------ diff --git a/src/logic/logic_auth_session.erl b/src/logic/logic_auth_session.erl index 1556d27..cb596df 100644 --- a/src/logic/logic_auth_session.erl +++ b/src/logic/logic_auth_session.erl @@ -5,55 +5,133 @@ %%%------------------------------------------------------------------- -module(logic_auth_session). -include("records.hrl"). --export([issue_admin_tokens/2, refresh_admin/1, logout_admin/1, - issue_user_tokens/2, refresh_user/1, logout_user/1]). +-export([ + issue_admin_tokens/2, refresh_admin/1, logout_admin/1, + issue_user_tokens/2, issue_user_tokens/5, refresh_user/1, logout_user/1, + list_user_sessions/1, revoke_user_session/2, revoke_other_user_sessions/2, + normalize_user_client_type/1, normalize_device_name/1 +]). -define(USER_CLIENT_WEB, <<"web">>). +-define(USER_CLIENT_MOBILE, <<"mobile">>). +-define(DEVICE_NAME_MAX, 120). %%%------------------------------------------------------------------- %%% Admin (фаза 1) %%%------------------------------------------------------------------- -spec issue_admin_tokens(AdminId :: binary(), Role :: binary()) -> - {ok, AccessToken :: binary(), RefreshToken :: binary()}. + {ok, AccessToken :: binary(), RefreshToken :: binary(), SessionId :: binary()}. issue_admin_tokens(AdminId, Role) -> - issue_tokens(AdminId, admin, <<"admin">>, Role, fun eventhub_auth:generate_admin_token/2, + issue_tokens(AdminId, admin, <<"admin">>, <<>>, <<>>, Role, + fun eventhub_auth:generate_admin_token/2, fun eventhub_auth:generate_admin_refresh_token/5). -spec refresh_admin(RefreshToken :: binary()) -> - {ok, AccessToken :: binary(), NewRefreshToken :: binary()} | + {ok, AccessToken :: binary(), NewRefreshToken :: binary(), SessionId :: binary()} | {error, atom()}. refresh_admin(RefreshToken) -> refresh_subject(RefreshToken, fun eventhub_auth:verify_admin_refresh_token/1, fun core_admin:get_by_id/1, admin, fun eventhub_auth:generate_admin_token/2, - fun eventhub_auth:generate_admin_refresh_token/5, <<"admin">>). + fun eventhub_auth:generate_admin_refresh_token/5). -spec logout_admin(RefreshToken :: binary()) -> ok | {error, atom()}. logout_admin(RefreshToken) -> logout_subject(RefreshToken, fun eventhub_auth:verify_admin_refresh_token/1). %%%------------------------------------------------------------------- -%%% User (фаза 2) +%%% User (фаза 2 / 3) %%%------------------------------------------------------------------- -spec issue_user_tokens(UserId :: binary(), Role :: binary()) -> - {ok, AccessToken :: binary(), RefreshToken :: binary()}. + {ok, AccessToken :: binary(), RefreshToken :: binary(), SessionId :: binary()}. issue_user_tokens(UserId, Role) -> - issue_tokens(UserId, user, ?USER_CLIENT_WEB, Role, fun eventhub_auth:generate_user_token/2, - fun eventhub_auth:generate_user_refresh_token/5). + issue_user_tokens(UserId, Role, ?USER_CLIENT_WEB, <<>>, <<>>). + +-spec issue_user_tokens(UserId :: binary(), Role :: binary(), + ClientType :: binary(), DeviceName :: binary(), + UserAgent :: binary()) -> + {ok, AccessToken :: binary(), RefreshToken :: binary(), SessionId :: binary()} | + {error, invalid_client_type}. +issue_user_tokens(UserId, Role, ClientType0, DeviceName0, UserAgent0) -> + case normalize_user_client_type(ClientType0) of + {ok, ClientType} -> + DeviceName = normalize_device_name(DeviceName0), + UserAgent = case UserAgent0 of + U when is_binary(U) -> U; + _ -> <<>> + end, + issue_tokens(UserId, user, ClientType, DeviceName, UserAgent, Role, + fun eventhub_auth:generate_user_token/2, + fun eventhub_auth:generate_user_refresh_token/5); + {error, _} = Err -> + Err + end. -spec refresh_user(RefreshToken :: binary()) -> - {ok, AccessToken :: binary(), NewRefreshToken :: binary()} | + {ok, AccessToken :: binary(), NewRefreshToken :: binary(), SessionId :: binary()} | {error, atom()}. refresh_user(RefreshToken) -> refresh_subject(RefreshToken, fun eventhub_auth:verify_user_refresh_token/1, 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). + fun eventhub_auth:generate_user_refresh_token/5). -spec logout_user(RefreshToken :: binary()) -> ok | {error, atom()}. logout_user(RefreshToken) -> logout_subject(RefreshToken, fun eventhub_auth:verify_user_refresh_token/1). +-spec list_user_sessions(UserId :: binary()) -> [#auth_session{}]. +list_user_sessions(UserId) -> + core_auth_session:list_active_for_subject(UserId, user). + +-spec revoke_user_session(UserId :: binary(), SessionId :: binary()) -> + ok | {error, not_found}. +revoke_user_session(UserId, SessionId) -> + case core_auth_session:get(SessionId) of + {ok, #auth_session{subject_id = UserId, subject_type = user}} -> + core_auth_session:revoke(SessionId); + {ok, _} -> + {error, not_found}; + {error, not_found} -> + {error, not_found} + end. + +-spec revoke_other_user_sessions(UserId :: binary(), RefreshToken :: binary()) -> + {ok, non_neg_integer()} | {error, atom()}. +revoke_other_user_sessions(UserId, RefreshToken) -> + case eventhub_auth:verify_user_refresh_token(RefreshToken) of + {ok, Claims} -> + case maps:get(<<"sub">>, Claims) of + UserId -> + SessionId = maps:get(<<"sid">>, Claims), + core_auth_session:revoke_others_for_subject(UserId, user, SessionId); + _ -> + {error, forbidden} + end; + {error, _} = Err -> + Err + end. + +-spec normalize_user_client_type(term()) -> {ok, binary()} | {error, invalid_client_type}. +normalize_user_client_type(undefined) -> {ok, ?USER_CLIENT_WEB}; +normalize_user_client_type(null) -> {ok, ?USER_CLIENT_WEB}; +normalize_user_client_type(<<>>) -> {ok, ?USER_CLIENT_WEB}; +normalize_user_client_type(?USER_CLIENT_WEB) -> {ok, ?USER_CLIENT_WEB}; +normalize_user_client_type(?USER_CLIENT_MOBILE) -> {ok, ?USER_CLIENT_MOBILE}; +normalize_user_client_type(<<"WEB">>) -> {ok, ?USER_CLIENT_WEB}; +normalize_user_client_type(<<"MOBILE">>) -> {ok, ?USER_CLIENT_MOBILE}; +normalize_user_client_type(_) -> {error, invalid_client_type}. + +-spec normalize_device_name(term()) -> binary(). +normalize_device_name(Name) when is_binary(Name) -> + Trimmed = string:trim(Name), + case byte_size(Trimmed) > ?DEVICE_NAME_MAX of + true -> binary:part(Trimmed, 0, ?DEVICE_NAME_MAX); + false -> Trimmed + end; +normalize_device_name(_) -> + <<>>. + %%%=================================================================== %%% Internal %%%=================================================================== @@ -71,8 +149,9 @@ logout_subject(RefreshToken, VerifyFn) -> Err end. -issue_tokens(SubjectId, SubjectType, ClientType, Role, GenAccess, GenRefresh) -> - {ok, Session} = core_auth_session:create(SubjectId, SubjectType, ClientType), +issue_tokens(SubjectId, SubjectType, ClientType, DeviceName, UserAgent, Role, GenAccess, GenRefresh) -> + {ok, Session} = core_auth_session:create( + SubjectId, SubjectType, ClientType, DeviceName, UserAgent), AccessToken = GenAccess(SubjectId, Role), {RefreshToken, _} = GenRefresh( Session#auth_session.session_id, @@ -81,28 +160,29 @@ issue_tokens(SubjectId, SubjectType, ClientType, Role, GenAccess, GenRefresh) -> SubjectId, ClientType ), - {ok, AccessToken, RefreshToken}. + {ok, AccessToken, RefreshToken, Session#auth_session.session_id}. -refresh_subject(RefreshToken, VerifyFn, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType) -> +refresh_subject(RefreshToken, VerifyFn, GetSubjectFn, SubjectType, GenAccess, GenRefresh) -> case VerifyFn(RefreshToken) of {ok, Claims} -> - do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType); + do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh); Error -> Error end. -do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType) -> +do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh) -> SessionId = maps:get(<<"sid">>, Claims), Jti = maps:get(<<"jti">>, Claims), SubjectId = maps:get(<<"sub">>, Claims), FamilyId = maps:get(<<"fid">>, Claims), case core_auth_session:rotate(SessionId, Jti) of - {ok, NewJti, _Session} -> + {ok, NewJti, Session} -> {ok, Subject} = GetSubjectFn(SubjectId), Role = subject_role(Subject, SubjectType), + ClientType = Session#auth_session.client_type, AccessToken = GenAccess(SubjectId, Role), {NewRefresh, _} = GenRefresh(SessionId, FamilyId, NewJti, SubjectId, ClientType), - {ok, AccessToken, NewRefresh}; + {ok, AccessToken, NewRefresh, SessionId}; {error, reuse_detected} -> core_auth_session:revoke_family(FamilyId), {error, reuse_detected}; diff --git a/src/migrations/20260816180000_auth_session_device.erl b/src/migrations/20260816180000_auth_session_device.erl new file mode 100644 index 0000000..a4073c6 --- /dev/null +++ b/src/migrations/20260816180000_auth_session_device.erl @@ -0,0 +1,45 @@ +%% @doc Add device_name and user_agent to auth_session (Back#74). +-module('20260816180000_auth_session_device'). + +-export([up/0, down/0]). + +-include("records.hrl"). + +up() -> + case table_exists(auth_session) of + false -> + ok; + true -> + ensure_device_fields(), + ok + end. + +down() -> + ok. + +table_exists(Table) -> + try lists:member(Table, mnesia:system_info(tables)) + catch + _:_ -> false + end. + +ensure_device_fields() -> + Attrs = mnesia:table_info(auth_session, attributes), + case lists:member(device_name, Attrs) andalso lists:member(user_agent, Attrs) of + true -> + ok; + false -> + Fun = fun(Rec) -> + case tuple_size(Rec) of + %% auth_session atom + 10 fields (pre-device) + 11 -> + list_to_tuple(tuple_to_list(Rec) ++ [<<>>, <<>>]); + _ -> + Rec + end + end, + case mnesia:transform_table(auth_session, Fun, record_info(fields, auth_session)) of + {atomic, ok} -> ok; + {aborted, Reason} -> error({transform_auth_session_failed, Reason}) + end + end. diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index 188e73d..def1d49 100755 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -71,6 +71,7 @@ user() -> handler_login, handler_refresh, handler_logout, + handler_sessions, handler_verify, handler_forgot_password, handler_reset_password, diff --git a/test/unit/admin_handler_refresh_tests.erl b/test/unit/admin_handler_refresh_tests.erl index 7d6a485..10e716c 100644 --- a/test/unit/admin_handler_refresh_tests.erl +++ b/test/unit/admin_handler_refresh_tests.erl @@ -31,7 +31,7 @@ admin_refresh_test_() -> ]}. test_refresh_ok() -> - {ok, _Access, Refresh} = logic_auth_session:issue_admin_tokens(?ADMIN_ID, <<"admin">>), + {ok, _Access, Refresh, _} = logic_auth_session:issue_admin_tokens(?ADMIN_ID, <<"admin">>), {Status, _, Body} = eh_test_support:call(admin_handler_refresh, #{ method => <<"POST">>, path => <<"/v1/admin/refresh">>, diff --git a/test/unit/core_auth_session_tests.erl b/test/unit/core_auth_session_tests.erl index 1036f3f..ae59f41 100644 --- a/test/unit/core_auth_session_tests.erl +++ b/test/unit/core_auth_session_tests.erl @@ -28,9 +28,11 @@ cleanup(_) -> core_auth_session_test_() -> {foreach, fun setup/0, fun cleanup/1, [ {"Create session", fun test_create/0}, + {"Create with device", fun test_create_device/0}, {"Rotate session updates jti", fun test_rotate/0}, {"Reuse detection", fun test_reuse/0}, - {"Revoke family", fun test_revoke_family/0} + {"Revoke family", fun test_revoke_family/0}, + {"List active and revoke others", fun test_list_and_revoke_others/0} ]}. test_create() -> @@ -38,9 +40,16 @@ test_create() -> ?assertEqual(<<"admin1">>, Session#auth_session.subject_id), ?assertEqual(admin, Session#auth_session.subject_type), ?assertEqual(false, Session#auth_session.revoked), + ?assertEqual(<<>>, Session#auth_session.device_name), {ok, Loaded} = core_auth_session:get(Session#auth_session.session_id), ?assertEqual(Session#auth_session.session_id, Loaded#auth_session.session_id). +test_create_device() -> + {ok, Session} = core_auth_session:create( + <<"u1">>, user, <<"web">>, <<"Chrome · Win">>, <<"Mozilla">>), + ?assertEqual(<<"Chrome · Win">>, Session#auth_session.device_name), + ?assertEqual(<<"Mozilla">>, Session#auth_session.user_agent). + test_rotate() -> {ok, Session} = core_auth_session:create(<<"admin1">>, admin, <<"admin">>), OldJti = Session#auth_session.current_jti, @@ -61,3 +70,16 @@ test_revoke_family() -> 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)). + +test_list_and_revoke_others() -> + {ok, Keep} = core_auth_session:create(<<"u1">>, user, <<"web">>, <<"A">>, <<"ua">>), + {ok, Other} = core_auth_session:create(<<"u1">>, user, <<"mobile">>, <<"B">>, <<"ub">>), + Active = core_auth_session:list_active_for_subject(<<"u1">>, user), + ?assertEqual(2, length(Active)), + {ok, 1} = core_auth_session:revoke_others_for_subject( + <<"u1">>, user, Keep#auth_session.session_id), + Left = core_auth_session:list_active_for_subject(<<"u1">>, user), + ?assertEqual(1, length(Left)), + ?assertEqual(Keep#auth_session.session_id, (hd(Left))#auth_session.session_id), + {ok, Revoked} = core_auth_session:get(Other#auth_session.session_id), + ?assertEqual(true, Revoked#auth_session.revoked). diff --git a/test/unit/infra_cleanup_tests.erl b/test/unit/infra_cleanup_tests.erl index cf34223..9eb0c1e 100644 --- a/test/unit/infra_cleanup_tests.erl +++ b/test/unit/infra_cleanup_tests.erl @@ -110,7 +110,9 @@ write_auth_session(Id, Revoked, ExpiresAt, CreatedAt) -> expires_at = ExpiresAt, revoked = Revoked, created_at = CreatedAt, - updated_at = CreatedAt + updated_at = CreatedAt, + device_name = <<>>, + user_agent = <<>> }). write_admin_audit(Id, Timestamp) -> diff --git a/test/unit/logic_auth_session_tests.erl b/test/unit/logic_auth_session_tests.erl index 8410528..6409f68 100644 --- a/test/unit/logic_auth_session_tests.erl +++ b/test/unit/logic_auth_session_tests.erl @@ -39,7 +39,9 @@ logic_auth_session_test_() -> {"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}} + {"Logout admin then refresh fails", {timeout, 120, fun test_logout_admin/0}}, + {"Mobile device meta and list", {timeout, 120, fun test_mobile_device_and_list/0}}, + {"Revoke others keeps current", {timeout, 120, fun test_revoke_others/0}} ]}. test_issue_and_refresh_admin() -> @@ -54,10 +56,12 @@ test_issue_and_refresh_admin() -> 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">>), + {ok, Access1, Refresh1, Sid1} = logic_auth_session:issue_admin_tokens(AdminId, <<"superadmin">>), ?assert(is_binary(Access1)), + ?assert(is_binary(Sid1)), {ok, _, _} = eventhub_auth:verify_admin_token(Access1), - {ok, Access2, Refresh2} = logic_auth_session:refresh_admin(Refresh1), + {ok, Access2, Refresh2, Sid2} = logic_auth_session:refresh_admin(Refresh1), + ?assertEqual(Sid1, Sid2), ?assert(is_binary(Access2)), ?assertNotEqual(Refresh1, Refresh2), {ok, _, _} = eventhub_auth:verify_admin_token(Access2). @@ -72,8 +76,8 @@ test_admin_reuse_revokes() -> 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), + {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)). @@ -89,13 +93,14 @@ test_issue_and_refresh_user() -> ok = meck:expect(core_user, get_by_id, fun(Id) -> ?assertEqual(UserId, Id), {ok, User} end), - {ok, Access1, Refresh1} = logic_auth_session:issue_user_tokens(UserId, <<"user">>), + {ok, Access1, Refresh1, Sid1} = logic_auth_session:issue_user_tokens(UserId, <<"user">>), ?assert(is_binary(Access1)), {ok, _, _} = eventhub_auth:verify_user_token(Access1), {ok, Claims} = eventhub_auth:verify_user_refresh_token(Refresh1), ?assertEqual(<<"user">>, maps:get(<<"aud">>, Claims)), ?assertEqual(<<"web">>, maps:get(<<"client">>, Claims)), - {ok, Access2, Refresh2} = logic_auth_session:refresh_user(Refresh1), + {ok, Access2, Refresh2, Sid2} = logic_auth_session:refresh_user(Refresh1), + ?assertEqual(Sid1, Sid2), ?assert(is_binary(Access2)), ?assertNotEqual(Refresh1, Refresh2), {ok, _, _} = eventhub_auth:verify_user_token(Access2). @@ -110,8 +115,8 @@ test_user_reuse_revokes() -> created_at = undefined, updated_at = undefined }, ok = meck:expect(core_user, get_by_id, fun(_) -> {ok, User} end), - {ok, _, Refresh1} = logic_auth_session:issue_user_tokens(UserId, <<"user">>), - {ok, _, Refresh2} = logic_auth_session:refresh_user(Refresh1), + {ok, _, Refresh1, _} = logic_auth_session:issue_user_tokens(UserId, <<"user">>), + {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)). @@ -125,7 +130,7 @@ test_logout_user() -> 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">>), + {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)). @@ -139,6 +144,51 @@ test_logout_admin() -> 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">>), + {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)). + +test_mobile_device_and_list() -> + UserId = <<"usr_dev">>, + User = #user{ + id = UserId, email = <<"d@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), + LongName = list_to_binary(lists:duplicate(200, $x)), + {ok, _, Refresh, Sid} = logic_auth_session:issue_user_tokens( + UserId, <<"user">>, <<"mobile">>, LongName, <<"UA-Test">>), + {ok, Claims} = eventhub_auth:verify_user_refresh_token(Refresh), + ?assertEqual(<<"mobile">>, maps:get(<<"client">>, Claims)), + [S] = logic_auth_session:list_user_sessions(UserId), + ?assertEqual(Sid, S#auth_session.session_id), + ?assertEqual(<<"mobile">>, S#auth_session.client_type), + ?assertEqual(<<"UA-Test">>, S#auth_session.user_agent), + ?assertEqual(120, byte_size(S#auth_session.device_name)), + ?assertMatch({error, invalid_client_type}, + logic_auth_session:issue_user_tokens(UserId, <<"user">>, <<"tablet">>, <<>>, <<>>)). + +test_revoke_others() -> + UserId = <<"usr_rev">>, + User = #user{ + id = UserId, email = <<"r@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, _, RefreshKeep, KeepId} = logic_auth_session:issue_user_tokens( + UserId, <<"user">>, <<"web">>, <<"Keep">>, <<"ua1">>), + {ok, _, _RefreshOther, OtherId} = logic_auth_session:issue_user_tokens( + UserId, <<"user">>, <<"web">>, <<"Other">>, <<"ua2">>), + {ok, 1} = logic_auth_session:revoke_other_user_sessions(UserId, RefreshKeep), + ?assertEqual(ok, logic_auth_session:revoke_user_session(UserId, OtherId)), + Active = logic_auth_session:list_user_sessions(UserId), + ?assertEqual(1, length(Active)), + ?assertEqual(KeepId, (hd(Active))#auth_session.session_id), + ?assertMatch({error, not_found}, + logic_auth_session:revoke_user_session(UserId, <<"nope">>)).