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;