Skip to content

Commit

Permalink
mod_auth2fa: add option to force setting 2FA before log on. (#3122)
Browse files Browse the repository at this point in the history
* mod_auth2fa: add option to force setting 2FA before log on.

* Add check for 2fa mode in pw reset form
  • Loading branch information
mworrell committed Sep 5, 2022
1 parent db04b66 commit 3b90561
Show file tree
Hide file tree
Showing 24 changed files with 836 additions and 292 deletions.
30 changes: 26 additions & 4 deletions modules/mod_auth2fa/mod_auth2fa.erl
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2019 Marc Worrell
%% @copyright 2019-2022 Marc Worrell
%% @doc Add 2FA TOTP authentication

%% Copyright 2010-2019 Marc Worrell
%% Copyright 2019-2022 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
Expand All @@ -21,7 +21,7 @@

-mod_title("Two-Factor authentication").
-mod_description("Add two-factor authentication using TOTP").
-mod_prio(600).
-mod_prio(400).
-mod_depends([authentication]).

-export([
Expand Down Expand Up @@ -121,5 +121,27 @@ observe_auth_postcheck(#auth_postcheck{ id = UserId, query_args = QueryArgs }, C
end
end;
false ->
undefined
% Could also have a POST of the new passcode secret to be set.
% In that case the passcode can be set for the user and 'undefined'
% returned.
case m_config:get_value(mod_auth2fa, mode, Context) of
<<"3">> ->
case proplists:get_value("code-new", QueryArgs) of
NewCode when NewCode =/= "", NewCode =/= undefined ->
Secret = z_auth2fa_base32:decode(z_convert:to_binary(NewCode)),
Code = proplists:get_value("test_passcode", QueryArgs, ""),
case m_auth2fa:is_valid_totp_test(Secret, Code) of
true ->
% Save the new 2FA code
m_auth2fa:totp_set(UserId, Secret, Context),
undefined;
false ->
{error, set_passcode_error}
end;
_ ->
{error, set_passcode}
end;
_ ->
undefined
end
end.
83 changes: 69 additions & 14 deletions modules/mod_auth2fa/models/m_auth2fa.erl
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2019 Marc Worrell
%% @copyright 2019-2022 Marc Worrell
%% @doc Generate TOTP image data: urls.

%% Copyright 2010-2019 Marc Worrell
%% Copyright 2019-2022 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
Expand All @@ -24,10 +24,13 @@
is_totp_enabled/2,
is_valid_totp/3,

is_valid_totp_test/2,

user_mode/1,

totp_image_url/2,
totp_disable/2
totp_disable/2,
totp_set/3
]).

-include("zotonic.hrl").
Expand Down Expand Up @@ -56,6 +59,16 @@ m_find_value(totp_image_url, #m{ value = undefined }, Context) ->
undefined
end
end;
m_find_value(new_totp_image_url, #m{ value = undefined }, Context) ->
case new_totp_image_url(Context) of
{ok, {Url, Secret}} ->
#{
url => Url,
secret => z_auth2fa_base32:encode(Secret)
};
{error, _} ->
undefined
end;
m_find_value(is_totp_enabled, #m{ value = undefined }, Context) ->
case z_acl:user(Context) of
undefined -> false;
Expand Down Expand Up @@ -96,12 +109,13 @@ is_totp_enabled(UserId, Context) ->
[_] -> true
end.

%% @doc Check the totp mode for the current user: 0 = optional, 1 = ask, 2 = required
-spec user_mode( z:context() ) -> 0 | 1 | 2.
%% @doc Check the totp mode for the current user: 0 = optional, 1 = ask, 2 = required, 3 = forced
-spec user_mode( z:context() ) -> 0 | 1 | 2 | 3.
user_mode(Context) ->
case z_auth:is_auth(Context) of
true ->
case z_convert:to_integer(m_config:get_value(mod_auth2fa, mode, Context)) of
3 -> 3;
2 -> 2;
1 -> erlang:max( user_group_mode(Context), 1 );
_ -> erlang:max( user_group_mode(Context), 0 )
Expand Down Expand Up @@ -133,7 +147,18 @@ user_group_mode(Context) ->
totp_disable(UserId, Context) ->
m_identity:delete_by_type(UserId, ?TOTP_IDENTITY_TYPE, Context).

%% @doc Generate a new totp code and return the barcode
%% @doc Set the totp token for the user
-spec totp_set( m_rsc:resource_id(), Passcode::string()|binary(), z:context() ) -> ok | {error, already_set}.
totp_set(UserId, Passcode, Context) ->
case is_totp_enabled(UserId, Context) of
true ->
{error, already_set};
false ->
{ok, _} = set_user_secret(UserId, Passcode, Context),
ok
end.

%% @doc Generate a new totp code and return the barcode, save it for the user.
-spec totp_image_url( m_rsc:resource_id(), z:context() ) -> {ok, {binary(), binary()}} | {error, eacces}.
totp_image_url(UserId, Context) when is_integer(UserId) ->
case is_allowed_totp_enable(UserId, Context) of
Expand All @@ -152,6 +177,24 @@ totp_image_url(UserId, Context) when is_integer(UserId) ->
{error, eacces}
end.

%% @doc Generate a new totp code and return the barcode, do not save it.
-spec new_totp_image_url( z:context() ) -> {ok, {binary(), binary()}} | {error, eacces}.
new_totp_image_url(Context) ->
Issuer = z_convert:to_binary( z_context:hostname(Context) ),
Title = z_convert:to_binary(m_site:get(title, Context)),
ServicePart = iolist_to_binary([
Issuer,
<<"%3A">>,
z_url:url_encode(Title),
case Issuer of
Title -> <<>>;
_ -> [ <<"%20%2F%20">>, Issuer ]
end
]),
Passcode = new_secret(),
{ok, Png} = generate_png(ServicePart, Issuer, Passcode, ?TOTP_PERIOD),
{ok, {encode_data_url(Png, <<"image/png">>), Passcode}}.

%% Only the admin user can enable totp for the admin user
is_allowed_totp_enable(1, Context) ->
z_acl:user(Context) =:= 1;
Expand All @@ -170,21 +213,29 @@ is_valid_totp(UserId, Code, Context) when is_integer(UserId), is_binary(Code) ->
case m_identity:get_rsc_by_type(UserId, ?TOTP_IDENTITY_TYPE, Context) of
[Idn] ->
Passcode = proplists:get_value(propb, Idn),
{A, B, C} = totp(Passcode, ?TOTP_PERIOD),
case Code of
A -> true;
B -> true;
C -> true;
_ -> false
end;
is_valid_totp_test(Passcode, Code);
[] ->
false
end.

%% @doc Check if the given code is a valid TOTP code
-spec is_valid_totp_test( Secret::string()|binary(), Code::string()|binary() ) -> boolean().
is_valid_totp_test(Secret, Code) ->
{A, B, C} = totp(z_convert:to_binary(Secret), ?TOTP_PERIOD),
case z_convert:to_binary(Code) of
A -> true;
B -> true;
C -> true;
_ -> false
end.

regenerate_user_secret(UserId, Context) ->
Passcode = new_secret(),
set_user_secret(UserId, Passcode, Context).

set_user_secret(UserId, Passcode, Context) ->
F = fun(Ctx) ->
totp_disable(UserId, Context),
Passcode = crypto:hash(sha, z_ids:id(32)),
Props = [
{propb, {term, Passcode}}
],
Expand All @@ -193,6 +244,10 @@ regenerate_user_secret(UserId, Context) ->
end,
z_db:transaction(F, Context).


new_secret() ->
crypto:hash(sha, z_ids:id(32)).

% url format: https://github.com/google/google-authenticator/wiki/Key-Uri-Format
generate_png(Domain, Issuer, Passcode, Seconds) ->
PasscodeBase32 = z_auth2fa_base32:encode(Passcode),
Expand Down
12 changes: 7 additions & 5 deletions modules/mod_auth2fa/templates/_auth2fa_html_body.tpl
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{% with m.auth2fa.user_mode as mode %}
{% if mode == 2 or (mode == 1 and not m.auth2fa.is_totp_requested) %}
{% wire postback={request_2fa} delegate=`mod_auth2fa` %}
{% endif %}
{% endwith %}
{% if not m.auth2fa.is_totp_enabled %}
{% with m.auth2fa.user_mode as mode %}
{% if mode >= 2 or (mode == 1 and not m.auth2fa.is_totp_requested) %}
{% wire postback={request_2fa} delegate=`mod_auth2fa` %}
{% endif %}
{% endwith %}
{% endif %}
43 changes: 43 additions & 0 deletions modules/mod_auth2fa/templates/_logon_login_set_passcode.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<p>{_ You must set your two-factor authentication passcode now. Next time you sign in you will need to enter the two-factor authentication passcode. _}</p>
<p>{_ Scan the two-factor authentication barcode with an app such as <a href="https://support.google.com/accounts/answer/1066447">Google Authenticator</a> or <a href="https://duo.com/product/trusted-users/two-factor-authentication/duo-mobile">Duo Mobile</a>. _}</p>

<div style="margin: 50px 0">
{% with m.auth2fa.new_totp_image_url as totp %}
<p style="text-align: center">
<img src="{{ totp.url }}" style="width: 200px; height: 200px; max-width: 90%">
</p>

<p style="text-align: center">
<input readonly
type="hidden"
value="{{ totp.secret }}"
name="code-new"
id="{{ #secret }}"
style="text-align: center; border: none;">
<a class="btn btn-xs btn-default" id="{{ #btn }}">
<span class="fa fa-copy"></span> {_ Copy _}
</a>
{% wire id=#btn
action={script
script="
document.getElementById('" ++ #secret ++ "').select();
document.execCommand('copy');
"
}
action={growl text=_"Copied to clipboard"}
%}
</p>
{% endwith %}
</div>

<div class="form-group">
<p>{_ After you scanned the barcode, please enter the passcode generated by your two-factor authentication app below. _}</p>

<label for="password" class="control-label">{_ Passcode generated by your app _}</label>
<input class="form-control {% if not is_reset %}do_autofocus{% endif %}" type="text" id="test_passcode" name="test_passcode" value="" autocomplete="one-time-code" placeholder="{_ Two-factor passcode _}" inputmode="numeric" pattern="^[0-9]+$" required>

{% validate id="test_passcode"
type={presence}
type={format pattern="^[0-9]+$"}
%}
</div>
4 changes: 4 additions & 0 deletions modules/mod_auth2fa/templates/_logon_reset_set_passcode.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{# This template is only shown iff no passcode entry field is shown. #}
{% if m.config.mod_auth2fa.mode.value == "3" %}
{% include "_logon_login_set_passcode.tpl" is_reset %}
{% endif %}
18 changes: 16 additions & 2 deletions modules/mod_auth2fa/templates/admin_auth2fa_config.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<input name="2fa_mode" type="radio" id="opt2fa" value="0" {% if not m.config.mod_auth2fa.mode.value %}checked="checked"{% endif %} />
{_ Optional _}
</label>
<p class="help-block">{_ The two-factor authentication can be added per user in the admin or (if available on the site) on their profile page. _}</p>
</div>

<div>
Expand All @@ -42,24 +43,37 @@
<input name="2fa_mode" type="radio" id="ask2fa" value="1" {% if m.config.mod_auth2fa.mode.value == '1' %}checked="checked"{% endif %} />
{_ Ask after signing in _}
</label>
<p class="help-block">{_ On the first page load a dialog appears for setting the two-factor authentication. _}</p>
</div>

<div>
{% wire id="nag2fa"
action={config_toggle module="mod_auth2fa" key="mode"}
%}
<label class="radio-inline">
<input name="2fa_mode" type="radio" id="nag2fa" value="2" {% if m.config.mod_auth2fa.mode.value == '2' %}checked="checked"{% endif %} />
{_ Ask on every page _}
</label>
<p class="help-block">{_ On each page load a dialog appears for setting the two-factor authentication. _}</p>
</div>

<div>
{% wire id="force2fa"
action={config_toggle module="mod_auth2fa" key="mode"}
%}
<label class="radio-inline">
<input name="2fa_mode" type="radio" id="force2fa" value="2" {% if m.config.mod_auth2fa.mode.value == '2' %}checked="checked"{% endif %} />
<input name="2fa_mode" type="radio" id="force2fa" value="3" {% if m.config.mod_auth2fa.mode.value == '3' %}checked="checked"{% endif %} />
{_ Force two-factor authentication _}
</label>
<p class="help-block">{_ The two-factor authentication must be added before logging in. _}</p>
</div>
</div>

{% if m.modules.active.mod_acl_user_groups %}
<h3>{_ User group configuration _}</h3>

<p>{_ It is possible to force two-factor authentication for a specific user group, regardless of the setting above. _}</p>
<p>{_ Check the user groups for which two-factor authentication should be forced. _}</p>
<p>{_ Check the user groups for which two-factor authentication should be forced. _} {_ This is done by showing the two-factor setup dialog on every page load. _}</p>

<ul class="list-unstyled">
{% for cg in m.hierarchy.acl_user_group.tree_flat %}
Expand Down

0 comments on commit 3b90561

Please sign in to comment.