Current section
Files
Jump to
Current section
Files
src/controllers/controller_authentication.erl
%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2019-2021 Marc Worrell
%% @doc Handle HTTP authentication of users.
%% Copyright 2019-2021 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(controller_authentication).
-author("Marc Worrell <marc@worrell.nl>").
-export([
service_available/1,
allowed_methods/1,
content_types_accepted/1,
content_types_provided/1,
process/4
]).
-include_lib("zotonic_core/include/zotonic.hrl").
-define(RESET_TOKEN_MAXAGE, 48*3600).
service_available(Context) ->
Context1 = z_context:set_noindex_header(Context),
Context2 = z_context:set_nocache_headers(Context1),
{true, Context2}.
allowed_methods(Context) ->
{[ <<"POST">> ], Context}.
content_types_accepted(Context) ->
{[
{<<"application">>, <<"json">>, []},
{<<"text">>, <<"x-ubf">>, []},
{<<"application">>, <<"x-bert">>, []},
{<<"application">>, <<"x-www-form-urlencoded">>, []},
{<<"multipart">>, <<"form-data">>, []}
], Context}.
content_types_provided(Context) ->
{[
{<<"application">>, <<"json">>, []},
{<<"text">>, <<"x-ubf">>, []},
{<<"application">>, <<"x-bert">>, []}
], Context}.
process(<<"POST">>, AcceptedCT, ProvidedCT, Context) ->
case fetch_body(AcceptedCT, Context) of
{ok, {Payload, Context1}} ->
{Result, Context2} = handle_cmd( maps:get(<<"cmd">>, Payload, undefined), Payload, Context1 ),
{z_controller_helper:encode_response(ProvidedCT, Result), Context2};
{error, timeout} ->
{{halt, 500}, Context}
end.
fetch_body(AcceptedCT, Context) ->
try
Ret = z_controller_helper:decode_request(AcceptedCT, Context),
{ok, Ret}
catch
exit:timeout ->
% Timeout reading the request body
{error, timeout}
end.
-spec handle_cmd( binary(), map(), z:context() ) -> { map(), z:context() }.
handle_cmd(<<"logon">>, Payload, Context) ->
logon(Payload, Context);
handle_cmd(<<"switch_user">>, Payload, Context) ->
switch_user(Payload, Context);
handle_cmd(<<"onetime_token">>, Payload, Context) ->
onetime_token(Payload, Context);
handle_cmd(<<"logoff">>, Payload, Context) ->
logoff(Payload, Context);
handle_cmd(<<"refresh">>, Payload, Context) ->
refresh(Payload, Context);
handle_cmd(<<"setautologon">>, Payload, Context) ->
setautologon(Payload, Context);
handle_cmd(<<"change">>, Payload, Context) ->
change(Payload, Context);
handle_cmd(<<"reset_check">>, Payload, Context) ->
{ check_reminder_secret(Payload, Context), Context };
handle_cmd(<<"reset">>, Payload, Context) ->
reset(Payload, Context);
handle_cmd(<<"status">>, Payload, Context) ->
status(Payload, Context);
handle_cmd(Cmd, _Payload, Context) ->
lager:info("controller_authentication: unknown cmd ~p", [ Cmd ]),
{
#{
status => error,
error => <<"unknown_cmd">>,
message => <<"Unknown cmd, use one of 'logon', 'logoff', 'refresh', 'setautologon', "
"'reset_check', 'reset', 'change', switch_user' or 'status'">>
},
Context
}.
-spec logon( map(), z:context() ) -> { map(), z:context() }.
logon(Payload, Context) ->
{Result, Context1} = logon_1(z_notifier:first(#logon_submit{ payload = Payload }, Context), Payload, Context),
maybe_add_logon_options(Result, Payload, Context1).
logon_1({ok, UserId}, Payload, Context) when is_integer(UserId) ->
case z_auth:logon(UserId, Context) of
{ok, Context1} ->
log_logon(UserId, Payload, Context),
% - (set cookie in handlers - like device-id) --> needs notification
Options = z_context:get(auth_options, Context, #{}),
Context2 = z_authentication_tokens:set_auth_cookie(UserId, Options, Context1),
Context3 = maybe_setautologon(Payload, Context2),
return_status(Payload, Context3);
{error, user_not_enabled} ->
case m_rsc:p_no_acl(UserId, is_verified_account, Context) of
false ->
% The account is awaiting verification
{ #{ status => error, error => verification_pending, user_id => UserId }, Context };
V when V == true orelse V == undefined ->
% The account has been disabled after verification, or
% verification flag not set, account didn't need verification
{ #{ status => error, error => disabled, user_id => UserId }, Context }
end
% {error, _Reason} ->
% % Hide other error codes, map to generic 'pw' error
% { #{ status => error, error => pw }, Context }
end;
logon_1({expired, UserId}, _Payload, Context) when is_integer(UserId) ->
% The password is expired and needs a reset - this is similar to password reset
case m_identity:get_username(UserId, Context) of
undefined ->
{ #{ status => error, error => pw }, Context };
Username ->
Code = m_authentication:set_reminder_secret(UserId, Context),
{ #{ status => error, error => password_expired, username => Username, secret => Code }, Context }
end;
logon_1({error, ratelimit}, _Payload, Context) ->
{ #{ status => error, error => ratelimit }, Context };
logon_1({error, need_passcode}, _Payload, Context) ->
{ #{ status => error, error => need_passcode }, Context };
logon_1({error, passcode}, _Payload, Context) ->
{ #{ status => error, error => passcode }, Context };
logon_1({error, _Reason}, _Payload, Context) ->
% Hide other error codes, map to generic 'pw' error
{ #{ status => error, error => pw }, Context };
logon_1(undefined, _Payload, Context) ->
{ #{ status => error, error => pw }, Context }.
log_logon(UserId, #{ <<"username">> := Username }, Context) when is_binary(Username) ->
lager:info("~p: Logon of user ~p (~s) using username '~s'",
[ z_context:site(Context), UserId, username(UserId, Context), Username ]);
log_logon(UserId, #{ <<"token">> := Token }, Context) when is_binary(Token) ->
lager:info("~p: Logon of user ~p (~s) using token",
[ z_context:site(Context), UserId, username(UserId, Context) ]);
log_logon(UserId, #{}, Context) ->
lager:info("~p: Logon of user ~p (~s)",
[ z_context:site(Context), UserId, username(UserId, Context) ]).
username(UserId, Context) ->
case m_identity:get_username(UserId, Context) of
Username when is_binary(Username) -> Username;
undefined -> <<>>
end.
-spec maybe_add_logon_options( map(), map(), z:context() ) -> { map(), z:context() }.
maybe_add_logon_options(#{ error := ratelimit } = Result, _Payload, Context) ->
{Result, Context};
maybe_add_logon_options(#{ status := error } = Result, Payload, Context) ->
Options = #{
is_username_checked => false,
is_user_local => false,
is_user_external => false,
username => maps:get(<<"username">>, Payload, undefined),
user_external => [],
page => maps:get(<<"page">>, Payload, undefined),
is_password_entered => not z_utils:is_empty(maps:get(<<"password">>, Payload, <<>>))
},
Options1 = z_notifier:foldr(#logon_options{ payload = Payload }, Options, Context),
Result1 = Result#{
options => Options1
},
case Options1 of
#{ is_user_external := true } -> {Result1, Context};
#{ is_user_local := true } -> {Result1, Context};
#{ is_user_local := false, is_user_external := false, username := Username }
when is_binary(Username), Username =/= <<>> ->
% Hide the fact an user is unknown, this prevents fishing for known usernames.
Options2 = Options1#{
is_user_local => true,
is_username_checked => true
},
{Result1#{ options => Options2 }, Context};
_ ->
{Result1, Context}
end;
maybe_add_logon_options(#{ status := ok } = Result, _Payload, Context) ->
{Result, Context}.
-spec onetime_token( map(), z:context() ) -> { map(), z:context() }.
onetime_token(#{ <<"token">> := Token } = Payload, Context) ->
case z_authentication_tokens:decode_onetime_token(Token, Context) of
{ok, UserId} ->
logon_1({ok, UserId}, Payload, Context);
{error, _} ->
{ #{ status => error, error => token }, Context }
end;
onetime_token(_Payload, Context) ->
{ #{ status => error, error => missing_token }, Context }.
-spec switch_user( map(), z:context() ) -> { map(), z:context() }.
switch_user(#{ <<"user_id">> := UserId } = Payload, Context) when is_integer(UserId) ->
AuthOptions = z_context:get(auth_options, Context, #{}),
SudoUserId = maps:get(sudo_user_id, AuthOptions, z_acl:user(Context)),
case z_auth:logon_switch(UserId, SudoUserId, Context) of
{ok, Context1} ->
z:warning(
"Sudo as user ~p (~s) by user ~p (~s)",
[
UserId, z_convert:to_binary( m_rsc:p_no_acl(UserId, email, Context) ),
z_acl:user(Context), z_convert:to_binary( m_rsc:p_no_acl(z_acl:user(Context), email, Context) )
],
[
{module, ?MODULE}, {line, ?LINE}, {auth_user_id, UserId}
],
Context),
Options2 = AuthOptions#{
sudo_user_id => SudoUserId
},
Context2 = z_authentication_tokens:set_auth_cookie(UserId, Options2, Context1),
return_status(Payload, Context2);
{error, _Reason} ->
{ #{ status => error, error => eacces }, Context }
end;
switch_user(_Payload, Context) ->
{ #{ status => error, error => missing_user_id }, Context }.
%% @doc Remove authentication cookie(s), signal user logoff
-spec logoff( map(), z:context() ) -> { map(), z:context() }.
logoff(Payload, Context) ->
Context1 = z_auth:logoff(Context),
Context2 = z_authentication_tokens:reset_cookies(Context1),
return_status(Payload, Context2).
%% @doc Refresh the current authentication cookie
-spec refresh( map(), z:context() ) -> { map(), z:context() }.
refresh(Payload, Context) ->
Options = case maps:get(<<"options">>, Payload, #{}) of
V when is_map(V) -> V;
_ -> #{}
end,
Context1 = z_authentication_tokens:refresh_auth_cookie(Options, Context),
return_status(Payload, Context1).
%% @doc Set an autologon cookie for the current user
-spec setautologon( map(), z:context() ) -> { map(), z:context() }.
setautologon(_Payload, Context) ->
case z_acl:user(Context) of
undefined ->
{ #{ status => error, error => nouser }, Context };
UserId ->
Context1 = z_authentication_tokens:set_autologon_cookie(UserId, Context),
{ #{ status => ok }, Context1 }
end.
%% @doc Set the autologon cookie for the user if the flag was speficied in the request.
-spec maybe_setautologon( map(), z:context() ) -> z:context().
maybe_setautologon(#{ <<"setautologon">> := SetAutoLogon }, Context) ->
case z_convert:to_bool(SetAutoLogon) of
true ->
z_authentication_tokens:set_autologon_cookie(z_acl:user(Context), Context);
false ->
z_authentication_tokens:reset_autologon_cookie(Context)
end;
maybe_setautologon(_Payload, Context) ->
z_authentication_tokens:reset_autologon_cookie(Context).
%% @doc Change the password for the current user, use the (optional) 2FA code
-spec change( map(), z:context() ) -> { map(), z:context() }.
change(#{
<<"password">> := Password,
<<"password_reset">> := NewPassword,
<<"passcode">> := Passcode
}, Context) when is_binary(NewPassword), is_binary(Password), is_binary(Passcode) ->
case z_acl:user(Context) of
undefined ->
{ #{ status => error, error => no_user }, Context };
UserId ->
case m_identity:get_username(UserId, Context) of
undefined ->
lager:error("Password change: User ~p does not have an username defined.", [ UserId ]),
{ #{ status => error, error => username }, Context };
Username ->
case auth_precheck(Username, Context) of
ok ->
PasswordMinLength = z_convert:to_integer(m_config:get_value(mod_authentication, password_min_length, 8, Context)),
case size(Password) of
N when N < PasswordMinLength ->
{ #{ status => error, error => tooshort }, Context };
_ ->
change_1(UserId, Username, Password, NewPassword, Passcode, Context)
end;
{error, ratelimit} ->
{ #{ status => error, error => ratelimit }, Context };
_ ->
{ #{ status => error, error => error }, Context }
end
end
end;
change(_Payload, Context) ->
{ #{
status => error,
error => args,
message => <<"Missing one of: password, password_reset, passcode">>
}, Context }.
change_1(UserId, Username, Password, NewPassword, Passcode, Context) ->
Payload = #{
<<"username">> => Username,
<<"password">> => Password,
<<"passcode">> => Passcode
},
case z_notifier:first(#logon_submit{ payload = Payload }, Context) of
{ok, UserId} ->
case reset_1(UserId, Username, NewPassword, Passcode, Context) of
ok ->
logon_1({ok, UserId}, Payload, Context);
{error, Reason} ->
{ #{ status => error, error => Reason }, Context }
end;
{error, ratelimit} ->
{ #{ status => error, error => ratelimit }, Context };
{error, need_passcode} ->
{ #{ status => error, error => need_passcode }, Context };
{error, passcode} ->
{ #{ status => error, error => passcode }, Context };
{ok, _} ->
{ #{ status => error, error => pw }, Context }
end.
%% @doc Reset the password for an user, using the mailed reset secret and (optional) 2FA code.
-spec reset( map(), z:context() ) -> { map(), z:context() }.
reset(#{
<<"secret">> := Secret,
<<"username">> := Username,
<<"password">> := Password,
<<"passcode">> := Passcode
} = Payload, Context) when is_binary(Secret), is_binary(Username), is_binary(Password), is_binary(Passcode) ->
case auth_precheck(Username, Context) of
ok ->
PasswordMinLength = z_convert:to_integer(m_config:get_value(mod_authentication, password_min_length, 8, Context)),
case size(Password) of
N when N < PasswordMinLength ->
{ #{ status => error, error => tooshort }, Context };
_ ->
case get_by_reminder_secret(Secret, Context) of
{ok, UserId} ->
case m_identity:get_username(UserId, Context) of
undefined ->
lager:error("Password reset: User ~p does not have an username defined.", [ UserId ]),
{ #{ status => error, error => username }, Context };
Username ->
case reset_1(UserId, Username, Password, Passcode, Context) of
ok ->
logon_1({ok, UserId}, Payload, Context);
{error, Reason} ->
{ #{ status => error, error => Reason }, Context }
end
end;
undefined ->
{ #{ status => error, error => unknown_code }, Context }
end
end;
{error, ratelimit} ->
{ #{ status => error, error => ratelimit }, Context };
_ ->
{ #{ status => error, error => error }, Context }
end;
reset(_Payload, Context) ->
{ #{
status => error,
error => args,
message => <<"Missing one of: secret, username, password, passcode">>
}, Context }.
reset_1(UserId, Username, Password, Passcode, Context) ->
QArgs = #{
<<"username">> => Username,
<<"passcode">> => Passcode
},
case auth_postcheck(UserId, QArgs, Context) of
ok ->
case m_identity:set_username_pw(UserId, Username, Password, z_acl:sudo(Context)) of
ok ->
ContextLoggedon = z_acl:logon(UserId, Context),
delete_reminder_secret(UserId, ContextLoggedon),
ok;
{error, password_match} ->
{error, password_change_match};
{error, _} ->
{error, error}
end;
{error, need_passcode} ->
{error, need_passcode};
{error, passcode} ->
z_notifier:notify_sync(
#auth_checked{
id = UserId,
username = Username,
is_accepted = false
},
Context),
{error, passcode};
_Error ->
{error, error}
end.
%% @doc Return information about the current user and request language/timezone
-spec status( map(), z:context() ) -> { map(), z:context() }.
status(Payload, Context) ->
Context1 = z_authentication_tokens:ensure_auth_cookie(Context),
return_status(Payload, Context1).
return_status(Payload, Context) ->
Context1 = z_notifier:foldl(
#request_context{
phase = auth_status,
document = maps:get(<<"document">>, Payload, #{})
},
Context,
Context),
Status = #{
status => ok,
is_authenticated => z_auth:is_auth(Context1),
user_id => z_acl:user(Context1),
username => m_identity:get_username(Context1),
preferences => #{
language => z_context:language(Context1),
timezone => z_context:tz(Context1)
},
options => z_context:get(auth_options, Context1, #{}),
url => maps:get(<<"url">>, Payload, undefined)
},
Status1 = case z_auth:is_auth(Context1) of
true ->
Status#{ expires => z_context:get(auth_expires, Context1) };
false ->
Status
end,
{ Status1, Context1 }.
-spec check_reminder_secret( map(), z:context() ) -> map().
check_reminder_secret(#{ <<"secret">> := Secret, <<"username">> := Username }, Context) when is_binary(Secret), is_binary(Username) ->
case z_notifier:first( #auth_reset{ username = Username }, Context ) of
Ok when Ok =:= undefined; Ok =:= ok ->
case get_by_reminder_secret(Secret, Context) of
{ok, UserId} ->
case m_identity:get_username(UserId, Context) of
undefined ->
{[], Context};
Username ->
NeedPasscode = case auth_postcheck(UserId, #{}, Context) of
{error, need_passcode} -> true;
_ -> false
end,
#{
status => ok,
user_id => UserId,
username => Username,
need_passcode => NeedPasscode
};
OtherUsername ->
lager:error("Password reset with username mismatch: got \"~s\", expected \"~s\"",
[ Username, OtherUsername ]),
#{
status => error,
error => unknown_code
}
end;
undefined ->
#{
status => error,
error => unknown_code
}
end;
{error, Reason} ->
#{
status => error,
error => Reason
}
end;
check_reminder_secret(_Payload, _Context) ->
#{
status => error,
error => args,
message => <<"Missing username and/or secret">>
}.
get_by_reminder_secret(Code, Context) ->
MaxAge = case z_convert:to_integer( m_config:get_value(mod_authentication, reset_token_maxage, Context) ) of
undefined -> ?RESET_TOKEN_MAXAGE;
MA -> MA
end,
case m_identity:lookup_by_type_and_key(logon_reminder_secret, Code, Context) of
undefined ->
undefined;
Row ->
{rsc_id, UserId} = proplists:lookup(rsc_id, Row),
{modified, Modified} = proplists:lookup(modified, Row),
ModifiedTm = z_datetime:datetime_to_timestamp(Modified),
case z_datetime:timestamp() < ModifiedTm + MaxAge of
true ->
{ok, UserId};
false ->
lager:info("Accessing expired reminder secret for user ~p", [UserId]),
delete_reminder_secret(UserId, Context),
undefined
end
end.
%% @doc Delete the reminder secret of the user
delete_reminder_secret(Id, Context) ->
m_identity:delete_by_type(Id, logon_reminder_secret, Context).
auth_precheck(Username, Context) when is_binary(Username) ->
case z_notifier:first(#auth_precheck{ username = Username }, Context) of
undefined -> ok;
ok -> ok;
Error -> Error
end.
auth_postcheck(UserId, QueryArgs, Context) when is_map(QueryArgs) ->
case z_notifier:first(#auth_postcheck{ id = UserId, query_args = QueryArgs }, Context) of
undefined -> ok;
ok -> ok;
Error -> Error
end.