src/support/z_auth.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2009-2024 Marc Worrell
%% @doc Handle authentication of zotonic users.  Also shows the logon screen when authentication is required.
%% @end

%% Copyright 2009-2024 Marc Worrell
%%
%% 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.

-module(z_auth).
-author("Marc Worrell <marc@worrell.nl").

%% interface functions
-export([
    is_auth/1,

    logon/2,
    logon_switch/2,
    logon_switch/3,
    logon_redirect/3,
    confirm/2,
    logon_pw/3,
    logoff/1,

    switch_user/2,
    publish_user_session/1,
    unpublish_user_session/1,

    is_enabled/2
]).


-include_lib("zotonic.hrl").


%% @doc Check if the visitor has been authenticated. Assumes a completely initalized context.
-spec is_auth(z:context()) -> boolean().
is_auth(#context{ user_id = undefined }) -> false;
is_auth(_) -> true.

%% @doc Logon a username/password combination, checks passwords with m_identity.
-spec logon_pw( binary(), binary(), z:context() ) -> {boolean(), z:context()}.
logon_pw(Username, Password, Context) ->
    case m_identity:check_username_pw(Username, Password, Context) of
        {ok, Id} ->
            case logon(Id, Context) of
                {ok, Context1} -> {true, Context1};
                {error, _Reason} -> {false, Context}
            end;
        {error, _Reason} -> {false, Context}
    end.


%% @doc Set the user to 'confirmed'.
-spec confirm( m_rsc:resource_id(), z:context() ) -> {ok, z:context()} | {error, user_not_enabled}.
confirm(UserId, Context) ->
    % check if auth_user_id == userId??
    case is_enabled(UserId, Context) of
        true ->
            Context1 = z_notifier:foldl(#auth_confirm{ id = UserId }, Context, Context),
            z_notifier:notify(#auth_confirm_done{ id = UserId }, Context1),
            {ok, Context1};
        false ->
            {error, user_not_enabled}
    end.

%% @doc Logon a user whose id we know, set all user prefs in the context.
-spec logon( m_rsc:resource_id(), z:context() ) -> {ok, z:context()} | {error, user_not_enabled}.
logon(UserId, Context) ->
    case is_enabled(UserId, Context) of
        true ->
            Context1 = z_acl:logon_prefs(UserId, Context),
            Context2 = z_notifier:foldl(#auth_logon{ id = UserId }, Context1, Context1),
            m_identity:set_visited(UserId, Context2),
            {ok, Context2};
        false ->
            {error, user_not_enabled}
    end.

%% @doc Allow an admin user to switch to another user account.
-spec logon_switch( m_rsc:resource_id(), z:context() ) -> {ok, z:context()} | {error, eacces}.
logon_switch(ToUserId, Context) ->
    case is_allowed_switch_user(ToUserId, Context) of
        true ->
            Context1 = z_acl:logon_prefs(ToUserId, Context),
            Context2 = z_notifier:foldl(#auth_logon{ id = ToUserId }, Context1, Context1),
            {ok, Context2};
        false ->
            {error, eacces}
    end.

%% @doc Allow an admin user to switch to another user account. The ActingUserId is
%% typically the user_id of the user that initially logged on. This is stored in the
%% auth_options as 'sudo_user_id' and is set when performing the switch.
-spec logon_switch(ToUserId, ActingUserId, Context) -> {ok, Context1} | {error, eacces} when
    ToUserId :: m_rsc:resource_id(),
    ActingUserId :: m_rsc:resource_id(),
    Context :: z:context(),
    Context1 :: z:context().
logon_switch(ToUserId, ActingUserId, Context) ->
    OriginalSudoUserContext = z_acl:logon(ActingUserId, Context),
    case is_allowed_switch_user(ToUserId, OriginalSudoUserContext) of
        true ->
            Context1 = z_acl:logon_prefs(ToUserId, Context),
            Context2 = z_notifier:foldl(#auth_logon{ id = ToUserId }, Context1, Context1),
            {ok, Context2};
        false ->
            {error, eacces}
    end.

is_allowed_switch_user(?ACL_ADMIN_USER_ID, Context) ->
    % Hard coded protection against logging in as the admin user from any account that
    % is not the admin user.
    z_acl:user(Context) =:= ?ACL_ADMIN_USER_ID;
is_allowed_switch_user(ToUserId, Context) ->
    m_rsc:exists(ToUserId, Context)
    andalso (
               z_acl:user(Context) =:= ToUserId
        orelse z_acl:sudo_user(Context) =:= ToUserId
        orelse z_acl:is_admin(Context)
        orelse z_acl:is_allowed(sudo_user, ToUserId, Context)
    ).

%% @doc Logon a user and redirect the user agent. The MQTT websocket MUST be connected.
-spec logon_redirect( m_rsc:resource_id(), binary() | undefined, z:context() ) -> ok | {error, term()}.
logon_redirect(UserId, Url, Context) ->
    case z_notifier:first(#auth_client_logon_user{
            user_id = UserId,
            url = Url
        }, Context)
    of
        undefined -> {error, no_handler};
        ok -> ok;
        {error, _} = Error -> Error
    end.

%% @doc Request the client's auth worker to re-authenticate as a new user. The ACL
%% for this operation is checked by z_auth:logon_switch/3, which is called by the
%% controller_authentication when asked to make the switch.
-spec switch_user( m_rsc:resource_id(), z:context() ) -> ok | {error, eacces}.
switch_user(UserId, Context) when is_integer(UserId) ->
    case z_notifier:first(#auth_client_switch_user{
            user_id = UserId
        }, Context)
    of
        undefined -> {error, no_handler};
        ok -> ok;
        {error, _} = Error -> Error
    end.

%% @doc Forget about the user being logged on.
-spec logoff(z:context()) -> z:context().
logoff(Context) ->
    Logoff = #auth_logoff{
        id = z_acl:user(Context)
    },
    Context1 = z_notifier:foldl(Logoff, Context, Context),
    z_acl:logoff(Context1).


%% @doc Publish the current IP address and session-id to the sessions topic
%% of the user. This enables tracking where users are active. This _must_ be
%% a session initiated from a remote IP address. If no remote IP address is
%% known then the user session is not logged.
-spec publish_user_session(Context) -> ok | {error, Reason} when
    Context :: z:context(),
    Reason :: no_user | no_session | term().
publish_user_session(Context) ->
    case is_auth(Context) of
        true ->
            case z_context:session_id(Context) of
                {ok, SessionId} ->
                    case m_req:get(peer_ip, Context) of
                        undefined ->
                            {error, no_peer_ip};
                        PeerIP ->
                            Peer = z_convert:to_binary(inet:ntoa(PeerIP)),
                            Payload = #{
                                <<"session_id">> => SessionId,
                                <<"user_agent">> => m_req:get(user_agent, Context),
                                <<"ip_address">> => Peer,
                                <<"timestamp">> => calendar:universal_time()
                            },
                            z_mqtt:publish(
                                [ <<"~user">>, <<"sessions">>, SessionId ],
                                Payload,
                                #{ retain => true },
                                Context)
                    end;
                {error, _} = Error ->
                    Error
            end;
        false ->
            {error, no_user}
    end.

%% @doc Remove the retained user session value, this removes the session from the overview
%% of active sessions. Called when resetting the authentication cookies.
-spec unpublish_user_session(Context) -> ok | {error, Reason} when
    Context :: z:context(),
    Reason :: no_user | no_session | term().
unpublish_user_session(Context) ->
    case is_auth(Context) of
        true ->
            case z_context:session_id(Context) of
                {ok, SessionId} ->
                    z_mqtt:publish(
                        [ <<"~user">>, <<"sessions">>, SessionId ],
                        undefined,
                        #{ retain => true },
                        Context);
                {error, _} = Error ->
                    Error
            end;
        false ->
            {error, no_user}
    end.


%% @doc Check if the user is enabled, a user is enabled when the rsc is published and within its publication date range.
-spec is_enabled( m_rsc:resource_id(), z:context() ) -> boolean().
is_enabled(?ACL_ADMIN_USER_ID, _Context) ->
    % The admin user is always enabled.
    true;
is_enabled(UserId, Context) ->
    case z_notifier:first(#user_is_enabled{id=UserId}, Context) of
        undefined ->
            Acl = m_rsc:get_acl_props(UserId, Context),
            case Acl#acl_props.is_published of
                false ->
                    false;
                true ->
                    Date = calendar:universal_time(),
                    Acl#acl_props.publication_start =< Date andalso Acl#acl_props.publication_end >= Date
            end;
        Other when is_boolean(Other) ->
            Other
    end.