Bivio::Biz::Model::UserTOTP
# Copyright (c) 2025 bivio Software Artisans, Inc. All Rights Reserved. package Bivio::Biz::Model::UserTOTP; use strict; use Bivio::Base 'Model.RealmBase'; my($_DT) = b_use('Type.DateTime'); my($_RFC6238) = b_use('Biz.RFC6238'); my($_TA) = b_use('Type.TOTPAlgorithm'); my($_TD) = b_use('Type.TOTPDigits'); my($_TP) = b_use('Type.TOTPPeriod'); my($_TS) = b_use('Type.TOTPSecret'); my($_TST) = b_use('Type.TOTPTimeStepTolerance'); Bivio::IO::Config->register(my $_CFG = { default_algorithm => $_TA->SHA1, default_digits => 6, default_period => 30, time_step_tolerance => 1, }); sub REALM_ID_FIELD { return 'user_id'; } sub REALM_ID_FIELD_TYPE { return 'User.user_id'; } sub SECRET_KEY { return 'totp_secret'; } sub create { my($self, $values) = @_; b_die('secret required') unless $values->{secret}; b_die('time_step required') unless $values->{time_step}; return $self->SUPER::create({ map(($_ => $_CFG->{'default_' . $_}), qw(algorithm digits period)), %$values, secret => $self->get_field_type('secret')->from_literal_or_die($values->{secret}), last_time_step => $self->get_field_type('last_time_step') ->from_literal_or_die($values->{time_step}), }); } sub get_default_algorithm { return $_CFG->{default_algorithm}; } sub get_default_digits { return $_CFG->{default_digits}; } sub get_default_period { return $_CFG->{default_period}; } sub handle_config { my(undef, $cfg) = @_; $_CFG = { map(($_->[0] => $_->[1]->from_literal_or_die($cfg->{$_->[0]})), ( ['default_algorithm', $_TA], ['default_digits', $_TD], ['default_period', $_TP], ['time_step_tolerance', $_TST], )), }; return; } sub internal_initialize { my($self) = @_; return $self->merge_initialize_info($self->SUPER::internal_initialize, { version => 1, table_name => 'user_totp_t', columns => { $self->REALM_ID_FIELD => [$self->REALM_ID_FIELD_TYPE, 'PRIMARY_KEY'], creation_date_time => ['DateTime', 'NOT_NULL'], algorithm => ['TOTPAlgorithm', 'NOT_ZERO_ENUM'], digits => ['TOTPDigits', 'NOT_NULL'], period => ['TOTPPeriod', 'NOT_NULL'], secret => ['TOTPSecret', 'NOT_NULL'], last_time_step => ['TOTPTimeStep', 'NONE'], }, }); } sub is_valid_cookie_code { my($proto, $realm_id, $code, $time_step) = @_; my($model) = $proto->new->set_ephemeral; unless ($model->unauth_load({$proto->REALM_ID_FIELD => $realm_id})) { b_warn('validating cookie totp with no totp'); return 0; } return _code_valid_for_time_step( $code, $model->get(qw(algorithm digits secret)), $time_step); } sub is_valid_input_code { my($self, $input) = @_; my($time_step) = _input_in_range($input, $self->get(qw(algorithm digits period secret))); return 0 unless $time_step; if ($time_step == ($self->get('last_time_step') // -1)) { b_warn('TOTP code reuse disallowed'); return 0; } $self->update({last_time_step => $time_step}); return 1; } sub is_valid_setup { my($proto, $input, $secret) = @_; return _input_in_range( $input, map($_CFG->{$_}, qw(default_algorithm default_digits default_period)), $secret); } sub _input_in_range { my($input, $algorithm, $digits, $period, $secret) = @_; b_die('missing arguments') unless $input && $algorithm && $digits && $period && $secret; my($now_ts) = $_RFC6238->get_time_step($_DT->to_unix($_DT->now), $period); foreach my $ts ( # Test time step for now first as it will most often be the valid one $now_ts, map($now_ts + $_, grep($_ != 0, -$_CFG->{time_step_tolerance} .. $_CFG->{time_step_tolerance})) ) { next unless _code_valid_for_time_step($input, $algorithm, $digits, $secret, $ts); return $ts; } return undef; } sub _code_valid_for_time_step { my($code, $algorithm, $digits, $secret, $time_step) = @_; b_die('missing arguments') unless $code && $algorithm && $digits && $secret && $time_step; return $code eq $_RFC6238->compute($algorithm->get_name, $digits, $secret, $time_step) ? 1 : 0; } 1;