diff options
Diffstat (limited to 'FS')
-rw-r--r-- | FS/FS/ClientAPI/MyAccount.pm | 16 | ||||
-rw-r--r-- | FS/FS/ClientAPI/Signup.pm | 3 | ||||
-rw-r--r-- | FS/FS/Conf.pm | 14 | ||||
-rw-r--r-- | FS/FS/Mason.pm | 1 | ||||
-rw-r--r-- | FS/FS/Password_Mixin.pm | 165 | ||||
-rw-r--r-- | FS/FS/Schema.pm | 52 | ||||
-rw-r--r-- | FS/FS/TemplateItem_Mixin.pm | 7 | ||||
-rw-r--r-- | FS/FS/Template_Mixin.pm | 51 | ||||
-rw-r--r-- | FS/FS/cust_main/Billing.pm | 3 | ||||
-rw-r--r-- | FS/FS/cust_main/Billing_Discount.pm | 7 | ||||
-rw-r--r-- | FS/FS/msg_template.pm | 32 | ||||
-rw-r--r-- | FS/FS/part_export/cardfortress.pm | 6 | ||||
-rw-r--r-- | FS/FS/password_history.pm | 174 | ||||
-rw-r--r-- | FS/FS/quotation_pkg.pm | 23 | ||||
-rw-r--r-- | FS/FS/quotation_pkg_detail.pm | 7 | ||||
-rw-r--r-- | FS/FS/reason.pm | 74 | ||||
-rw-r--r-- | FS/FS/svc_acct.pm | 10 | ||||
-rw-r--r-- | FS/FS/svc_circuit.pm | 8 | ||||
-rw-r--r-- | FS/MANIFEST | 2 | ||||
-rw-r--r-- | FS/t/password_history.t | 5 |
20 files changed, 554 insertions, 106 deletions
diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index 1a3e57ed8..7e1720da5 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -661,6 +661,11 @@ sub customer_info_short { } + # this is here because this routine is called by both fs_ and ng_ main pages, where it appears + # it is not customer-specific, though it is only shown to authenticated customers + # it is not currently agent-specific, though at some point it might be + $return{'announcement'} = join(' ',$conf->config('selfservice-announcement')) || ''; + return { 'error' => '', 'custnum' => $custnum, %return, @@ -2990,13 +2995,15 @@ sub myaccount_passwd { ) && ! $svc_acct->check_password($p->{'old_password'}); + # should move password length checks into is_password_allowed $error = 'Password too short.' if length($p->{'new_password'}) < ($conf->config('passwordmin') || 6); $error = 'Password too long.' if length($p->{'new_password'}) > ($conf->config('passwordmax') || 8); - $svc_acct->set_password($p->{'new_password'}); - $error ||= $svc_acct->replace(); + $error ||= $svc_acct->is_password_allowed($p->{'new_password'}) + || $svc_acct->set_password($p->{'new_password'}) + || $svc_acct->replace(); #regular pw change in self-service should change contact pw too, otherwise its #way too confusing. hell its confusing they're separate at all, but alas. @@ -3275,8 +3282,9 @@ sub process_reset_passwd { if ( $svc_acct ) { - $svc_acct->set_password($p->{'new_password'}); - my $error = $svc_acct->replace(); + my $error ||= $svc_acct->is_password_allowed($p->{'new_password'}) + || $svc_acct->set_password($p->{'new_password'}) + || $svc_acct->replace(); return { %$info, 'error' => $error } if $error; diff --git a/FS/FS/ClientAPI/Signup.pm b/FS/FS/ClientAPI/Signup.pm index 5d719c490..a178bec9c 100644 --- a/FS/FS/ClientAPI/Signup.pm +++ b/FS/FS/ClientAPI/Signup.pm @@ -694,6 +694,9 @@ sub new_customer { map { $_ => $packet->{$_} } qw( username _password sec_phrase popnum domsvc ), }; + + my $error = $svc->is_password_allowed($packet->{_password}); + return { error => $error } if $error; my @acct_snarf; my $snarfnum = 1; diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 990f2a3be..a4cc87199 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -4053,6 +4053,13 @@ and customer address. Include units.', }, { + 'key' => 'password-no_reuse', + 'section' => 'password', + 'description' => 'Minimum number of password changes before a password can be reused. By default, passwords can be reused without restriction.', + 'type' => 'text', + }, + + { 'key' => 'datavolume-forcemegabytes', 'section' => 'UI', 'description' => 'All data volumes are expressed in megabytes', @@ -5679,6 +5686,13 @@ and customer address. Include units.', }, { + 'key' => 'selfservice-announcement', + 'section' => 'self-service', + 'description' => 'HTML announcement to display to all authenticated users on account overview page', + 'type' => 'textarea', + }, + + { 'key' => 'logout-timeout', 'section' => 'UI', 'description' => 'If set, automatically log users out of the backoffice after this many minutes.', diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index 98a75c8df..58b3da7db 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -409,6 +409,7 @@ if ( -e $addl_handler_use_file ) { use FS::report_batch; use FS::report_batch; use FS::report_batch; + use FS::password_history; # Sammath Naur if ( $FS::Mason::addl_handler_use ) { diff --git a/FS/FS/Password_Mixin.pm b/FS/FS/Password_Mixin.pm new file mode 100644 index 000000000..c4549c727 --- /dev/null +++ b/FS/FS/Password_Mixin.pm @@ -0,0 +1,165 @@ +package FS::Password_Mixin; + +use FS::Record qw(qsearch); +use FS::Conf; +use FS::password_history; +use Authen::Passphrase; +use Authen::Passphrase::BlowfishCrypt; +# https://rt.cpan.org/Ticket/Display.html?id=72743 + +our $DEBUG = 1; +our $conf; +FS::UID->install_callback( sub { + $conf = FS::Conf->new; + # this is safe + #eval "use Authen::Passphrase::BlowfishCrypt;"; +}); + +our $me = '[' . __PACKAGE__ . ']'; + +our $BLOWFISH_COST = 10; + +=head1 NAME + +FS::Password_Mixin - Object methods for accounts that have passwords governed +by the password policy. + +=head1 METHODS + +=over 4 + +=item is_password_allowed PASSWORD + +Checks the password against the system password policy. Returns an error +message on failure, an empty string on success. + +This MUST NOT be called from check(). It should be called by the office UI, +self-service ClientAPI, or other I<user-interactive> code that processes a +password change, and only if the user has taken some action with the intent +of changing the password. + +=cut + +sub is_password_allowed { + my $self = shift; + my $password = shift; + + # check length and complexity here + + if ( $conf->config('password-no_reuse') =~ /^(\d+)$/ ) { + + my $no_reuse = $1; + + # "the last N" passwords includes the current password and the N-1 + # passwords before that. + warn "$me checking password reuse limit of $no_reuse\n" if $DEBUG; + my @latest = qsearch({ + 'table' => 'password_history', + 'hashref' => { $self->password_history_key => $self->get($self->primary_key) }, + 'order_by' => " ORDER BY created DESC LIMIT $no_reuse", + }); + + # don't check the first one; reusing the current password is allowed. + shift @latest; + + foreach my $history (@latest) { + warn "$me previous password created ".$history->created."\n" if $DEBUG; + if ( $history->password_equals($password) ) { + my $message; + if ( $no_reuse == 1 ) { + $message = "This password is the same as your previous password."; + } else { + $message = "This password was one of the last $no_reuse passwords on this account."; + } + return $message; + } + } #foreach $history + + } # end of no_reuse checking + + ''; +} + +=item password_history_key + +Returns the name of the field in L<FS::password_history> that's the foreign +key to this table. + +=cut + +sub password_history_key { + my $self = shift; + $self->table . '__' . $self->primary_key; +} + +=item insert_password_history + +Creates a L<FS::password_history> record linked to this object, with its +current password. + +=cut + +sub insert_password_history { + my $self = shift; + my $encoding = $self->_password_encoding; + my $password = $self->_password; + my $auth; + + if ( $encoding eq 'bcrypt' or $encoding eq 'crypt' ) { + + # it's smart enough to figure this out + $auth = Authen::Passphrase->from_crypt($password); + + } elsif ( $encoding eq 'ldap' ) { + + $password =~ s/^{PLAIN}/{CLEARTEXT}/i; # normalize + $auth = Authen::Passphrase->from_rfc2307($password); + if ( $auth->isa('Authen::Passphrase::Clear') ) { + # then we've been given the password in cleartext + $auth = $self->_blowfishcrypt( $auth->passphrase ); + } + + } elsif ( $encoding eq 'plain' ) { + + $auth = $self->_blowfishcrypt( $password ); + + } + + my $password_history = FS::password_history->new({ + _password => $auth->as_rfc2307, + created => time, + $self->password_history_key => $self->get($self->primary_key), + }); + + my $error = $password_history->insert; + return "recording password history: $error" if $error; + ''; + +} + +=item _blowfishcrypt PASSWORD + +For internal use: takes PASSWORD and returns a new +L<Authen::Passphrase::BlowfishCrypt> object representing it. + +=cut + +sub _blowfishcrypt { + my $class = shift; + my $passphrase = shift; + return Authen::Passphrase::BlowfishCrypt->new( + cost => $BLOWFISH_COST, + salt_random => 1, + passphrase => $passphrase, + ); +} + +=back + +=head1 SEE ALSO + +L<FS::password_history> + +=cut + +1; diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 6b5d6586c..5a2a9bead 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1973,15 +1973,15 @@ sub tables_hashref { 'quotation_pkg_detail' => { 'columns' => [ 'detailnum', 'serial', '', '', '', '', - 'billpkgnum', 'int', '', '', '', '', # actually links to quotationpkgnum + 'quotationpkgnum', 'int', '', '', '', '', 'format', 'char', 'NULL', 1, '', '', # not used for anything 'detail', 'varchar', '', 255, '', '', ], 'primary_key' => 'detailnum', 'unique' => [], - 'index' => [ [ 'billpkgnum' ] ], + 'index' => [ [ 'quotationpkgnum' ] ], 'foreign_keys' => [ - { columns => [ 'billpkgnum' ], + { columns => [ 'quotationpkgnum' ], table => 'quotation_pkg', references => [ 'quotationpkgnum' ], }, @@ -7206,6 +7206,52 @@ sub tables_hashref { ], }, + 'password_history' => { + 'columns' => [ + 'passwordnum', 'serial', '', '', '', '', + '_password', 'varchar', 'NULL', $char_d, '', '', + 'encryption_method', 'varchar', 'NULL', $char_d, '', '', + 'created', @date_type, '', '', + # each table that needs password history gets a column here, and + # an entry in foreign_keys. + 'svc_acct__svcnum', 'int', 'NULL', '', '', '', + 'svc_dsl__svcnum', 'int', 'NULL', '', '', '', + 'svc_alarm__svcnum', 'int', 'NULL', '', '', '', + 'agent__agentnum', 'int', 'NULL', '', '', '', + 'contact__contactnum', 'int', 'NULL', '', '', '', + 'access_user__usernum', 'int', 'NULL', '', '', '', + ], + 'primary_key' => 'passwordnum', + 'unique' => [], + 'index' => [], + 'foreign_keys' => [ + { columns => [ 'svc_acct__svcnum' ], + table => 'svc_acct', + references => [ 'svcnum' ], + }, + { columns => [ 'svc_dsl__svcnum' ], + table => 'svc_dsl', + references => [ 'svcnum' ], + }, + { columns => [ 'svc_alarm__svcnum' ], + table => 'svc_alarm', + references => [ 'svcnum' ], + }, + { columns => [ 'agent__agentnum' ], + table => 'agent', + references => [ 'agentnum' ], + }, + { columns => [ 'contact__contactnum' ], + table => 'contact', + references => [ 'contactnum' ], + }, + { columns => [ 'access_user__usernum' ], + table => 'access_user', + references => [ 'usernum' ], + }, + ], + }, + # name type nullability length default local #'new_table' => { diff --git a/FS/FS/TemplateItem_Mixin.pm b/FS/FS/TemplateItem_Mixin.pm index dcd7ab3fb..248da3cae 100644 --- a/FS/FS/TemplateItem_Mixin.pm +++ b/FS/FS/TemplateItem_Mixin.pm @@ -175,6 +175,7 @@ sub details { my $escape_function = $opt{escape_function} || sub { shift }; my $csv = new Text::CSV_XS; + my $key = $self->primary_key; if ( $opt{format_function} ) { @@ -189,14 +190,14 @@ sub details { ) } qsearch ({ 'table' => $self->detail_table, - 'hashref' => { 'billpkgnum' => $self->billpkgnum }, + 'hashref' => { $key => $self->get($key) }, 'order_by' => 'ORDER BY detailnum', }); } elsif ( $opt{'no_usage'} ) { my $sql = "SELECT detail FROM ". $self->detail_table. - " WHERE billpkgnum = ". $self->billpkgnum. + " WHERE " . $key . " = ". $self->get($key). " AND ( format IS NULL OR format != 'C' ) ". " ORDER BY detailnum"; my $sth = dbh->prepare($sql) or die dbh->errstr; @@ -251,7 +252,7 @@ sub details { } my $sql = "SELECT format, detail FROM ". $self->detail_table. - " WHERE billpkgnum = ". $self->billpkgnum. + " WHERE " . $key . " = ". $self->get($key). " ORDER BY detailnum"; my $sth = dbh->prepare($sql) or die dbh->errstr; $sth->execute or die $sth->errstr; diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index e02aa1f87..e889142a5 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -3000,9 +3000,6 @@ location (whichever is defined). multisection: a flag indicating that this is a multisection invoice, which does something complicated. -preref_callback: coderef run for each line item, code should return HTML to be -displayed before that line item (quotations only) - Returns a list of hashrefs, each of which may contain: pkgnum, description, amount, unit_amount, quantity, pkgpart, _is_setup, and @@ -3139,51 +3136,7 @@ sub _items_cust_bill_pkg { 'no_usage' => $opt{'no_usage'}, ); - if ( ref($cust_bill_pkg) eq 'FS::quotation_pkg' ) { - # XXX this should be pulled out into quotation_pkg - - warn "$me _items_cust_bill_pkg cust_bill_pkg is quotation_pkg\n" - if $DEBUG > 1; - # quotation_pkgs are never fees, so don't worry about the case where - # part_pkg is undefined - - # and I guess they're never bundled either? - if ( $cust_bill_pkg->setup != 0 ) { - my $description = $desc; - $description .= ' Setup' - if $cust_bill_pkg->recur != 0 - || $discount_show_always - || $cust_bill_pkg->recur_show_zero; - #push @b, { - # keep it consistent, please - $s = { - 'pkgnum' => $cust_bill_pkg->pkgpart, #so it displays in Ref - 'description' => $description, - 'amount' => sprintf("%.2f", $cust_bill_pkg->setup), - 'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitsetup), - 'quantity' => $cust_bill_pkg->quantity, - 'preref_html' => ( $opt{preref_callback} - ? &{ $opt{preref_callback} }( $cust_bill_pkg ) - : '' - ), - }; - } - if ( $cust_bill_pkg->recur != 0 ) { - #push @b, { - $r = { - 'pkgnum' => $cust_bill_pkg->pkgpart, #so it displays in Ref - 'description' => "$desc (". $cust_bill_pkg->part_pkg->freq_pretty.")", - 'amount' => sprintf("%.2f", $cust_bill_pkg->recur), - 'unit_amount' => sprintf("%.2f", $cust_bill_pkg->unitrecur), - 'quantity' => $cust_bill_pkg->quantity, - 'preref_html' => ( $opt{preref_callback} - ? &{ $opt{preref_callback} }( $cust_bill_pkg ) - : '' - ), - }; - } - - } elsif ( $cust_bill_pkg->pkgnum > 0 ) { + if ( $cust_bill_pkg->pkgnum > 0 ) { # a "normal" package line item (not a quotation, not a fee, not a tax) warn "$me _items_cust_bill_pkg cust_bill_pkg is non-tax\n" @@ -3483,7 +3436,7 @@ sub _items_cust_bill_pkg { + $cust_bill_pkg->recur) }; - } # if quotation / package line item / other line item + } # if package line item / other line item # decide whether to show active discounts here if ( diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index eee0958e0..d3c618d55 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -1183,7 +1183,8 @@ sub _make_lines { } else { # the normal case, not a supplemental package $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0); - return "unparsable frequency: ". $part_pkg->freq + return "unparsable frequency: ". + ($options{freq_override} || $part_pkg->freq) if $next_bill == -1; } diff --git a/FS/FS/cust_main/Billing_Discount.pm b/FS/FS/cust_main/Billing_Discount.pm index 117bf311a..ec2bf077e 100644 --- a/FS/FS/cust_main/Billing_Discount.pm +++ b/FS/FS/cust_main/Billing_Discount.pm @@ -92,8 +92,11 @@ sub discount_terms { my @discount_pkgs = $self->_discount_pkgs_and_bill; shift @discount_pkgs; #discard bill; - - map { $terms{$_->months} = 1 } + + # convert @discount_pkgs (the list of packages that have available discounts) + # to a list of distinct term lengths in months, and strip any decimal places + # from the number of months, not that it should have any + map { $terms{sprintf('%.0f', $_->months)} = 1 } grep { $_->months && $_->months > 1 } map { $_->discount } map { $_->part_pkg->part_pkg_discount } diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm index 01a656366..7d9750cc2 100644 --- a/FS/FS/msg_template.pm +++ b/FS/FS/msg_template.pm @@ -462,6 +462,17 @@ my $usage_warning = sub { # If you add anything, be sure to add a description in # httemplate/edit/msg_template.html. sub substitutions { + my $payinfo_sub = sub { + my $obj = shift; + ($obj->payby eq 'CARD' || $obj->payby eq 'CHEK') + ? $obj->paymask + : $obj->decrypt($obj->payinfo) + }; + my $payinfo_end = sub { + my $obj = shift; + my $payinfo = &$payinfo_sub($obj); + substr($payinfo, -4); + }; { 'cust_main' => [qw( display_custnum agentnum agent_name @@ -608,11 +619,8 @@ sub substitutions { # overrides the one in cust_main in cases where a cust_pay is passed [ payby => sub { FS::payby->shortname(shift->payby) } ], [ date => sub { time2str("%a %B %o, %Y", shift->_date) } ], - [ payinfo => sub { - my $cust_pay = shift; - ($cust_pay->payby eq 'CARD' || $cust_pay->payby eq 'CHEK') ? - $cust_pay->paymask : $cust_pay->decrypt($cust_pay->payinfo) - } ], + [ 'payinfo' => $payinfo_sub ], + [ 'payinfo_end' => $payinfo_end ], ], # for refund receipts 'cust_refund' => [ @@ -620,11 +628,8 @@ sub substitutions { [ refund => sub { sprintf("%.2f", shift->refund) } ], [ payby => sub { FS::payby->shortname(shift->payby) } ], [ date => sub { time2str("%a %B %o, %Y", shift->_date) } ], - [ payinfo => sub { - my $cust_refund = shift; - ($cust_refund->payby eq 'CARD' || $cust_refund->payby eq 'CHEK') ? - $cust_refund->paymask : $cust_refund->decrypt($cust_refund->payinfo) - } ], + [ 'payinfo' => $payinfo_sub ], + [ 'payinfo_end' => $payinfo_end ], ], # for payment decline messages # try to support all cust_pay fields @@ -636,11 +641,8 @@ sub substitutions { [ paid => sub { sprintf("%.2f", shift->paid) } ], [ payby => sub { FS::payby->shortname(shift->payby) } ], [ date => sub { time2str("%a %B %o, %Y", shift->_date) } ], - [ payinfo => sub { - my $pending = shift; - ($pending->payby eq 'CARD' || $pending->payby eq 'CHEK') ? - $pending->paymask : $pending->decrypt($pending->payinfo) - } ], + [ 'payinfo' => $payinfo_sub ], + [ 'payinfo_end' => $payinfo_end ], ], }; } diff --git a/FS/FS/part_export/cardfortress.pm b/FS/FS/part_export/cardfortress.pm index 154f979b0..8c9413597 100644 --- a/FS/FS/part_export/cardfortress.pm +++ b/FS/FS/part_export/cardfortress.pm @@ -4,6 +4,7 @@ use strict; use base 'FS::part_export'; use vars qw( %info ); use String::ShellQuote; +use Net::OpenSSH; #tie my %options, 'Tie::IxHash'; #; @@ -21,8 +22,6 @@ sub rebless { shift; } sub _export_insert { my($self, $svc_acct) = (shift, shift); - eval "use Net::OpenSSH;"; - return $@ if $@; open my $def_in, '<', '/dev/null' or die "unable to open /dev/null"; my $ssh = Net::OpenSSH->new( $self->machine, @@ -61,9 +60,6 @@ sub _export_delete { #well, we're just going to disable them for now, but there you go - eval "use Net::OpenSSH;"; - return $@ if $@; - open my $def_in, '<', '/dev/null' or die "unable to open /dev/null"; my $ssh = Net::OpenSSH->new( $self->machine, default_stdin_fh => $def_in ); diff --git a/FS/FS/password_history.pm b/FS/FS/password_history.pm new file mode 100644 index 000000000..dd527b980 --- /dev/null +++ b/FS/FS/password_history.pm @@ -0,0 +1,174 @@ +package FS::password_history; +use base qw( FS::Record ); + +use strict; +use FS::Record qw( qsearch qsearchs ); +use Authen::Passphrase; + +# the only bit of autogenerated magic in here +our @foreign_keys; +FS::UID->install_callback(sub { + @foreign_keys = grep /__/, __PACKAGE__->dbdef_table->columns; +}); + +=head1 NAME + +FS::password_history - Object methods for password_history records + +=head1 SYNOPSIS + + use FS::password_history; + + $record = new FS::password_history \%hash; + $record = new FS::password_history { 'column' => 'value' }; + + $error = $record->insert; + + $error = $new_record->replace($old_record); + + $error = $record->delete; + + $error = $record->check; + +=head1 DESCRIPTION + +An FS::password_history object represents a current or past password used +by a login account, employee, or other account managed within Freeside. +FS::password_history inherits from FS::Record. The following fields are +currently supported: + +=over 4 + +=item passwordnum - primary key + +=item _password - the encrypted password, as an RFC2307-style string +("{CRYPT}$2a$08$..." or "{MD5}1ab201f..." or similar). This is a serialized +L<Authen::Passphrase> object. + +=item created - the date the password was set to this value. The record with +the most recent created time is the current password. + +=back + +Plus one of the following foreign keys: + +=over 4 + +=item svc_acct__svcnum + +=item svc_dsl__svcnum + +=item svc_alarm__svcnum + +=item agent__agentnum + +=item contact__contactnum + +=item access_user__usernum + +=back + +=head1 METHODS + +=over 4 + +=item new HASHREF + +Creates a new password history record. To add the record to the database, +see L<"insert">. + +=cut + +sub table { 'password_history'; } + +=item insert + +=item delete + +=item replace OLD_RECORD + +=item check + +Checks all fields to make sure this is a valid password history record. If +there is an error, returns the error, otherwise returns false. Called by the +insert and replace methods. + +=cut + +sub check { + my $self = shift; + + my $error = + $self->ut_numbern('passwordnum') + || $self->ut_anything('_password') + || $self->ut_numbern('create') + || $self->ut_numbern('create') + ; + return $error if $error; + + # FKs are mutually exclusive + my $fk_in_use; + foreach my $fk ( @foreign_keys ) { + if ( $self->get($fk) ) { + $self->ut_numbern($fk); + return "multiple records linked to this password_history" if $fk_in_use; + $fk_in_use = $fk; + } + } + + $self->SUPER::check; +} + +=item linked_acct + +Returns the object that's using this password. + +=cut + +sub linked_acct { + my $self = shift; + + foreach my $fk ( @foreign_keys ) { + if ( my $val = $self->get($fk) ) { + my ($table, $key) = split(/__/, $fk); + return qsearchs($table, { $key => $val }); + } + } +} + +=item password_equals PASSWORD + +Returns true if PASSWORD (plaintext) is the same as the one stored in the +history record, false if not. + +=cut + +sub password_equals { + + my ($self, $check_password) = @_; + + # _password here is always LDAP-style. + try { + my $auth = Authen::Passphrase->from_rfc2307($self->_password); + return $auth->match($check_password); + } catch { + # if there's somehow bad data in the _password field, then it doesn't + # match anything. much better than having it match _everything_. + warn "password_history #" . $self->passwordnum . ": $_"; + return ''; + } + +} + +=back + +=head1 BUGS + +=head1 SEE ALSO + +L<FS::Record> + +=cut + +1; + diff --git a/FS/FS/quotation_pkg.pm b/FS/FS/quotation_pkg.pm index 49d0d9a5c..e264209ef 100644 --- a/FS/FS/quotation_pkg.pm +++ b/FS/FS/quotation_pkg.pm @@ -101,21 +101,8 @@ sub display_table { 'quotation_pkg'; } # # (for invoice display order) sub discount_table { 'quotation_pkg_discount'; } - -# detail table uses non-quotation fieldnames, see billpkgnum below sub detail_table { 'quotation_pkg_detail'; } -=item billpkgnum - -Sets/returns quotationpkgnum, for ease of integration with TemplateItem_Mixin::details - -=cut - -sub billpkgnum { - my $self = shift; - $self->quotationpkgnum(@_); -} - =item insert Adds this record to the database. If there is an error, returns the error, @@ -380,7 +367,7 @@ sub delete_details { local $FS::UID::AutoCommit = 0; my $dbh = dbh; - foreach my $detail ( qsearch('quotation_pkg_detail',{ 'billpkgnum' => $self->quotationpkgnum }) ) { + foreach my $detail ( qsearch('quotation_pkg_detail',{ 'quotationpkgnum' => $self->quotationpkgnum }) ) { my $error = $detail->delete; if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -416,8 +403,8 @@ sub set_details { foreach my $detail ( @details ) { my $quotation_pkg_detail = new FS::quotation_pkg_detail { - 'billpkgnum' => $self->quotationpkgnum, - 'detail' => $detail, + 'quotationpkgnum' => $self->quotationpkgnum, + 'detail' => $detail, }; $error = $quotation_pkg_detail->insert; if ( $error ) { @@ -431,6 +418,10 @@ sub set_details { } +sub details_header { + return (); +} + =item cust_bill_pkg_display [ type => TYPE ] =cut diff --git a/FS/FS/quotation_pkg_detail.pm b/FS/FS/quotation_pkg_detail.pm index be3d81529..ce13589f0 100644 --- a/FS/FS/quotation_pkg_detail.pm +++ b/FS/FS/quotation_pkg_detail.pm @@ -34,10 +34,9 @@ currently supported: primary key -=item billpkgnum +=item quotationpkgnum -named thusly for quick compatability with L<FS::TemplateItem_Mixin>, -actually the quotationpkgnum for the relevant L<FS::quotation_pkg> +for the relevant L<FS::quotation_pkg> =item detail @@ -108,7 +107,7 @@ sub check { my $error = $self->ut_numbern('detailnum') - || $self->ut_foreign_key('billpkgnum', 'quotation_pkg', 'quotationpkgnum') + || $self->ut_foreign_key('quotationpkgnum', 'quotation_pkg', 'quotationpkgnum') || $self->ut_text('detail') ; return $error if $error; diff --git a/FS/FS/reason.pm b/FS/FS/reason.pm index 6f4bf62d9..e62bf342b 100644 --- a/FS/FS/reason.pm +++ b/FS/FS/reason.pm @@ -155,6 +155,80 @@ sub reasontype { qsearchs( 'reason_type', { 'typenum' => shift->reason_type } ); } +=item merge + +Accepts an arrayref of reason objects, to be merged into this reason. +Reasons must all have the same reason_type class as this one. +Matching reasonnums will be replaced in the following tables: + + cust_bill_void + cust_bill_pkg_void + cust_credit + cust_credit_void + cust_pay_void + cust_pkg_reason + cust_refund + +=cut + +sub merge { + my ($self,$reasons) = @_; + return "Bad input for merge" unless ref($reasons) eq 'ARRAY'; + + my $class = $self->reasontype->class; + + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; + + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; + + my $error; + foreach my $reason (@$reasons) { + last if $error; + next if $reason->reasonnum eq $self->reasonnum; + $error = "Mismatched reason type class" + unless $reason->reasontype->class eq $class; + foreach my $table ( qw( + cust_bill_void + cust_bill_pkg_void + cust_credit + cust_credit_void + cust_pay_void + cust_pkg_reason + cust_refund + )) { + last if $error; + my @fields = ('reasonnum'); + push(@fields, 'void_reasonnum') if $table eq 'cust_credit_void'; + foreach my $field (@fields) { + last if $error; + foreach my $obj ( qsearch($table,{ $field => $reason->reasonnum }) ) { + last if $error; + $obj->set($field,$self->reasonnum); + $error = $obj->replace; + } + } + } + $error ||= $reason->delete; + } + + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + + ''; + +} + =back =head1 CLASS METHODS diff --git a/FS/FS/svc_acct.pm b/FS/FS/svc_acct.pm index f3070338b..d3e23f237 100644 --- a/FS/FS/svc_acct.pm +++ b/FS/FS/svc_acct.pm @@ -4,6 +4,7 @@ use base qw( FS::svc_Domain_Mixin FS::svc_PBX_Mixin FS::svc_Radius_Mixin FS::svc_Tower_Mixin FS::svc_IP_Mixin + FS::Password_Mixin FS::svc_Common ); @@ -684,6 +685,9 @@ sub insert { 'child_objects' => $self->child_objects, %options, ); + + $error ||= $self->insert_password_history; + if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; @@ -893,6 +897,12 @@ sub replace { my $dbh = dbh; $error = $new->SUPER::replace($old, @_); # usergroup here + + # don't need to record this unless the password was changed + if ( $old->_password ne $new->_password ) { + $error ||= $new->insert_password_history; + } + if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error if $error; diff --git a/FS/FS/svc_circuit.pm b/FS/FS/svc_circuit.pm index 408bd79e4..1a42efadd 100644 --- a/FS/FS/svc_circuit.pm +++ b/FS/FS/svc_circuit.pm @@ -6,7 +6,7 @@ use base qw( FS::MAC_Mixin FS::svc_Common ); -use FS::Record qw( qsearch qsearchs ); +use FS::Record qw( dbh qsearch qsearchs ); use FS::circuit_provider; use FS::circuit_type; use FS::circuit_termination; @@ -221,9 +221,9 @@ sub label { sub search_sql { my ($class, $string) = @_; my @where = (); - push @where, 'LOWER(svc_circuit.circuit_id) = \''.lc($string).'\''; - push @where, 'LOWER(circuit_provider.provider) = \''.lc($string).'\''; - push @where, 'LOWER(circuit_type.typename) = \''.lc($string).'\''; + push @where, 'LOWER(svc_circuit.circuit_id) = ' . dbh->quote($string); + push @where, 'LOWER(circuit_provider.provider) = ' . dbh->quote($string); + push @where, 'LOWER(circuit_type.typename) = ' . dbh->quote($string); '(' . join(' OR ', @where) . ')'; } diff --git a/FS/MANIFEST b/FS/MANIFEST index 5041ccd68..f1195acc7 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -858,3 +858,5 @@ FS/report_batch.pm t/report_batch.t FS/report_batch.pm t/report_batch.t +FS/password_history.pm +t/password_history.t diff --git a/FS/t/password_history.t b/FS/t/password_history.t new file mode 100644 index 000000000..b7a05fd9f --- /dev/null +++ b/FS/t/password_history.t @@ -0,0 +1,5 @@ +BEGIN { $| = 1; print "1..1\n" } +END {print "not ok 1\n" unless $loaded;} +use FS::password_history; +$loaded=1; +print "ok 1\n"; |