fix(auth): make auth_session mutations transactional

Wrap create/rotate/revoke paths in mnesia transactions so concurrent
refresh and family revoke stay atomic; align revoke_* specs with errors.
This commit is contained in:
2026-08-03 22:55:52 +03:00
parent e3467e7412
commit d88689f1a3
+75 -23
View File
@@ -5,6 +5,7 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(core_auth_session). -module(core_auth_session).
-include("records.hrl"). -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, get/1, rotate/2, revoke/1, revoke_family/1, revoke_all_for_subject/2]).
-define(REFRESH_TTL_SECONDS, 30 * 24 * 3600). -define(REFRESH_TTL_SECONDS, 30 * 24 * 3600).
@@ -32,9 +33,14 @@ create(SubjectId, SubjectType, ClientType) ->
created_at = Now, created_at = Now,
updated_at = Now updated_at = Now
}, },
mnesia:dirty_write(Session), %% Transaction ensures atomic write of the new session record.
case mnesia:transaction(fun() -> mnesia:write(Session) end) of
{atomic, ok} ->
inc_counter(SubjectType), inc_counter(SubjectType),
{ok, Session}. {ok, Session};
{aborted, Reason} ->
{error, Reason}
end.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Получить сессию по идентификатору. %%% @doc Получить сессию по идентификатору.
@@ -56,11 +62,19 @@ get(SessionId) ->
{ok, NewJti :: binary(), #auth_session{}} | {ok, NewJti :: binary(), #auth_session{}} |
{error, not_found | expired | revoked | reuse_detected}. {error, not_found | expired | revoked | reuse_detected}.
rotate(SessionId, PresentedJti) -> rotate(SessionId, PresentedJti) ->
case mnesia:dirty_read({auth_session, SessionId}) of %% Transaction prevents concurrent rotate from racing on the same session
%% (e.g. two clients presenting the same jti simultaneously).
F = fun() ->
case mnesia:read(auth_session, SessionId, write) of
[Session] -> [Session] ->
rotate_session(Session, PresentedJti); rotate_session(Session, PresentedJti);
[] -> [] ->
{error, not_found} {error, not_found}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, _Reason} -> {error, transaction_failed}
end. end.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
@@ -69,60 +83,98 @@ rotate(SessionId, PresentedJti) ->
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec revoke(SessionId :: binary()) -> ok | {error, not_found}. -spec revoke(SessionId :: binary()) -> ok | {error, not_found}.
revoke(SessionId) -> revoke(SessionId) ->
case mnesia:dirty_read({auth_session, SessionId}) of %% Transaction ensures the read-then-write revocation is atomic.
F = fun() ->
case mnesia:read(auth_session, SessionId, write) of
[Session] when Session#auth_session.revoked =:= true -> [Session] when Session#auth_session.revoked =:= true ->
ok; ok;
[Session] -> [Session] ->
Now = calendar:universal_time(), Now = calendar:universal_time(),
mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}), mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
dec_counter(Session#auth_session.subject_type), {revoked, Session#auth_session.subject_type};
ok;
[] -> [] ->
{error, not_found} {error, not_found}
end
end,
case mnesia:transaction(F) of
{atomic, ok} -> ok;
{atomic, {revoked, SubjectType}} ->
dec_counter(SubjectType),
ok;
{atomic, {error, _} = Err} -> Err;
{aborted, _Reason} -> {error, transaction_failed}
end. end.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Отозвать все сессии семейства (reuse attack / принудительный logout). %%% @doc Отозвать все сессии семейства (reuse attack / принудительный logout).
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec revoke_family(FamilyId :: binary()) -> ok. -spec revoke_family(FamilyId :: binary()) -> ok | {error, transaction_failed}.
revoke_family(FamilyId) -> revoke_family(FamilyId) ->
Sessions = mnesia:dirty_index_read(auth_session, FamilyId, #auth_session.family_id), %% Transaction ensures all sessions in the family are revoked atomically,
%% preventing partial revocation under concurrent access.
F = fun() ->
Sessions = mnesia:index_read(auth_session, FamilyId, #auth_session.family_id),
Now = calendar:universal_time(), Now = calendar:universal_time(),
lists:foreach(fun(Session) -> RevokedTypes = lists:filtermap(fun(Session) ->
case Session#auth_session.revoked of case Session#auth_session.revoked of
true -> ok; true -> false;
false -> false ->
mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}), mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
dec_counter(Session#auth_session.subject_type) {true, Session#auth_session.subject_type}
end end
end, Sessions), end, Sessions),
ok. RevokedTypes
end,
case mnesia:transaction(F) of
{atomic, Types} ->
%% Decrement counters outside the transaction
lists:foreach(fun dec_counter/1, Types),
ok;
{aborted, _Reason} ->
{error, transaction_failed}
end.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Отозвать все сессии субъекта (например после сброса пароля). %%% @doc Отозвать все сессии субъекта (например после сброса пароля).
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec revoke_all_for_subject(SubjectId :: binary(), SubjectType :: user | admin) -> ok. -spec revoke_all_for_subject(SubjectId :: binary(), SubjectType :: user | admin) ->
ok | {error, transaction_failed}.
revoke_all_for_subject(SubjectId, SubjectType) -> revoke_all_for_subject(SubjectId, SubjectType) ->
Sessions = mnesia:dirty_match_object(#auth_session{subject_id = SubjectId, _ = '_'}), %% Transaction ensures all matching sessions are revoked atomically.
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(), Now = calendar:universal_time(),
lists:foreach(fun(Session) -> Count = lists:foldl(fun(Session, Acc) ->
case Session#auth_session.subject_type =:= SubjectType andalso case Session#auth_session.subject_type =:= SubjectType andalso
Session#auth_session.revoked =:= false of Session#auth_session.revoked =:= false of
true -> true ->
mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}), mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
dec_counter(Session#auth_session.subject_type); Acc + 1;
false -> false ->
ok Acc
end end
end, Sessions), end, 0, Sessions),
ok. Count
end,
case mnesia:transaction(F) of
{atomic, Count} ->
%% Decrement counters outside the transaction
lists:foreach(fun(_) -> dec_counter(SubjectType) end, lists:seq(1, Count)),
ok;
{aborted, _Reason} ->
{error, transaction_failed}
end.
%%%=================================================================== %%%===================================================================
%%% Internal %%% Internal
%%%=================================================================== %%%===================================================================
%% NOTE: Called inside a mnesia:transaction/1 from rotate/2.
%% Uses mnesia:read/3 and mnesia:write/1 (not dirty_ variants).
rotate_session(Session, PresentedJti) -> rotate_session(Session, PresentedJti) ->
case Session#auth_session.revoked of case Session#auth_session.revoked of
true -> true ->
@@ -140,7 +192,7 @@ rotate_session(Session, PresentedJti) ->
current_jti = NewJti, current_jti = NewJti,
updated_at = Now updated_at = Now
}, },
mnesia:dirty_write(Updated), mnesia:write(Updated),
{ok, NewJti, Updated}; {ok, NewJti, Updated};
false -> false ->
{error, reuse_detected} {error, reuse_detected}