Bivio::Biz::Action::AccessChallenge
# Copyright (c) 2025 bivio Software, Inc. All Rights Reserved.
package Bivio::Biz::Action::AccessChallenge;
use strict;
use Bivio::Base 'Biz.Action';
b_use('IO.Trace');
our($_TRACE);
my($_A) = b_use('Action.Acknowledgement');
my($_C) = b_use('AgentHTTP.Cookie');
my($_TAC) = b_use('Type.AccessCode');
my($_TACS) = b_use('Type.AccessCodeStatus');
my($_UAC) = b_use('Model.UserAccessCode');
my($_ULF) = b_use('Model.UserLoginForm');
my($_COOKIE_KEY) = {
LOGIN_CHALLENGE => 'aclc',
ESCALATION_CHALLENGE => 'acec',
PASSWORD_QUERY => 'acpq',
next_task => 'acnt',
};
my($_PASSWORD_QUERY_KEY) = 'x';
sub LOGIN_CHALLENGE_FIELD {
return $_COOKIE_KEY->{LOGIN_CHALLENGE};
}
sub ESCALATION_CHALLENGE_FIELD {
return $_COOKIE_KEY->{ESCALATION_CHALLENGE};
}
sub PASSWORD_QUERY_FIELD {
return $_COOKIE_KEY->{PASSWORD_QUERY};
}
sub NEXT_TASK_FIELD {
return $_COOKIE_KEY->{next_task};
}
sub assert_challenge {
my($proto, $req, $query) = @_;
return $proto->unsafe_get_challenge($req, $query, 1) || b_die('FORBIDDEN');
}
sub create_challenge {
my($proto, $req, $owner, $type) = @_;
# Cookies are required to localize challenges to current browser. Otherwise,
# a challenge could be used from a browser other than the one where the challenge
# passed, which could be a security issue.
#
# Not using $_C->assert_is_ok($req) as this is used by execute_password_reset and the cookie may
# not have been sent to the browser yet.
b_die('MISSING_COOKIES')
unless my $cookie = $req->unsafe_get('cookie');
b_die('unexpected type=', $type)
unless $type->equals_by_name(qw(LOGIN_CHALLENGE ESCALATION_CHALLENGE));
my($uac) = $_UAC->new($req)->set_ephemeral->create({
$_UAC->REALM_ID_FIELD => $owner->get('realm_id'),
type => $type,
status => $_TACS->PENDING,
});
_put_cookie($cookie, $uac);
_put_req($proto, $req, $uac);
return $uac;
}
sub delete_challenges {
my($proto, $req) = @_;
return
unless my $cookie = $req->ureq('cookie');
_trace('deleting all cookies')
if $_TRACE;
foreach my $t ($_TAC->LOGIN_CHALLENGE, $_TAC->ESCALATION_CHALLENGE) {
# Might be logged out already
if (my $user_id = (
$req->ureq('auth_user_id') || $cookie->unsafe_get($proto->internal_login_form->USER_FIELD)
)) {
my($m) = _unauth_load_from_cookie($proto, $req, {
user_id => $user_id,
type => $t,
status => [$_TACS->get_list],
});
$m->delete
if $m;
}
elsif ($cookie->unsafe_get($_COOKIE_KEY->{$t->get_name})) {
b_warn('no auth user or cookie user, but have code for challenge=', $t);
}
$cookie->delete($_COOKIE_KEY->{$t->get_name});
}
return;
}
sub do_plain_or_mfa {
my(undef, $owner, $plain_op, $mfa_op, $no_context) = @_;
$plain_op ||= sub {};
$mfa_op ||= sub {};
my($methods) = $owner->get_configured_mfa_methods;
unless (int(@{$methods || []})) {
_trace('no MFA methods configured')
if $_TRACE;
return $plain_op->($owner) // _redirect('plain_task', $no_context);
}
# Only TOTP currently supported. If more methods are added, additional task redirects
# will be required, possibly including a task that allows the user to select which of
# multiple configured methods they want to use.
my($m) = $methods->[0]{type};
if (int(@$methods) > 1 || !$m->eq_totp) {
b_die('unsupported methods=', $methods);
}
_trace('redirecting to MFA method=', $m)
if $_TRACE;
return $mfa_op->($owner) // _redirect((lc($m->get_name) . '_task'), $no_context);
}
sub execute_assert_escalation {
my($proto, $req) = @_;
return _assert_escalation($proto, $req);
}
sub execute_assert_login {
my($proto, $req) = @_;
_trace('asserting login')
if $_TRACE;
my($owner) = $proto->internal_login_form->load_cookie_user($req, $req->req('cookie'));
my($uac) = $owner
? _unauth_load_from_cookie($proto, $req, {
user_id => $owner->get('realm_id'),
type => $_TAC->LOGIN_CHALLENGE,
status => $_TACS->PASSED,
}, 1)
: undef;
return _redirect('login_task', 1)
unless $owner && $uac;
b_die('only for mfa login forms to assert plain login')
unless $owner->get_configured_mfa_methods;
_trace('MFA available; creating escalation code')
if $_TRACE;
$proto->create_challenge($req, $owner, $_TAC->ESCALATION_CHALLENGE);
return;
}
sub execute_assert_escalation_if_mfa {
my($proto, $req) = @_;
return _assert_escalation($proto, $req, sub {0});
}
sub execute_password_reset {
my($proto, $req) = @_;
my($query_key) = delete(($req->get('query') || {})->{$_PASSWORD_QUERY_KEY});
my($u) = $req->get_nested(qw(auth_realm owner));
my($res);
my($die) = Bivio::Die->catch(sub {
b_die('no query key')
unless $query_key;
my($err);
($query_key, $err) = $_TAC->PASSWORD_QUERY->from_literal_for_type($query_key);
b_die('invalid query key')
if $err;
my($uac) = _unauth_load($proto, $req, $query_key, {
user_id => $u->get('realm_id'),
type => $_TAC->PASSWORD_QUERY,
status => $_TACS->ACTIVE,
});
b_die('invalid or expired')
unless $uac;
$uac->update({status => $_TACS->USED});
Bivio::Biz::Model->get_instance('UserLoginForm')->execute($req, {
realm_owner => $u,
# there might not be a cookie if user is visiting site
# from the reset-password URI
disable_assert_cookie => 1,
require_mfa => 1,
});
});
if ($die) {
$die->throw
if $die->get('code')->eq_missing_cookies;
_put_ack($proto, $req, 'password_nak');
Bivio::Die->throw(NOT_FOUND => {
entity => $query_key,
realm => $u,
});
}
_put_ack($proto, $req);
return $proto->do_plain_or_mfa($u, sub {
$proto->create_challenge($req, $u, $_TAC->ESCALATION_CHALLENGE)
->update({status => $_TACS->PASSED});
return;
}, sub {
$proto->put_next($req, $req->req('task')->get_attr_as_id('password_task')->get_name);
return;
}, 1);
}
sub format_password_query_uri {
my(undef, $req) = @_;
my($pqsc) = $_UAC->new($req)->create({
type => $_TAC->PASSWORD_QUERY,
status => $_TACS->ACTIVE,
});
return $req->format_http({
task_id => $req->get('task')->get_attr_as_id('reset_task'),
query => {$_PASSWORD_QUERY_KEY => $pqsc->get('code')},
no_context => 1,
});
}
sub get_next {
my($proto, $req) = @_;
return
unless $req->ureq('cookie');
my($next) = $req->req('cookie')->unsafe_get($_COOKIE_KEY->{next_task});
_trace('get next=', $next)
if $_TRACE;
$req->req('cookie')->delete($_COOKIE_KEY->{next_task});
return $next;
}
sub internal_login_form {
return $_ULF;
}
sub put_next {
my($proto, $req, $task) = @_;
_trace('put next task=', $task)
if $_TRACE;
$req->req('cookie')->put($_COOKIE_KEY->{next_task} => $task);
return;
}
sub unauth_assert_challenge {
my($proto, $req, $query) = _assert_query_args(@_);
return _unsafe_get_from_req($proto, $req, $query, 1)
|| _unauth_load_from_cookie($proto, $req, $query, 1)
|| b_die('FORBIDDEN');
}
sub unsafe_get_challenge {
my($proto, $req, $query, $expired_ack) = _assert_query_args(@_);
return _unsafe_get_from_req($proto, $req, $query, $expired_ack)
|| _unsafe_load_from_cookie($proto, $req, $query, $expired_ack);
}
sub _assert_query_args {
my(undef, undef, $query) = @_;
b_die('type required')
unless $query->{type};
b_die('status required')
unless $query->{status};
return @_;
}
sub _assert_escalation {
my($proto, $req, $plain_op, $mfa_op) = @_;
if ($req->is_substitute_user) {
_trace('not requiring escalation for substitute user')
if $_TRACE;
$proto->create_challenge($req, $req->req('auth_user'), $_TAC->ESCALATION_CHALLENGE)
->update({status => $_TACS->PASSED});
return;
}
_trace('asserting escalation')
if $_TRACE;
b_die('must have user in non-general realm')
unless $req->req('auth_user') && !$req->req('auth_realm')->is_general;
return
if _unsafe_load_from_cookie($proto, $req, {
type => $_TAC->ESCALATION_CHALLENGE,
status => $_TACS->PASSED,
}, $req->is_http_method('POST'));
$proto->create_challenge($req, $req->req('auth_user'), $_TAC->ESCALATION_CHALLENGE);
return $proto->do_plain_or_mfa($req->req('auth_user'), $plain_op, $mfa_op);
}
sub _cookie_key {
my($type) = @_;
return $_COOKIE_KEY->{$type->get_name} || b_die('no key for type=', $type);
}
sub _get_req_key {
my($proto, $type) = @_;
return join('.', $proto, lc($type->get_name));
}
sub _load {
my($proto, $req, $method, $code, $query, $expired_ack) = @_;
_trace('load method=', $method, ' query=', $query)
if $_TRACE;
my($uac) = $_UAC->new($req)->set_ephemeral->$method($code, $query, $expired_ack);
_trace('result=', $uac)
if $_TRACE;
_put_req($proto, $req, $uac)
if $uac;
return $uac;
}
sub _put_ack {
my($proto, $req, $label) = @_;
$proto->get_instance('Acknowledgement')->save_label($label ? ($label => $req) : $req);
return;
}
sub _put_cookie {
my($cookie, $uac) = @_;
_trace('put cookie ', _cookie_key($uac->get('type')))
if $_TRACE;
$cookie->put(_cookie_key($uac->get('type')) => $uac->get('code'));
return;
}
sub _put_req {
my($proto, $req, $uac) = @_;
_trace('put req ', _get_req_key($proto, $uac->get('type')), '=', $uac)
if $_TRACE;
$req->put_durable(_get_req_key($proto, $uac->get('type')) => $uac);
return;
}
sub _unauth_load {
my($proto, $req, $code, $query, $expired_ack) = @_;
return _load($proto, $req, 'unauth_load_by_code', $code, $query, $expired_ack);
}
sub _unauth_load_from_cookie {
my($proto, $req, $query, $expired_ack) = @_;
return
unless my $code = _unsafe_get_code_from_cookie($proto, $req, $query->{type});
return _unauth_load($proto, $req, $code, $query, $expired_ack);
}
sub _unsafe_load_from_cookie {
my($proto, $req, $query, $expired_ack) = @_;
return
unless my $code = _unsafe_get_code_from_cookie($proto, $req, $query->{type});
# Not using unsafe_load_by_code as we may not be in user realm
return _load($proto, $req, 'unauth_load_by_code', $code, {
%$query,
user_id => $req->req('auth_user_id'),
}, $expired_ack);
}
sub _unsafe_get_from_req {
my($proto, $req, $query, $expired_ack) = @_;
if (my $uac = $req->ureq(_get_req_key($proto, $query->{type}))) {
_trace('have challenge from req=', $uac)
if $_TRACE;
if ($uac->is_expired) {
_trace('challenge expired')
if $_TRACE;
$_A->save_label(access_code_expired => $req)
if $expired_ack;
return;
}
if (!$uac->get('status')->is_equal($query->{status})) {
_trace('not expected status=', $query->{status})
if $_TRACE;
return;
}
return $uac;
}
return;
}
sub _unsafe_get_code_from_cookie {
my($proto, $req, $type) = @_;
return
unless my $cookie = $req->unsafe_get('cookie');
return
unless my $code = $cookie->unsafe_get(_cookie_key($type));
_trace('have cookie code for type=', $type)
if $_TRACE;
return $code;
}
sub _redirect {
my($task_id, $no_context) = @_;
my($res) = {
method => 'server_redirect',
task_id => shift,
$no_context ? (
no_context => 1,
) : (),
};
_trace('redirect=', $res)
if $_TRACE;
return $res;
}
1;