Bivio::Biz::Model::UserLoginTOTPForm
# Copyright (c) 2025 bivio Software, Inc. All rights reserved.
package Bivio::Biz::Model::UserLoginTOTPForm;
use strict;
use Bivio::Base 'Model.UserLoginMFABaseForm';
my($_C) = b_use('AgentHTTP.Cookie');
my($_DT) = b_use('Type.DateTime');
my($_MM) = b_use('Type.MFAMethod');
my($_RFC6238) = b_use('Biz.RFC6238');
my($_UT) = b_use('Model.UserTOTP');
sub TOTP_CODE_FIELD {
return 'totpc';
}
sub TOTP_TIME_STEP_FIELD {
return 'totpt';
}
sub SENSITIVE_FIELDS {
return [qw(totp_code)];
}
sub delete_cookie {
my($proto, $cookie) = @_;
shift->SUPER::delete_cookie(@_);
$cookie->delete(
$proto->TOTP_CODE_FIELD,
$proto->TOTP_TIME_STEP_FIELD,
);
return;
}
sub internal_initialize {
my($self) = @_;
return $self->merge_initialize_info($self->SUPER::internal_initialize, {
version => 1,
$self->field_decl(
visible => [
[qw(totp_code Line)],
],
other => [
[qw(totp_time_step Integer)],
],
),
});
}
sub internal_pre_execute {
my($self) = @_;
my($res) = shift->SUPER::internal_pre_execute(@_);
return $res
if $res || !$self->unsafe_get('realm_owner');
_totp_model($self);
return $res;
}
sub internal_set_cookie {
my($self) = @_;
return undef
unless $self->ureq('cookie');
my($cookie) = shift->SUPER::internal_set_cookie(@_);
return $cookie
if $cookie;
$cookie = $self->req('cookie');
$self->delete_cookie($cookie);
if ($self->get('realm_owner')) {
$_C->assert_is_ok($self->req);
if ($self->unsafe_get('totp_code')) {
$cookie->put(
$self->TOTP_CODE_FIELD => $self->get('totp_code'),
$self->TOTP_TIME_STEP_FIELD => $self->get('totp_time_step'),
);
return $cookie;
}
if ($self->req->is_substitute_user || $self->unsafe_get('bypass_challenge')) {
my($totp) = _totp_model($self);
my($ts) = $_RFC6238->get_time_step($_DT->to_unix($_DT->now), $totp->get('period'));
$cookie->put(
$self->TOTP_CODE_FIELD => $_RFC6238->compute(
$totp->get(qw(algorithm digits secret)), $ts),
$self->TOTP_TIME_STEP_FIELD => $ts,
);
return $cookie;
}
b_die('set cookie with no codes');
# DOES NOT RETURN
}
return $cookie;
}
sub is_valid_cookie {
my($proto, $cookie, $auth_user) = @_;
my($res) = shift->SUPER::is_valid_cookie(@_);
return $res
if $res;
if (my $c = $cookie->unsafe_get($proto->TOTP_CODE_FIELD)) {
if (my $t = $cookie->unsafe_get($proto->TOTP_TIME_STEP_FIELD)) {
return $_UT->is_valid_cookie_code($auth_user->get('realm_id'), $c, $t);
}
b_warn('invalid totp cookie fields');
return 0;
}
return 0;
}
sub validate {
my($self, $realm_owner, $mfa_recovery_code, $totp_code) = @_;
shift->SUPER::validate(@_);
if (defined($totp_code)) {
$self->internal_put_field(totp_code => $totp_code);
}
_validate_totp_code($self);
if ($self->in_error) {
$self->internal_clear_sensitive_fields;
$self->internal_login_form->record_login_attempt($self->get('realm_owner'), 0);
return;
}
elsif (!$self->get('totp_code') && !$self->get('mfa_recovery_code')) {
$self->internal_put_error(totp_code => 'NULL');
return;
}
$self->internal_login_form->record_login_attempt($self->get('realm_owner'), 1);
return;
}
sub _totp_model {
my($self) = @_;
my($m) = $self->new_other('UserTOTP');
return $m->unauth_load_or_die({
$m->REALM_ID_FIELD => $self->get_nested(qw(realm_owner realm_id)),
});
}
sub _validate_totp_code {
my($self) = @_;
if ($self->get('totp_code')) {
my($totp) = _totp_model($self);
if ($totp->is_valid_input_code($self->get('totp_code'))) {
$self->internal_put_field(totp_time_step => $totp->get('last_time_step'));
return;
}
# Need to stay on page or the login attempt would get rolled back
$self->internal_stay_on_page;
$self->internal_put_error(totp_code => 'INVALID_TOTP_CODE');
return;
}
return;
}
1;