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:
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user