%%
%% %CopyrightBegin%
%%
%% SPDX-License-Identifier: Apache-2.0
%%
%% Copyright Ericsson AB 2001-2025. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%%     http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%
%% %CopyrightEnd%
%%
%%
%% Security Audit Functionality

%%
%% The gen_server code.
%%
%% A gen_server is needed in this module to take care of shared access to the
%% data file used to store failed and successful authentications as well as 
%% user blocks.
%%
%% The storage model is a write-through model with both an ets and a dets 
%% table. Writes are done to both the ets and then the dets table, but reads 
%% are only done from the ets table.
%%
%% This approach also enables parallelism when using dets by returning the 
%% same dets table identifier when opening several files with the same 
%% physical location.
%%
%% NOTE: This could be implemented using a single dets table, as it is 
%%       possible to open a dets file with the ram_file flag, but this 
%%       would require periodical sync's to disk, and it would be hard 
%%       to decide when such an operation should occur.
%%


-module(mod_security_server).
-moduledoc false.

-include("httpd.hrl").
-include("httpd_internal.hrl").

-behaviour(gen_server).


%% User API exports (called via mod_security)
-export([list_blocked_users/2, list_blocked_users/3, 
	 block_user/5, 
	 unblock_user/3, unblock_user/4,
	 list_auth_users/2, list_auth_users/3]).

%% Internal exports (for mod_security only)
-export([start/3, stop/2, stop/3,
	 new_table/4, delete_tables/3, 
	 store_failed_auth/6, store_successful_auth/5, 
	 check_blocked_user/6]).

%% gen_server exports
-export([start_link/3, init/1, 
	 handle_info/2, handle_call/3, handle_cast/2, 
	 terminate/2,
	 code_change/3]).

%%====================================================================
%% Internal application API
%%====================================================================	     

%% NOTE: This is called by httpd_misc_sup when the process is started
start_link(Addr, Port, Profile) ->
    Name = make_name(Addr, Port, Profile),
    gen_server:start_link({local, Name}, ?MODULE, [], [{timeout, infinity}]).

%% Called  by the mod_security module.
start(Addr, Port, Profile) ->
    Name = make_name(Addr, Port, Profile),
    case whereis(Name) of
	undefined ->
	   httpd_misc_sup:start_sec_server(Addr, Port, Profile);
	_ -> %% Already started...
	    ok
    end.

stop(Port, Profile) ->
    stop(undefined, Port, Profile).
stop(Addr, Port, Profile) ->
    Name = make_name(Addr, Port, Profile),
    case whereis(Name) of
	undefined ->
	    ok;
	_ ->
	    httpd_misc_sup:stop_sec_server(Addr, Port, Profile)
    end.

addr(undefined) ->
    any;
addr(Addr) ->
    Addr.

list_blocked_users(Addr, Port) ->
    list_blocked_users(Addr, Port, ?DEFAULT_PROFILE).
list_blocked_users(Addr, Port, Profile) when is_atom(Profile)->
    Name = make_name(Addr, Port, Profile),
    Req  = {list_blocked_users, addr(Addr), Port, Profile,'_'},
    call(Name, Req);
list_blocked_users(Addr, Port, Dir) ->
    list_blocked_users(Addr, Port, ?DEFAULT_PROFILE, Dir).
list_blocked_users(Addr, Port, Profile, Dir) ->
    Name = make_name(Addr, Port, Profile),
    Req  = {list_blocked_users, addr(Addr), Port, Profile, Dir},
    call(Name, Req).

block_user(User, Addr, Port, Dir, Time) ->
    block_user(User, Addr, Port, ?DEFAULT_PROFILE, Dir, Time).
block_user(User, Addr, Port, Profile, Dir, Time) ->
    Name = make_name(Addr, Port, Profile),
    Req  = {block_user, User, addr(Addr), Port, Profile, Dir, Time},
    call(Name, Req).

unblock_user(User, Addr, Port) ->
    unblock_user(User, Addr, Port, ?DEFAULT_PROFILE).
unblock_user(User, Addr, Port, Profile) when is_atom(Profile)->
    Name = make_name(Addr, Port, Profile),
    Req  = {unblock_user, User, addr(Addr), Port, Profile, '_'},
    call(Name, Req);
unblock_user(User, Addr, Port, Dir) ->
    unblock_user(User, Addr, Port, ?DEFAULT_PROFILE, Dir).
unblock_user(User, Addr, Port, Profile, Dir) ->
    Name = make_name(Addr, Port, Profile),
    Req  = {unblock_user, User, addr(Addr), Port, Profile, Dir},
    call(Name, Req).

list_auth_users(Addr, Port) ->
    list_auth_users(Addr, Port, ?DEFAULT_PROFILE).
list_auth_users(Addr, Port, Profile) when is_atom(Profile) ->
    Name = make_name(Addr, Port, Profile),
    Req  = {list_auth_users, addr(Addr), Port, Profile, '_'},
    call(Name, Req);
list_auth_users(Addr, Port, Dir) ->
    list_auth_users(Addr, Port, ?DEFAULT_PROFILE, Dir).
list_auth_users(Addr, Port, Profile, Dir) ->
    Name = make_name(Addr,Port, Profile),
    Req  = {list_auth_users, addr(Addr), Port, Profile, Dir}, 
    call(Name, Req).

new_table(Addr, Port, Profile, TabName) ->
    Name = make_name(Addr,Port, Profile),
    Req  = {new_table, addr(Addr), Port, Profile, TabName}, 
    call(Name, Req).

delete_tables(Addr, Port, Profile) ->
    Name = make_name(Addr, Port, Profile),
    case whereis(Name) of
	undefined ->
	    ok;
	_ ->
	    call(Name, delete_tables)
    end.

store_failed_auth(Info, Addr, Port, Profile, DecodedString, SDirData) ->
    Name = make_name(Addr, Port, Profile),
    Msg  = {store_failed_auth, Profile, [Info,DecodedString,SDirData]},
    cast(Name, Msg).

store_successful_auth(Addr, Port, Profile, User, SDirData) ->
    Name = make_name(Addr,Port, Profile),
    Msg  = {store_successful_auth, [User,Addr,Port, Profile, SDirData]}, 
    cast(Name, Msg).
 
check_blocked_user(Info, User, SDirData, Addr, Port, Profile) ->
    Name = make_name(Addr, Port, Profile),
    Req  = {check_blocked_user, Profile, [Info, User, SDirData]}, 
    call(Name, Req).

%%====================================================================
%% Behavior call backs
%%====================================================================	     
init(_) ->
    process_flag(trap_exit, true),
    {ok, []}.

handle_call(stop, _From, _Tables) ->
    {stop, normal, ok, []};

handle_call({block_user, User, Addr, Port, Profile, Dir, Time}, _From, Tables) ->
    Ret = block_user_int(User, Addr, Port, Profile, Dir, Time),
    {reply, Ret, Tables};

handle_call({list_blocked_users, Addr, Port, Profile, Dir}, _From, Tables) ->
    Blocked = list_blocked(Tables, Addr, Port, Profile, Dir, []),
    {reply, Blocked, Tables};

handle_call({unblock_user, User, Addr, Port, Profile, Dir}, _From, Tables) ->
    Ret = unblock_user_int(User, Addr, Port, Profile,Dir),
    {reply, Ret, Tables};

handle_call({list_auth_users, Addr, Port, Profile, Dir}, _From, Tables) ->
    Auth = list_auth(Tables, Addr, Port, Profile, Dir, []),
    {reply, Auth, Tables};

handle_call({new_table, Addr, Port, Profile, Name}, _From, Tables) ->
    case lists:keysearch(Name, 1, Tables) of
	{value, {Name, {Ets, Dets}}} ->
	    {reply, {ok, {Ets, Dets}}, Tables};
	false ->
	    TName = make_name(Addr,Port, Profile, length(Tables)),
	    case dets:open_file(TName, [{type, bag}, {file, Name}, 
					{repair, true}, 
					{access, read_write}]) of
		{ok, DFile} ->
		    ETS = ets:new(TName, [bag, private]),
		    sync_dets_to_ets(DFile, ETS),
		    NewTables = [{Name, {ETS, DFile}}|Tables],
		    {reply, {ok, {ETS, DFile}}, NewTables};
		{error, Err} ->
		    {reply, {error, {create_dets, Err}}, Tables}
	    end
    end;

handle_call(delete_tables, _From, Tables) ->
    lists:foreach(fun({_Name, {ETS, DETS}}) ->
			  dets:close(DETS),
			  ets:delete(ETS)
		  end, Tables),
    {reply, ok, []};

handle_call({check_blocked_user, Profile, [Info, User, SDirData]}, _From, Tables) ->
    {ETS, DETS} = proplists:get_value(data_file, SDirData),
    Dir = proplists:get_value(path, SDirData),
    Addr = proplists:get_value(bind_address, SDirData),
    Port = proplists:get_value(port, SDirData),
    CBModule = 
	proplists:get_value(callback_module, SDirData, no_module_at_all),
    Ret = 
	check_blocked_user(Info, User, Dir, Addr, Port, Profile, ETS, DETS, CBModule),
    {reply, Ret, Tables};

handle_call(_Request,_From,Tables) ->
    {reply,ok,Tables}.

handle_cast({store_failed_auth, _,[_, _, []]}, Tables) ->
    %% Some other authentication scheme than mod_auth (example mod_htacess)
    %% was the source for the authentication failure so we should ignor it!
    {noreply, Tables};
handle_cast({store_failed_auth, Profile, [Info, DecodedString, SDirData]}, Tables) ->
    {ETS, DETS} = proplists:get_value(data_file, SDirData),
    Dir  = proplists:get_value(path, SDirData),
    Addr = proplists:get_value(bind_address, SDirData),
    Port = proplists:get_value(port, SDirData),
    {ok, [User,Password]} = httpd_util:split(DecodedString,":",2),
    Seconds = universal_time(),
    Key = {User, Dir, Addr, Port, Profile},
    %% Event
    CBModule = proplists:get_value(callback_module, 
				     SDirData, no_module_at_all),
    auth_fail_event(CBModule,Addr,Port,Dir,User,Password),
    
    %% Find out if any of this user's other failed logins are too old to keep..
    case ets:match_object(ETS, {failed, {Key, '_', '_'}}) of
	[] ->
	    no;
	List ->
	    ExpireTime = proplists:get_value(fail_expire_time, 
					     SDirData, 30)*60,
	    lists:map(fun({failed, {TheKey, LS, Gen}}) ->
			      Diff = Seconds-LS,
			      if
				  Diff > ExpireTime ->
				      ets:match_delete(ETS, 
						       {failed, 
							{TheKey, LS, Gen}}),
				      dets:match_delete(DETS, 
							{failed, 
							 {TheKey, LS, Gen}});
				  true ->
				      ok
			      end
		      end,
		      List)
    end,

    %% Insert the new failure..
    Generation = length(ets:match_object(ETS, {failed, {Key, '_', '_'}})),
    ets:insert(ETS, {failed, {Key, Seconds, Generation}}),
    dets:insert(DETS, {failed, {Key, Seconds, Generation}}),
    
    %% See if we should block this user..
    MaxRetries = proplists:get_value(max_retries, SDirData, 3),
    BlockTime = proplists:get_value(block_time, SDirData, 60),
    case ets:match_object(ETS, {failed, {Key, '_', '_'}}) of
	List1 when length(List1) >= MaxRetries ->
	    %% Block this user until Future
	    Future = Seconds+BlockTime*60,
	    Reason = io_lib:format("Blocking user ~s from dir ~s "
				   "for ~p minutes", 
				   [User, Dir, BlockTime]),
	    mod_log:security_log(Info, lists:flatten(Reason)),
	    
	    %% Event
	    user_block_event(CBModule,Addr,Port,Dir,User),
	    
	    ets:match_delete(ETS,{blocked_user,
				  {User, Addr, Port, Dir, '$1'}}), 
	    dets:match_delete(DETS, {blocked_user,
				     {User, Addr, Port, Dir, '$1'}}),
	    BlockRecord = {blocked_user, 
			   {User, Addr, Port, Profile, Dir, Future}},
	    ets:insert(ETS, BlockRecord),
	    dets:insert(DETS, BlockRecord),
	    %% Remove previous failed requests.
	    ets:match_delete(ETS, {failed, {Key, '_', '_'}}),
	    dets:match_delete(DETS, {failed, {Key, '_', '_'}});
	_ ->
	    no
    end,
    {noreply, Tables};

handle_cast({store_successful_auth, [User, Addr, Port, Profile, SDirData]}, Tables) ->
    {ETS, DETS} = proplists:get_value(data_file, SDirData),
    AuthTimeOut = proplists:get_value(auth_timeout, SDirData, 30),
    Dir = proplists:get_value(path, SDirData),
    Key = {User, Dir, Addr, Port, Profile},

    %% Remove failed entries for this Key
    dets:match_delete(DETS, {failed, {Key, '_', '_'}}),
    ets:match_delete(ETS, {failed, {Key, '_', '_'}}), 

    %% Keep track of when the last successful login took place.
    Seconds = universal_time()+AuthTimeOut,
    ets:match_delete(ETS, {success, {Key, '_'}}),
    dets:match_delete(DETS, {success, {Key, '_'}}),
    ets:insert(ETS, {success, {Key, Seconds}}),
    dets:insert(DETS, {success, {Key, Seconds}}),
    {noreply, Tables};
	    
handle_cast(Req, Tables) ->
    error_msg("security server got unknown cast: ~p",[Req]),
    {noreply, Tables}.

handle_info(_Info, State) ->
    {noreply, State}.

terminate(_Reason, _Tables) ->
    ok.

code_change(_, State, _Extra) ->
    {ok, State}.

%%--------------------------------------------------------------------
%%% Internal functions
%%--------------------------------------------------------------------

%% block_user_int/5
block_user_int(User, Addr, Port, Profile, Dir, Time) ->
    Dirs = httpd_manager:config_match(Addr, Port, Profile, 
				      {security_directory, {'_', '_'}}),
    case find_dirdata(Dirs, Dir) of
	{ok, DirData, {ETS, DETS}} ->
	    Time1 = 
		case Time of
		    infinity ->
			99999999999999999999999999999;
		    _ ->
			Time
		end,
	    Future = universal_time()+Time1,
	    ets:match_delete(ETS, {blocked_user, {User,Addr,Port,Profile, Dir,'_'}}),
	    dets:match_delete(DETS, {blocked_user, 
				     {User,Addr,Port,Profile, Dir,'_'}}),
	    ets:insert(ETS, {blocked_user, {User,Addr,Port, Profile, Dir,Future}}),
	    dets:insert(DETS, {blocked_user, {User,Addr,Port,Profile, Dir,Future}}),
	    CBModule = proplists:get_value(callback_module, DirData, 
					     no_module_at_all),
	    user_block_event(CBModule,Addr,Port,Dir,User),
	    true;
	_ ->
	    {error, no_such_directory}
    end.
    
find_dirdata([], _Dir) ->
    false;
find_dirdata([{security_directory, {_, DirData}}|SDirs], Dir) ->
    case lists:keysearch(path, 1, DirData) of
	{value, {path, Dir}} ->
	    {value, {data_file, {ETS, DETS}}} =
		lists:keysearch(data_file, 1, DirData),
	    {ok, DirData, {ETS, DETS}};
	_ ->
	    find_dirdata(SDirs, Dir)
    end.

unblock_user_int(User, Addr, Port, Profile, Dir) ->
    Dirs = httpd_manager:config_match(Addr, Port, Profile, 
				      {security_directory, {'_', '_'}}),
    case find_dirdata(Dirs, Dir) of
	{ok, DirData, {ETS, DETS}} ->
	    case ets:match_object(ETS,
				  {blocked_user,{User,Addr,Port,Profile,Dir,'_'}}) of
		[] ->
		    {error, not_blocked};
		_Objects ->
		    ets:match_delete(ETS, {blocked_user,
					   {User, Addr, Port, Profile, Dir, '_'}}),
		    dets:match_delete(DETS, {blocked_user,
					     {User, Addr, Port, Profile, Dir, '_'}}),
	       	    CBModule = proplists:get_value(callback_module, 
						     DirData, 
						     no_module_at_all),
		    user_unblock_event(CBModule,Addr,Port,Dir,User),
		    true
	    end;
	_ ->
	    {error, no_such_directory}
    end.

list_auth([], _, _, _, _, Acc) ->
    Acc;
list_auth([{_Name, {ETS, DETS}}|Tables], Addr, Port, Profile, Dir, Acc) ->
    case ets:match_object(ETS, {success, {{'_', Dir, Addr, Port, Profile}, '_'}}) of
	[] ->
	    list_auth(Tables, Addr, Port, Profile, Dir, Acc);
	List ->
	    TN = universal_time(),
	    NewAcc = lists:foldr(fun({success,{{U,Ad,P, Pr,D},T}},Ac) -> 
					 if
					     T-TN > 0 ->
						 [U|Ac];
					     true ->
						 Rec = {success,
							{{U,Ad,P,Pr,D},T}},
						 ets:match_delete(ETS,Rec),
						 dets:match_delete(DETS,Rec),
						 Ac
					 end
				 end,
				 Acc, List),
	    list_auth(Tables, Addr, Port, Profile, Dir, NewAcc)
    end.

list_blocked([], _, _, _, _, Acc) ->
    TN = universal_time(),
    lists:foldl(fun({U,Ad,P,Pr,D,T}, Ac) ->
			if
			    T-TN > 0 ->
				[{U,Ad,P, Pr,D,local_time(T)}|Ac];
			    true ->
				Ac
			end
		end, 
		[], Acc);
list_blocked([{_Name, {ETS, _DETS}}|Tables], Addr, Port, Profile, Dir, Acc) ->
    List = ets:match_object(ETS, {blocked_user, 
				  {'_',Addr,Port,Profile, Dir,'_'}}),
    
    NewBlocked = lists:foldl(fun({blocked_user, X}, A) -> 
				     [X|A] end, Acc, List),
    
    list_blocked(Tables, Addr, Port, Profile, Dir, NewBlocked).


%% Reads dets-table DETS and synchronizes it with the ets-table ETS.
%%
sync_dets_to_ets(DETS, ETS) ->
    dets:traverse(DETS, fun(X) ->
				ets:insert(ETS, X),
				continue
			end).

%% Check if a specific user is blocked from access.
%%
%% The sideeffect of this routine is that it unblocks also other users
%% whose blocking time has expired. This to keep the tables as small
%% as possible.
%%
check_blocked_user(Info, User, Dir, Addr, Port, Profile, ETS, DETS, CBModule) ->
    TN = universal_time(),
    BlockList = ets:match_object(ETS, {blocked_user, {User, '_', '_', '_', '_', '_'}}), 
    Blocked = lists:foldl(fun({blocked_user, X}, A) ->
				  [X|A] end, [], BlockList),
    check_blocked_user(Info,User,Dir,
		       Addr,Port, Profile, ETS,DETS,TN,Blocked,CBModule).

check_blocked_user(_Info, _, _, _, _, _, _, _, _,[], _CBModule) ->
    false;
check_blocked_user(Info, User, Dir, Addr, Port, Profile, ETS, DETS, TN, 
		   [{User,Addr,Port,Profile, Dir,T}| _], CBModule) ->
    TD = T-TN,
    if
	TD =< 0 ->
	    %% Blocking has expired, remove and grant access.
	    unblock_user(Info, User, Dir, Addr, Port, Profile, ETS, DETS, CBModule),
	    false;
	true ->
	    true
    end;
check_blocked_user(Info, User, Dir, Addr, Port, Profile, ETS, DETS, TN, 
		   [{OUser,ODir,OAddr,OPort, OProfile, T}|Ls], CBModule) ->
    TD = T-TN,
    if
	TD =< 0 ->
	    %% Blocking has expired, remove.
	    unblock_user(Info, OUser, ODir, OAddr, OPort, OProfile,
			 ETS, DETS, CBModule);
	true ->
	    true
    end,
    check_blocked_user(Info, User, Dir, Addr, Port, Profile, ETS, DETS, 
		       TN, Ls, CBModule).

unblock_user(Info, User, Dir, Addr, Port, Profile, ETS, DETS, CBModule) ->
    Reason =
	io_lib:format("User ~s was removed from the block list for dir ~s",
			 [User, Dir]),
    mod_log:security_log(Info, lists:flatten(Reason)),
    user_unblock_event(CBModule,Addr,Port,Dir,User),
    dets:match_delete(DETS, {blocked_user, {User, Addr, Port, Profile, Dir, '_'}}),
    ets:match_delete(ETS, {blocked_user, {User, Addr, Port, Profile, Dir, '_'}}).
  
make_name(Addr,Port, Profile) ->
    httpd_util:make_name(?MODULE_STRING, Addr, Port, Profile).

make_name(Addr,Port, Profile, Num) ->
    httpd_util:make_name(?MODULE_STRING, Addr,Port,
			 atom_to_list(Profile) ++ "__" ++ integer_to_list(Num)).

auth_fail_event(Mod,Addr,Port,Dir,User,Passwd) ->
    event(auth_fail,Mod,Addr,Port,Dir,[{user,User},{password,Passwd}]).

user_block_event(Mod,Addr,Port,Dir,User) ->
    event(user_block,Mod,Addr,Port,Dir,[{user,User}]).

user_unblock_event(Mod,Addr,Port,Dir,User) ->
    event(user_unblock,Mod,Addr,Port,Dir,[{user,User}]).

event(Event, Mod, undefined, Port, Dir, Info) ->
    (catch Mod:event(Event,Port,Dir,Info));
event(Event, Mod, any, Port, Dir, Info) ->
    (catch Mod:event(Event,Port,Dir,Info));
event(Event, Mod, Addr, Port, Dir, Info) ->
    (catch Mod:event(Event,Addr,Port,Dir,Info)).

universal_time() ->
    calendar:datetime_to_gregorian_seconds(calendar:universal_time()).

local_time(T) ->
    calendar:universal_time_to_local_time(
      calendar:gregorian_seconds_to_datetime(T)).

error_msg(F, A) ->
    error_logger:error_msg(F, A).

call(Name, Req) ->
    case (catch gen_server:call(Name, Req)) of
        {'EXIT', Reason} ->
            {error, Reason};
        Reply ->
            Reply
    end.

cast(Name, Msg) ->
    case (catch gen_server:cast(Name, Msg)) of
        {'EXIT', Reason} ->
            {error, Reason};
        Result ->
            Result
    end.
