Bivio::Biz::Model::UserAccessCode
# Copyright (c) 2025 bivio Software Artisans, Inc. All Rights Reserved. package Bivio::Biz::Model::UserAccessCode; use strict; use Bivio::Base 'Model.RealmBase'; my($_A) = b_use('Action.Acknowledgement'); my($_DT) = b_use('Type.DateTime'); my($_C) = b_use('Type.AccessCode'); my($_S) = b_use('Type.AccessCodeStatus'); my($_STARTING_STATUSES) = { LOGIN_CHALLENGE => ['PENDING'], ESCALATION_CHALLENGE => ['PENDING'], MFA_RECOVERY => ['ACTIVE'], PASSWORD_QUERY => ['ACTIVE'], }; my($_STATUS_TRANSITIONS) = { LOGIN_CHALLENGE => {PENDING => ['PASSED']}, ESCALATION_CHALLENGE => {PENDING => ['PASSED']}, MFA_RECOVERY => {ACTIVE => ['ARCHIVED']}, PASSWORD_QUERY => {ACTIVE => ['USED']}, }; sub REALM_ID_FIELD { return 'user_id'; } sub REALM_ID_FIELD_TYPE { return 'User.user_id'; } sub create { my($self, $values) = @_; b_die('type required') unless $values->{type}; b_die('status required') unless $values->{status}; b_die('invalid status') unless grep( $values->{status}->equals_by_name($_), @{$_STARTING_STATUSES->{$values->{type}->get_name}}, ); $values->{code} ||= $values->{type}->generate_code_for_type; if ($values->{type}->equals_by_name(qw( login_challenge escalation_challenge password_query ))) { # Users only allowed one of these types in progress at a time. if ($self->req('auth_realm')->is_general) { b_die($self->REALM_ID_FIELD, ' required') unless $values->{$self->REALM_ID_FIELD}; $self->req->with_realm($values->{$self->REALM_ID_FIELD}, sub { _delete_all($self, $values->{type}); return; }); } else { _delete_all($self, $values->{type}) } } return $self->SUPER::create(_values_with_expiry($self, $values)); } sub internal_initialize { my($self) = @_; return $self->merge_initialize_info($self->SUPER::internal_initialize, { version => 1, table_name => 'user_access_code_t', as_string_fields => [qw(user_access_code_id user_id creation_date_time expiration_date_time type status)], columns => { user_access_code_id => [qw(PrimaryId PRIMARY_KEY)], creation_date_time => [qw(DateTime NOT_NULL)], modified_date_time => [qw(DateTime NOT_NULL)], expiration_date_time => [qw(DateTime NONE)], code => [qw(SecretLine NOT_NULL)], type => [qw(AccessCode NOT_ZERO_ENUM)], status => [qw(AccessCodeStatus NOT_ZERO_ENUM)], }, }); } sub is_expired { my($self) = @_; return 0 unless $self->get('expiration_date_time'); return $_DT->is_less_than_or_equals($self->get('expiration_date_time'), $_DT->now) ? 1 : 0; } sub is_valid_cookie_code { my($proto, $realm_id, $code) = @_; my($model) = $proto->new->set_ephemeral; return _find($model, $code, 'unauth_iterate_start', { user_id => $realm_id, type => $_C->MFA_RECOVERY, status => $_S->ARCHIVED, }) ? 1 : 0; } sub update { my($self, $values) = _validate_update(@_); return $self->SUPER::update(_values_with_expiry($self, $values)); } sub unauth_load_by_code { my($self, $code, $query, $expired_ack) = @_; return _find($self, $code, 'unauth_iterate_start', $query, $expired_ack); } sub unsafe_load_by_code { my($self, $code, $query, $expired_ack) = @_; return _find($self, $code, 'iterate_start', $query, $expired_ack); } sub _delete_all { my($self, $type) = @_; return $self->new_other('UserAccessCode')->delete_all({type => $type}); } sub _find { my($self, $code, $method, $query, $expired_ack) = @_; foreach my $f ('type', 'status', $method =~ /unauth/ ? 'user_id' : ()) { b_die($f . ' required') unless $query->{$f}; } my($found); $self->do_iterate(sub { my($it) = @_; return 1 unless $it->get('code') eq $code; if ($it->is_expired) { $_A->save_label(access_code_expired => $self->req) if $expired_ack; return 0; } $found = 1; return 0; }, $method, $query); return $self if $found; $self->internal_unload; return undef; } sub _validate_update { my($self, $values) = @_; if ($values->{status}) { b_die( $self, ' type=', $self->get('type'), ' status=', $self->get('status'), ' update to=', $values->{status}, ' not allowed', ) unless grep( $_ eq $values->{status}->get_name, @{$_STATUS_TRANSITIONS->{$self->get('type')->get_name}{$self->get('status')->get_name}}, ); } return ($self, $values); } sub _values_with_expiry { my($self, $values) = @_; return $values unless $values->{status}; return { %$values, $values->{status}->equals_by_name(qw(active passed)) ? ( expiration_date_time => ($values->{type} || $self->get('type'))->get_expiry_for_type, ) : (), }; } 1;