File Coverage

blib/lib/Interchange6/Schema/Result/User.pm
Criterion Covered Total %
statement 98 99 100.0
branch 34 36 97.2
condition 12 12 100.0
subroutine 21 21 100.0
pod 12 12 100.0
total 177 180 99.4


line stmt bran cond sub pod time code
1 2     2   1369 use utf8;
  2         6  
  2         14  
2              
3             package Interchange6::Schema::Result::User;
4              
5             =head1 NAME
6              
7             Interchange6::Schema::Result::User
8              
9             =cut
10              
11 2     2   99 use base 'Interchange6::Schema::Base::Attribute';
  2         5  
  2         1027  
12              
13 2         20 use Interchange6::Schema::Candy -components =>
14 2     2   15 [qw(InflateColumn::DateTime PassphraseColumn TimeStamp)];
  2         6  
15              
16 2     2   8138 use Class::Method::Modifiers;
  2         1798  
  2         149  
17 2     2   1002 use Data::UUID;
  2         1380  
  2         136  
18 2     2   20 use DateTime;
  2         5  
  2         52  
19 2     2   10 use Digest::MD5;
  2         6  
  2         73  
20 2     2   13 use DateTime;
  2         4  
  2         50  
21 2     2   1238 use Session::Token;
  2         2369  
  2         3351  
22              
23             =head1 ACCESSORS
24              
25             =head2 users_id
26              
27             Primary key.
28              
29             =cut
30              
31             primary_column users_id => {
32             data_type => "integer",
33             is_auto_increment => 1,
34             sequence => "users_users_id_seq",
35             };
36              
37             =head2 username
38              
39             The username is automatically converted to lowercase so
40             we make sure that the unique constraint on username works.
41              
42             =cut
43              
44             unique_column username => {
45             data_type => "varchar",
46             size => 255,
47             accessor => '_username',
48             };
49              
50             sub username {
51 9     9 1 35380 my $self = shift;
52 9 100       50 if ( @_ ) {
53 3         6 my $value = shift;
54 3         11 $value = check_username($value);
55 1         39 $self->_username($value);
56             }
57 7         349 return $self->_username();
58             }
59              
60             =head2 nickname
61              
62             Unique nickname for user.
63              
64             =cut
65              
66             unique_column nickname => {
67             data_type => "varchar",
68             is_nullable => 1,
69             size => 255,
70             };
71              
72             =head2 email
73              
74             email address.
75              
76             =cut
77              
78             column email => {
79             data_type => "varchar",
80             default_value => "",
81             size => 255,
82             };
83              
84             =head2 password
85              
86             See L<DBIx::Class::PassphraseColumn> and its dependency
87             L<Authen::Passphrase> for full details of supported password encodings.
88              
89             New/changed passwords are currently encoded using
90             L<Authen::Passphrase::BlowfishCrypt> using 2^14 rounds and random salt.
91              
92             Check password method is C<check_password>.
93              
94             Default value is '{CRYPT}*' which causes C<check_password> to fail.
95              
96             =cut
97              
98             column password => {
99             data_type => "varchar",
100             size => 2048,
101             default_value => '*', # reject
102             passphrase => 'crypt',
103             passphrase_class => 'BlowfishCrypt',
104             passphrase_args => {
105             cost => 14,
106             key_nul => 1,
107             salt_random => 1,
108             },
109             passphrase_check_method => 'check_password',
110             };
111              
112             around 'check_password' => sub {
113             my $orig = shift;
114             my $self = shift;
115             my $ret;
116             # DBIx::Class::PassphraseColumn v0.02 can throw exceptions so use eval.
117             # Must investigate further why column inflation sometimes returns undef
118             # which cause ->match to fail.
119             eval { $ret = $orig->($self, @_) };
120             if ( $ret ) {
121             $self->update( { fail_count => 0, last_login => DateTime->now } );
122             }
123             else {
124             $self->update( { fail_count => ( $self->fail_count || 0 ) + 1 } );
125             }
126             return $ret;
127             };
128              
129             =head2 first_name
130              
131             User's first name.
132              
133             =cut
134              
135             column first_name => {
136             data_type => "varchar",
137             default_value => "",
138             size => 255,
139             };
140              
141             =head2 last_name
142              
143             User's last name.
144              
145             =cut
146              
147             column last_name => {
148             data_type => "varchar",
149             default_value => "",
150             size => 255,
151             };
152              
153             =head2 last_login
154              
155             Last login returned as L<DateTime> object. Updated on successful call to
156             C<check_password>.
157              
158             =cut
159              
160             column last_login => {
161             data_type => "datetime",
162             is_nullable => 1,
163             };
164              
165             =head2 fail_count
166              
167             Count of failed logins since last successful login. On successful call to
168             C<check_password> gets reset to zero but on fail is incremented.
169              
170             =cut
171              
172             column fail_count => {
173             data_type => "integer",
174             default_value => 0,
175             };
176              
177             =head2 reset_expires
178              
179             Date and time when L</reset_token> expires.
180              
181             =cut
182              
183             column reset_expires => {
184             data_type => "datetime",
185             is_nullable => 1,
186             };
187              
188             =head2 reset_token
189              
190             Used to store password reset token.
191              
192             =cut
193              
194             column reset_token => {
195             data_type => "varchar",
196             size => 255,
197             is_nullable => 1,
198             };
199              
200             =head2 is_anonymous
201              
202             Boolean denoting an anonymous user. Defaults to 0 (false);
203              
204             =cut
205              
206             column is_anonymous => {
207             data_type => "boolean",
208             default_value => 0,
209             };
210              
211             =head2 created
212              
213             Date and time when this record was created returned as L<DateTime> object.
214             Value is auto-set on insert.
215              
216             =cut
217              
218             column created => {
219             data_type => "datetime",
220             set_on_create => 1,
221             };
222              
223             =head2 last_modified
224              
225             Date and time when this record was last modified returned as L<DateTime> object.
226             Value is auto-set on insert and update.
227              
228             =cut
229              
230             column last_modified => {
231             data_type => "datetime",
232             set_on_create => 1,
233             set_on_update => 1,
234             };
235              
236             =head2 active
237              
238             Is this user account active? Default is yes.
239              
240             =cut
241              
242             column active => {
243             data_type => "boolean",
244             default_value => 1,
245             };
246              
247             =head1 RELATIONS
248              
249             =head2 addresses
250              
251             Type: has_many
252              
253             Related object: L<Interchange6::Schema::Result::Address>
254              
255             =cut
256              
257             has_many
258             addresses => "Interchange6::Schema::Result::Address",
259             "users_id",
260             { cascade_copy => 0, cascade_delete => 0 };
261              
262             =head2 carts
263              
264             Type: has_many
265              
266             Related object: L<Interchange6::Schema::Result::Cart>
267              
268             =cut
269              
270             has_many
271             carts => "Interchange6::Schema::Result::Cart",
272             "users_id",
273             { cascade_copy => 0, cascade_delete => 0 };
274              
275             =head2 orders
276              
277             Type: has_many
278              
279             Related object: L<Interchange6::Schema::Result::Order>
280              
281             =cut
282              
283             has_many
284             orders => "Interchange6::Schema::Result::Order",
285             "users_id",
286             { cascade_copy => 0, cascade_delete => 0 };
287              
288             =head2 user_attributes
289              
290             Type: has_many
291              
292             Related object: L<Interchange6::Schema::Result::UserAttribute>
293              
294             =cut
295              
296             has_many
297             user_attributes => "Interchange6::Schema::Result::UserAttribute",
298             "users_id",
299             { cascade_copy => 0, cascade_delete => 0 };
300              
301             =head2 user_roles
302              
303             Type: has_many
304              
305             Related object: L<Interchange6::Schema::Result::UserRole>
306              
307             =cut
308              
309             has_many
310             user_roles => "Interchange6::Schema::Result::UserRole",
311             "users_id";
312              
313             =head2 roles
314              
315             Type: many_to_many
316              
317             Composing rels: L</user_roles> -> role
318              
319             =cut
320              
321             many_to_many roles => "user_roles", "role";
322              
323             =head2 approvals
324              
325             Type: has_many
326              
327             Related object: L<Interchange6::Schema::Result::Message> FK C<approved_by_users_id>
328              
329             =cut
330              
331             has_many
332             approvals => "Interchange6::Schema::Result::Message",
333             { 'foreign.approved_by_users_id' => 'self.users_id' };
334              
335             =head2 messages
336              
337             Type: has_many
338              
339             Related object: L<Interchange6::Schema::Result::Message> FK C<author_users_id>
340              
341             =cut
342              
343             has_many
344             messages => "Interchange6::Schema::Result::Message",
345             { 'foreign.author_users_id' => 'self.users_id' };
346              
347             =head1 METHODS
348              
349             Attribute methods are provided by the L<Interchange6::Schema::Base::Attribute> class.
350              
351             =head2 sqlt_deploy_hook
352              
353             Called during table creation to add indexes on the following columns:
354              
355             =over 4
356              
357             =item * reset_token
358              
359             =back
360              
361             =cut
362              
363             sub sqlt_deploy_hook {
364 1     1 1 3503 my ( $self, $table ) = @_;
365              
366 1         19 $table->add_index(
367             name => 'users_idx_reset_token',
368             fields => ['reset_token']
369             );
370             }
371              
372             =head2 reset_token_generate( %args );
373              
374             Arguments should be a hash of the following key/value pairs:
375              
376             =over
377              
378             =item * duration => $datetime_duration_hashref
379              
380             Value should be a hash reference containing values that can be passed directly
381             to L<DateTime::Duration/new>. Passing an undef value to duration will lead
382             to the creation of a reset token that does not expire. Default duration is
383             24 hours.
384              
385             =item * entropy => $number_of_bits
386              
387             The number of bits of entropy to be used by L<Session::Token/new>. Defaults
388             to 128.
389              
390             =back
391              
392             This method sets L</reset_expires> and L</reset_token> and returns the value
393             of L</reset_token> with checksum added.
394              
395             =cut
396              
397             sub reset_token_generate {
398 15     15 1 29452 my ( $self, %args ) = @_;
399              
400 15 100 100     86 if ( exists $args{duration} && !defined $args{duration} ) {
401              
402             # we got undef duration so clear reset_expires
403              
404 1         30 $self->reset_expires(undef);
405             }
406             else {
407              
408             # attempt to set reset_expires to appropriate value
409              
410 14 100       47 if ( $args{duration} ) {
411             $self->throw_exception(
412             "duration arg to reset_token_generate must be a hashref")
413 3 100       86 unless ref( $args{duration} ) eq 'HASH';
414             }
415             else {
416 11         43 $args{duration} = { hours => 24 };
417             }
418              
419 13         57 my $dt = DateTime->now;
420 13         4120 $dt->add( %{$args{duration}} );
  13         81  
421 13         13151 $self->reset_expires($dt);
422             }
423              
424             # generate random token and store it in the DB
425              
426             # Session::Token->new assumes entropy is numeric but first check is '>'
427             # which causes a warning on non-numeric so we check here 1st
428             die "bad value for entropy"
429 14 100 100     6295 if ( $args{entropy} && $args{entropy} !~ /^\d+$/ );
430              
431             # set default entropy if not defined
432 13 100       52 $args{entropy} = 128 unless $args{entropy};
433              
434 13         67 my $token = Session::Token->new( entropy => $args{entropy} )->get;
435 13         3333 $self->reset_token( $token );
436              
437             # flush changes to DB
438              
439 13         2692 $self->update;
440              
441             # return combined token and checksum
442              
443 13         38366 return join( "_", $token, $self->reset_token_checksum($token) );
444             }
445              
446             =head2 reset_token_checksum
447              
448             Returns the checksum for the token stored in L</reset_token>.
449              
450             Checksum is a digest of L</password>, L</reset_token> and L</reset_expires>
451             (if this is defined). This ensures that a reset token is not valid if password
452             has changed or if a newer token has been generated.
453              
454             =cut
455              
456             sub reset_token_checksum {
457 24     24 1 57 my $self = shift;
458 24         276 my $digest = Digest::MD5->new();
459 24 50       673 $digest->add( $self->password->as_crypt ) if $self->password;
460 24         6384 $digest->add( $self->reset_token );
461 24 100       800 $digest->add( $self->reset_expires->datetime ) if $self->reset_expires;
462 24         2345 return $digest->hexdigest();
463             }
464              
465             =head2 reset_token_verify
466              
467             When passed combined token and checksum as argument returns 1 if token
468             and checksum are correct and L</reset_expires> is not in the past (if
469             it is defined). Returns 0 on failure.
470              
471             =cut
472              
473             sub reset_token_verify {
474 16     16 1 4420 my $self = shift;
475 16         78 my ( $token, $checksum ) = split(/_/, shift);
476              
477 16 100       73 $self->throw_exception("Bad argument to reset_token_verify")
478             unless $checksum;
479              
480             # check token and expiry against DB and compare checksum
481              
482             # The following should be combined in a single 'if' statement but sadly
483             # we then end up with Devel::Cover being unable to test the condition
484             # correctly so instead we break it out into ugly code. At least it
485             # means we know our tests are covering all condition. :(
486 14 100       419 if ( $self->reset_token eq $token ) {
487 13 100 100     465 if ( !$self->reset_expires || DateTime->now <= $self->reset_expires ) {
488 11 100       6576 if ( $self->reset_token_checksum eq $checksum ) {
489 8         70 return 1;
490             }
491             }
492             }
493 6         1733 return 0;
494             }
495              
496             =head2 new
497              
498             Overloaded method.
499              
500             If L</username> is undefined and L</is_anonymous> is defined then create
501             a unique username for this anonymous user and set L</active> to false.
502             Otherwise check username using L</check_username>.
503              
504             =cut
505              
506             sub new {
507 53     53 1 136471 my ( $class, $attrs ) = @_;
508              
509 53 100 100     410 if ( defined $attrs->{is_anonymous} && !defined $attrs->{username} ) {
510 1         697 my $ug = Data::UUID->new;
511 1         25 $attrs->{username} = "anonymous-" . lc( $ug->create_str );
512 1         88 $attrs->{active} = 0;
513             }
514             else {
515 52         256 $attrs->{username} = check_username($attrs->{username});
516             }
517              
518 50         249 my $new = $class->next::method($attrs);
519 50         76251637 return $new;
520             }
521              
522             =head2 insert
523              
524             Overloaded method. Always add new users to Role with name 'user' unless user
525             has L</is_anonymous> set in which case add user to role 'anonymous';
526              
527             =cut
528              
529             sub insert {
530 50     50 1 1408 my ($self, @args) = @_;
531            
532 50         264 my $guard = $self->result_source->schema->txn_scope_guard;
533              
534 50         19610 $self->next::method(@args);
535              
536 49 100       262587 my $role_name = $self->is_anonymous ? 'anonymous' : 'user';
537              
538 49         2005 my $user_role = $self->result_source->schema->resultset('Role')
539             ->find( { name => $role_name } );
540              
541             # uncoverable branch false
542 49 50       298740 if ( $user_role ) {
543 49         1176 $self->create_related( 'user_roles', { roles_id => $user_role->id } );
544             }
545             else {
546             # we should never get here
547             # uncoverable statement
548 0         0 $self->throw_exception(
549             "Role with name 'user' must exist when creating a new user");
550             }
551            
552 49         169949 $guard->commit;
553 49         4577 return $self;
554             }
555              
556             =head2 update
557              
558             Overloaded method. Check username using L</check_username> if supplied.
559              
560             =cut
561              
562             sub update {
563 22     22 1 10060 my ( $self, $upd ) = @_;
564              
565 22         55 my $username;
566              
567             # username may have been passed as arg or previously set
568              
569 22 100       106 if ( exists $upd->{username} ) {
570 1         5 $upd->{username} = check_username($upd->{username});
571             }
572              
573 22         120 return $self->next::method($upd);
574             }
575              
576             =head2 check_username( $username )
577              
578             Die if C<$username> is undef or empty string. Otherwise return C<lc($username)>
579              
580             =cut
581              
582             sub check_username {
583 56     56 1 179 my $value = shift;
584 56 100       248 die "username cannot be undef" unless defined $value;
585 53         343 $value =~ s/(^\s+|\s+$)//g;
586 53 100       263 die "username cannot be empty string" if $value eq '';
587 51         254 return lc($value);
588             }
589              
590             =head2 blog_posts
591              
592             Returns resultset of messages that are blog posts
593              
594             =cut
595              
596             sub blog_posts {
597 1     1 1 30780 my $self = shift;
598 1         7 return $self->messages->search( { 'message_type.name' => 'blog_post' },
599             { join => 'message_type' } );
600             }
601              
602             =head2 name
603              
604             Returns L</first_name> and L</last_name> joined by a single space.
605              
606             =cut
607              
608             sub name {
609 6     6 1 12897 my $self = shift;
610 6         170 return join( " ", $self->first_name, $self->last_name );
611             }
612              
613             =head2 reviews
614              
615             Returns resultset of messages that are reviews.
616              
617             =cut
618              
619             sub reviews {
620 1     1 1 44174 my $self = shift;
621 1         31 return $self->messages->search( { 'message_type.name' => 'product_review' },
622             { join => 'message_type' } );
623             }
624              
625             1;