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
+96 -44
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.
inc_counter(SubjectType), case mnesia:transaction(fun() -> mnesia:write(Session) end) of
{ok, Session}. {atomic, ok} ->
inc_counter(SubjectType),
{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
[Session] -> %% (e.g. two clients presenting the same jti simultaneously).
rotate_session(Session, PresentedJti); F = fun() ->
[] -> case mnesia:read(auth_session, SessionId, write) of
{error, not_found} [Session] ->
rotate_session(Session, PresentedJti);
[] ->
{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.
[Session] when Session#auth_session.revoked =:= true -> F = fun() ->
case mnesia:read(auth_session, SessionId, write) of
[Session] when Session#auth_session.revoked =:= true ->
ok;
[Session] ->
Now = calendar:universal_time(),
mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
{revoked, Session#auth_session.subject_type};
[] ->
{error, not_found}
end
end,
case mnesia:transaction(F) of
{atomic, ok} -> ok;
{atomic, {revoked, SubjectType}} ->
dec_counter(SubjectType),
ok; ok;
[Session] -> {atomic, {error, _} = Err} -> Err;
Now = calendar:universal_time(), {aborted, _Reason} -> {error, transaction_failed}
mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}),
dec_counter(Session#auth_session.subject_type),
ok;
[] ->
{error, not_found}
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,
Now = calendar:universal_time(), %% preventing partial revocation under concurrent access.
lists:foreach(fun(Session) -> F = fun() ->
case Session#auth_session.revoked of Sessions = mnesia:index_read(auth_session, FamilyId, #auth_session.family_id),
true -> ok; Now = calendar:universal_time(),
false -> RevokedTypes = lists:filtermap(fun(Session) ->
mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}), case Session#auth_session.revoked of
dec_counter(Session#auth_session.subject_type) true -> false;
end false ->
end, Sessions), mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
ok. {true, Session#auth_session.subject_type}
end
end, Sessions),
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.
Now = calendar:universal_time(), F = fun() ->
lists:foreach(fun(Session) -> MS = ets:fun2ms(fun(#auth_session{subject_id = Sid} = S)
case Session#auth_session.subject_type =:= SubjectType andalso when Sid =:= SubjectId -> S end),
Session#auth_session.revoked =:= false of Sessions = mnesia:select(auth_session, MS, write),
true -> Now = calendar:universal_time(),
mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}), Count = lists:foldl(fun(Session, Acc) ->
dec_counter(Session#auth_session.subject_type); case Session#auth_session.subject_type =:= SubjectType andalso
false -> Session#auth_session.revoked =:= false of
ok true ->
end mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
end, Sessions), Acc + 1;
ok. false ->
Acc
end
end, 0, Sessions),
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}