Bivio::Biz::Model::UserLoginBaseForm
# Copyright (c) 2011-2025 bivio Software, Inc. All Rights Reserved. package Bivio::Biz::Model::UserLoginBaseForm; use strict; use Bivio::Base 'Biz.FormModel'; b_use('IO.Trace'); our($_TRACE); my($_A) = b_use('Action.Acknowledgement'); my($_AAC) = b_use('Action.AccessChallenge'); my($_DT) = b_use('Type.DateTime'); my($_LAS) = b_use('Type.LoginAttemptState'); my($_MM) = b_use('Type.MFAMethod'); my($_R) = b_use('Biz.Random'); my($_TAC) = b_use('Type.AccessCode'); my($_TACS) = b_use('Type.AccessCodeStatus'); sub PASSWORD_FIELD { return 'p'; } sub USER_FIELD { return 'u'; } sub disable_assert_cookie { shift->internal_put_field(disable_assert_cookie => 1); return; } sub get_basic_authorization_realm { my($self) = shift; my($ro) = $self->unsafe_get('realm_owner'); return $ro && $ro->require_otp # Extra space helps out on Mac, which puts a '.' right after realm ? 'Challenge: ' . $self->req('Model.OTP')->get_challenge . ' ' : '*'; } sub handle_cookie_in { # Sets the I<auth_user_id> if user is logged in. Sets the user # in the log (via I<r> record). # # Doesn't read the database to validate ids, simply translates values # from cookie to real code. my(undef, $delegator, $cookie, $req) = shift->delegated_args(@_); my($cookie_user) = $delegator->load_cookie_user($req, $cookie); if ($cookie_user) { my($need_mfa_cookie); my($have_mfa_cookie); foreach my $m (@{$cookie_user->get_configured_mfa_methods || []}) { $need_mfa_cookie = 1; next unless $m->{type}->get_login_form_class->is_valid_cookie($cookie, $cookie_user); $have_mfa_cookie = 1; last; } $cookie_user = undef if $need_mfa_cookie && !$have_mfa_cookie; } $delegator->set_user($cookie_user, $cookie, $req); return; } sub internal_challenge_redirect { my(undef, $delegator, $realm, $res, $req) = shift->delegated_args(@_); return unless $req->ureq('cookie'); return unless ($realm && !$realm->require_otp && $delegator->unsafe_get('validate_called')) || $delegator->unsafe_get('require_mfa'); _trace('successful login; creating challenge for user=', $realm) if $_TRACE; # No precursor that creates challenge, so creating passed challenge here. $_AAC->create_challenge($req, $realm, $_TAC->LOGIN_CHALLENGE) ->update({status => $_TACS->PASSED}); return $_AAC->do_plain_or_mfa($realm, sub { _trace('no MFA; setting user, escalation code, redirecting to next task') if $_TRACE; $delegator->set_user($realm, $req->ureq('cookie'), $req); # Initial login treated as an escalation so user doesn't have to present credentials # twice within a short period. $_AAC->create_challenge($req, $realm, $_TAC->ESCALATION_CHALLENGE) ->update({status => $_TACS->PASSED}); return $_AAC->get_next($req) || $res; }, undef, 1); } sub internal_is_disable_assert_cookie { my($self) = @_; return $self->unsafe_get('disable_assert_cookie') || $self->ureq('disable_assert_cookie') || 0; } sub internal_initialize { my($self) = @_; # B<FOR INTERNAL USE ONLY> my($info) = $self->merge_initialize_info( shift->SUPER::internal_initialize(@_), { # Form versions are checked and mismatches causes VERSION_MISMATCH version => 1, # This form's "next" is the task which redirected to this form. # If redirect was not from a task, returns to normal "next". require_context => 1, # Fields which are shown to the user. visible => [ { name => 'login', type => 'LoginName', constraint => 'NOT_NULL', form_name => 'x1', }, { name => 'RealmOwner.password', form_name => 'x2', }, ], # Fields used internally which are computed dynamically. # They are not sent to or returned from the user. other => [ # The following fields are computed by validate { name => 'realm_owner', # PropertyModels may act as types. type => 'Bivio::Biz::Model::RealmOwner', constraint => 'NONE', }, { # Only set by validate name => 'validate_called', type => 'Boolean', constraint => 'NONE', }, { # Don't assert the cookie is valid name => 'disable_assert_cookie', type => 'Boolean', constraint => 'NONE', }, { name => 'via_mta', type => 'Boolean', constraint => 'NONE', }, { name => 'require_mfa', type => 'Boolean', constraint => 'NONE', }, { name => 'no_record', type => 'Boolean', constraint => 'NONE', }, ], }); foreach my $field (@{$info->{visible}}) { $field = { name => $field, } unless ref($field); next if $field->{form_name}; $field->{form_name} = $field->{name}; } return $info; } sub internal_invalidate_cookie_user { my($proto, $cookie) = @_; $cookie->delete( $proto->USER_FIELD, $proto->PASSWORD_FIELD, ); return; } sub internal_validate_cookie_user { my($proto, $req, $cookie, $auth_user) = @_; # Must have password to be logged in my($cp) = $cookie && $cookie->unsafe_get($proto->PASSWORD_FIELD); return 0 unless $cp; return 1 if _validate_cookie_password($cp, $auth_user); return 0; } sub internal_validate_login_value { my(undef, $delegator, $value) = shift->delegated_args(@_); my($owner) = $delegator->new_other('RealmOwner'); my($err) = $owner->validate_login($value); return $err ? (undef, $err) : ($owner, undef); } sub load_cookie_user { my(undef, $delegator, $req, $cookie) = shift->delegated_args(@_); # Returns auth_user if logged in. Otherwise indicates logged out or # just visitor. return undef unless $cookie->unsafe_get($delegator->USER_FIELD); my($auth_user) = Bivio::Biz::Model->new($req, 'RealmOwner'); if ($auth_user->unauth_load({ realm_id => $cookie->get($delegator->USER_FIELD), realm_type => Bivio::Auth::RealmType->USER, })) { return $delegator->internal_validate_cookie_user($req, $cookie, $auth_user) ? $auth_user : undef; $req->warn($auth_user, ': user is not valid'); } else { $req->warn($cookie->get($delegator->USER_FIELD), ': user_id not found, logging out'); } $delegator->internal_invalidate_cookie_user($cookie); foreach my $t ($_MM->get_non_zero_list) { $t->get_login_form_class->delete_cookie($cookie); } return undef; } sub login_all_forms { my(undef, $delegator, $new_user, $req) = shift->delegated_args(@_); $req ||= $delegator->req; foreach my $class ( $delegator, map($_->{type}->get_login_form_class, @{$new_user->get_configured_mfa_methods || []}), ) { $class->new($req)->process({ realm_owner => $new_user, $delegator->equals_class_name($class->as_classloader_map_name) ? ( $delegator->b_can('internal_is_disable_assert_cookie') ? ( disable_assert_cookie => $delegator->internal_is_disable_assert_cookie, ) : (), ) : ( bypass_challenge => 1, ), }); } return; } sub record_login_attempt { my(undef, undef, $owner, $success) = shift->delegated_args(@_); return _maybe_lock_out($owner, $owner->new_other('LoginAttempt')->create({ realm_id => $owner->get('realm_id'), login_attempt_state => $success ? $_LAS->SUCCESS : $_LAS->FAILURE, })); } sub set_user { my(undef, $delegator, $user, $cookie, $req) = shift->delegated_args(@_); # Sets user on request based on cookie state. $req->set_user($user); $req->put_durable( # Cookie overrides but may not have a cookie so super_user_id super_user_id => _get($cookie, _super_user_field($delegator)) || $req->unsafe_get('super_user_id'), user_state => $delegator->use('Type.UserState')->from_name( $user ? 'LOGGED_IN' : _get($cookie, $delegator->USER_FIELD) ? 'LOGGED_OUT' : 'JUST_VISITOR'), ); _set_log_user($delegator, $cookie, $req); return $user; } sub validate { my(undef, $delegator, $login, $password, $no_record) = shift->delegated_args(@_); $delegator->internal_put_field(validate_called => 1); if (defined($login) && defined($password)) { $delegator->internal_put_field( login => $login, 'RealmOwner.password' => $password, no_record => $no_record, ); } _validate($delegator); # don't send password back to client in error case if ($delegator->in_error) { $delegator->internal_put_field('RealmOwner.password' => undef); $delegator->internal_clear_literal('RealmOwner.password'); } return; } sub validate_login { my(undef, $delegator, $model_or_login, $field) = shift->delegated_args(@_); $field ||= 'login'; my($model) = ref($model_or_login) ? $model_or_login : $delegator; $model->internal_put_field($field => $model_or_login) if defined($model_or_login) && !ref($model_or_login); my($login) = $model->get($field); return undef unless defined($login); my($realm, $err) = $delegator->internal_validate_login_value($login); $model->internal_put_error($field => $err) if $err; $model->internal_put_field(realm_owner => $realm); return $realm; } sub _get { my($cookie, $field) = @_; # Returns cookie field, if there is a cookie. return $cookie && $cookie->unsafe_get($field); } sub _maybe_lock_out { my($owner, $attempt) = @_; if ($attempt->is_state_locked_out) { b_warn('locked out owner=', $owner); $owner->update_password($_R->password); $owner->req->set_user(undef); $owner->req->server_redirect('GENERAL_USER_LOCKED_OUT'); # DOES NOT RETURN } return $attempt; } sub _password_error { my($self, $owner) = @_; my($pw_err); return undef if $owner->get_field_type('password')->is_equal( $owner->get('password'), $self->get('RealmOwner.password'), ); return 'PASSWORD_MISMATCH' unless $owner->require_otp; return 'OTP_PASSWORD_MISMATCH' unless $self->new_other('OTP')->unauth_load_or_die({ user_id => $owner->get('realm_id') })->verify($self->get('RealmOwner.password')); return undef; } sub _set_log_user { my($proto, $cookie, $req, $override_user_id) = @_; # Set the user for this connection. Shows up in the server log. my($r) = $req->unsafe_get('r'); return unless $r; my($uid) = $req->get('auth_user_id') || _get($cookie, $proto->USER_FIELD); my($suid) = $req->unsafe_get('super_user_id') || _get($cookie, _super_user_field($proto)); $r->connection->user( ($suid ? 'su-' . $suid . '-' : '') . ($uid ? ($req->get('user_state')->eq_logged_in ? 'li-' : 'lo-') . $uid : '')); return; } sub _super_user_field { return shift->get_instance('AdmSubstituteUserForm')->SUPER_USER_FIELD; } sub _validate { my($self) = @_; my($owner) = $self->validate_login; return if !$owner || ($self->in_error && !$owner->require_otp); return $self->internal_put_error(login => 'USER_LOCKED_OUT') if $owner->is_locked_out; _validate_login_attempt($self, $owner); return if $self->in_error && !$owner->require_otp; $owner->maybe_upgrade_password($self->get('RealmOwner.password')) if $self->get('RealmOwner.password'); $self->record_login_attempt($owner, 1) unless $self->unsafe_get('no_record'); return; } sub _validate_cookie_password { my($passwd, $auth_user) = @_; return $auth_user->require_otp ? $auth_user->new_other('OTP')->validate_password($passwd, $auth_user) : $passwd eq $auth_user->get('password') ? 1 : 0; } sub _validate_login_attempt { my($self, $owner) = @_; if (my $err = _password_error($self, $owner)) { # Need to stay on page or the login attempt would get rolled back $self->internal_stay_on_page; $self->internal_put_error('RealmOwner.password' => $err); $self->record_login_attempt($owner, 0) unless $self->unsafe_get('no_record'); } return; } 1;