diff --git a/src/core/core_auth_session.erl b/src/core/core_auth_session.erl index 2b9da03..864d26e 100755 --- a/src/core/core_auth_session.erl +++ b/src/core/core_auth_session.erl @@ -5,6 +5,7 @@ %%%------------------------------------------------------------------- -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]). -define(REFRESH_TTL_SECONDS, 30 * 24 * 3600). @@ -32,9 +33,14 @@ create(SubjectId, SubjectType, ClientType) -> created_at = Now, updated_at = Now }, - mnesia:dirty_write(Session), - inc_counter(SubjectType), - {ok, Session}. + %% Transaction ensures atomic write of the new session record. + case mnesia:transaction(fun() -> mnesia:write(Session) end) of + {atomic, ok} -> + inc_counter(SubjectType), + {ok, Session}; + {aborted, Reason} -> + {error, Reason} + end. %%%------------------------------------------------------------------- %%% @doc Получить сессию по идентификатору. @@ -56,11 +62,19 @@ get(SessionId) -> {ok, NewJti :: binary(), #auth_session{}} | {error, not_found | expired | revoked | reuse_detected}. rotate(SessionId, PresentedJti) -> - case mnesia:dirty_read({auth_session, SessionId}) of - [Session] -> - rotate_session(Session, PresentedJti); - [] -> - {error, not_found} + %% 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] -> + rotate_session(Session, PresentedJti); + [] -> + {error, not_found} + end + end, + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, _Reason} -> {error, transaction_failed} end. %%%------------------------------------------------------------------- @@ -69,60 +83,98 @@ rotate(SessionId, PresentedJti) -> %%%------------------------------------------------------------------- -spec revoke(SessionId :: binary()) -> ok | {error, not_found}. revoke(SessionId) -> - case mnesia:dirty_read({auth_session, SessionId}) of - [Session] when Session#auth_session.revoked =:= true -> + %% 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 -> + 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; - [Session] -> - Now = calendar:universal_time(), - mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}), - dec_counter(Session#auth_session.subject_type), - ok; - [] -> - {error, not_found} + {atomic, {error, _} = Err} -> Err; + {aborted, _Reason} -> {error, transaction_failed} end. %%%------------------------------------------------------------------- %%% @doc Отозвать все сессии семейства (reuse attack / принудительный logout). %%% @end %%%------------------------------------------------------------------- --spec revoke_family(FamilyId :: binary()) -> ok. +-spec revoke_family(FamilyId :: binary()) -> ok | {error, transaction_failed}. revoke_family(FamilyId) -> - Sessions = mnesia:dirty_index_read(auth_session, FamilyId, #auth_session.family_id), - Now = calendar:universal_time(), - lists:foreach(fun(Session) -> - case Session#auth_session.revoked of - true -> ok; - false -> - mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}), - dec_counter(Session#auth_session.subject_type) - end - end, Sessions), - ok. + %% 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(), + RevokedTypes = lists:filtermap(fun(Session) -> + case Session#auth_session.revoked of + true -> false; + false -> + mnesia:write(Session#auth_session{revoked = true, updated_at = Now}), + {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 Отозвать все сессии субъекта (например после сброса пароля). %%% @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) -> - Sessions = mnesia:dirty_match_object(#auth_session{subject_id = SubjectId, _ = '_'}), - Now = calendar:universal_time(), - lists:foreach(fun(Session) -> - case Session#auth_session.subject_type =:= SubjectType andalso - Session#auth_session.revoked =:= false of - true -> - mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}), - dec_counter(Session#auth_session.subject_type); - false -> - ok - end - end, Sessions), - ok. + %% 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(), + Count = lists:foldl(fun(Session, Acc) -> + case Session#auth_session.subject_type =:= SubjectType andalso + Session#auth_session.revoked =:= false of + true -> + mnesia:write(Session#auth_session{revoked = true, updated_at = Now}), + Acc + 1; + 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 %%%=================================================================== +%% 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) -> case Session#auth_session.revoked of true -> @@ -140,7 +192,7 @@ rotate_session(Session, PresentedJti) -> current_jti = NewJti, updated_at = Now }, - mnesia:dirty_write(Updated), + mnesia:write(Updated), {ok, NewJti, Updated}; false -> {error, reuse_detected}