29e4d823ff2ea1cf7bd065a79a572335fe67c202
[catagits/Catalyst-Plugin-Session.git] / lib / Catalyst / Plugin / Session.pm
1 #!/usr/bin/perl
2
3 package Catalyst::Plugin::Session;
4 use base qw/Class::Accessor::Fast/;
5
6 use strict;
7 use warnings;
8
9 use NEXT;
10 use Catalyst::Exception ();
11 use Digest              ();
12 use overload            ();
13 use Object::Signature   ();
14
15 our $VERSION = "0.12";
16
17 my @session_data_accessors; # used in delete_session
18 BEGIN {
19     __PACKAGE__->mk_accessors(
20         "_session_delete_reason",
21         @session_data_accessors = qw/
22           _sessionid
23           _session
24           _session_expires
25           _extended_session_expires
26           _session_data_sig
27           _flash
28           _flash_keep_keys
29           _flash_key_hashes
30           _tried_loading_session_id
31           _tried_loading_session_data
32           _tried_loading_session_expires
33           _tried_loading_flash_data
34           /
35     );
36 }
37
38 sub setup {
39     my $c = shift;
40
41     $c->NEXT::setup(@_);
42
43     $c->check_session_plugin_requirements;
44     $c->setup_session;
45
46     return $c;
47 }
48
49 sub check_session_plugin_requirements {
50     my $c = shift;
51
52     unless ( $c->isa("Catalyst::Plugin::Session::State")
53         && $c->isa("Catalyst::Plugin::Session::Store") )
54     {
55         my $err =
56           (     "The Session plugin requires both Session::State "
57               . "and Session::Store plugins to be used as well." );
58
59         $c->log->fatal($err);
60         Catalyst::Exception->throw($err);
61     }
62 }
63
64 sub setup_session {
65     my $c = shift;
66
67     my $cfg = ( $c->config->{session} ||= {} );
68
69     %$cfg = (
70         expires        => 7200,
71         verify_address => 1,
72         %$cfg,
73     );
74
75     $c->NEXT::setup_session();
76 }
77
78 sub prepare_action {
79     my $c = shift;
80
81     if (    $c->config->{session}{flash_to_stash}
82         and $c->sessionid
83         and my $flash_data = $c->flash )
84     {
85         @{ $c->stash }{ keys %$flash_data } = values %$flash_data;
86     }
87
88     $c->NEXT::prepare_action(@_);
89 }
90
91 sub finalize {
92     my $c = shift;
93
94     $c->finalize_session;
95     
96     $c->NEXT::finalize(@_);
97 }
98
99 sub finalize_session {
100     my $c = shift;
101
102     $c->_save_session_id;
103     $c->_save_session;
104     $c->_save_flash;
105     $c->_save_session_expires;
106
107     $c->_clear_session_instance_data;
108
109     $c->NEXT::finalize_session;
110 }
111
112 sub _save_session_id {
113     my $c = shift;
114
115     # we already called set when allocating
116     # no need to tell the state plugins anything new
117 }
118
119 sub _save_session_expires {
120     my $c = shift;
121
122     if ( defined($c->_session_expires) ) {
123         my $expires = $c->session_expires; # force extension
124
125         my $sid = $c->sessionid;
126         $c->store_session_data( "expires:$sid" => $expires );
127     }
128 }
129
130 sub _save_session {
131     my $c = shift;
132
133     if ( my $session_data = $c->_session ) {
134
135         no warnings 'uninitialized';
136         if ( Object::Signature::signature($session_data) ne
137             $c->_session_data_sig )
138         {
139             $session_data->{__updated} = time();
140             my $sid = $c->sessionid;
141             $c->store_session_data( "session:$sid" => $session_data );
142         }
143     }
144 }
145
146 sub _save_flash {
147     my $c = shift;
148
149     if ( my $flash_data = $c->_flash ) {
150
151         my $hashes = $c->_flash_key_hashes || {};
152         my $keep = $c->_flash_keep_keys || {};
153         foreach my $key ( keys %$hashes ) {
154             if ( !exists $keep->{$key} and Object::Signature::signature( \$flash_data->{$key} ) eq $hashes->{$key} ) {
155                 delete $flash_data->{$key};
156             }
157         }
158         
159         my $sid = $c->sessionid;
160
161         if (%$flash_data) {
162             $c->store_session_data( "flash:$sid", $flash_data );
163         }
164         else {
165             $c->delete_session_data("flash:$sid");
166         }
167     }
168 }
169
170 sub _load_session_expires {
171     my $c = shift;
172     return $c->_session_expires if $c->_tried_loading_session_expires;
173     $c->_tried_loading_session_expires(1);
174
175     if ( my $sid = $c->sessionid ) {
176         my $expires = $c->get_session_data("expires:$sid") || 0;
177
178         if ( $expires >= time() ) {
179             $c->_session_expires( $expires );
180             return $expires;
181         } else {
182             $c->delete_session( "session expired" );
183             return 0;
184         }
185     }
186
187     return;
188 }
189
190 sub _load_session {
191     my $c = shift;
192     return $c->_session if $c->_tried_loading_session_data;
193     $c->_tried_loading_session_data(1);
194
195     if ( my $sid = $c->sessionid ) {
196         if ( $c->_load_session_expires ) {    # > 0
197
198             my $session_data = $c->get_session_data("session:$sid") || return;
199             $c->_session($session_data);
200
201             no warnings 'uninitialized';    # ne __address
202             if (   $c->config->{session}{verify_address}
203                 && $session_data->{__address} ne $c->request->address )
204             {
205                 $c->log->warn(
206                         "Deleting session $sid due to address mismatch ("
207                       . $session_data->{__address} . " != "
208                       . $c->request->address . ")"
209                 );
210                 $c->delete_session("address mismatch");
211                 return;
212             }
213
214             $c->log->debug(qq/Restored session "$sid"/) if $c->debug;
215             $c->_session_data_sig( Object::Signature::signature($session_data) ) if $session_data;
216             $c->_expire_session_keys;
217
218             return $session_data;
219         }
220     }
221
222     return;
223 }
224
225 sub _load_flash {
226     my $c = shift;
227     return $c->_flash if $c->_tried_loading_flash_data;
228     $c->_tried_loading_flash_data(1);
229
230     if ( my $sid = $c->sessionid ) {
231         if ( my $flash_data = $c->_flash
232             || $c->_flash( $c->get_session_data("flash:$sid") ) )
233         {
234             $c->_flash_key_hashes({ map { $_ => Object::Signature::signature( \$flash_data->{$_} ) } keys %$flash_data });
235             
236             return $flash_data;
237         }
238     }
239
240     return;
241 }
242
243 sub _expire_session_keys {
244     my ( $c, $data ) = @_;
245
246     my $now = time;
247
248     my $expire_times = ( $data || $c->_session || {} )->{__expire_keys} || {};
249     foreach my $key ( grep { $expire_times->{$_} < $now } keys %$expire_times ) {
250         delete $c->_session->{$key};
251         delete $expire_times->{$key};
252     }
253 }
254
255 sub _clear_session_instance_data {
256     my $c = shift;
257     $c->$_(undef) for @session_data_accessors;
258     $c->NEXT::_clear_session_instance_data; # allow other plugins to hook in on this
259 }
260
261 sub delete_session {
262     my ( $c, $msg ) = @_;
263
264     $c->log->debug("Deleting session") if $c->debug;
265
266     # delete the session data
267     if ( my $sid = $c->sessionid ) {
268         $c->delete_session_data("${_}:${sid}") for qw/session expires flash/;
269         $c->delete_session_id($sid);
270     }
271
272     # reset the values in the context object
273     # see the BEGIN block
274     $c->_clear_session_instance_data;
275
276     $c->_session_delete_reason($msg);
277 }
278
279 sub session_delete_reason {
280     my $c = shift;
281
282     $c->session_is_valid; # check that it was loaded
283
284     $c->_session_delete_reason(@_);
285 }
286
287 sub session_expires {
288     my $c = shift;
289
290     if ( defined( my $expires = $c->_extended_session_expires ) ) {
291         return $expires;
292     } elsif ( defined( $expires = $c->_load_session_expires ) ) {
293         return $c->extend_session_expires( $expires );
294     } else {
295         return 0;
296     }
297 }
298
299 sub extend_session_expires {
300     my ( $c, $expires ) = @_;
301     $c->_extended_session_expires( my $updated = $c->calculate_extended_session_expires( $expires ) );
302     $c->extend_session_id( $c->sessionid, $updated );
303     return $updated;
304 }
305
306 sub calculate_initial_session_expires {
307     my $c = shift;
308     return ( time() + $c->config->{session}{expires} );
309 }
310
311 sub calculate_extended_session_expires {
312     my ( $c, $prev ) = @_;
313     $c->calculate_initial_session_expires;
314 }
315
316 sub reset_session_expires {
317     my ( $c, $sid ) = @_;
318     
319     my $exp = $c->calculate_initial_session_expires;
320     $c->_session_expires( $exp );
321     $c->_extended_session_expires( $exp );
322     $exp;
323 }
324
325 sub sessionid {
326     my $c = shift;
327     
328     return $c->_sessionid || $c->_load_sessionid;
329 }
330
331 sub _load_sessionid {
332     my $c = shift;
333     return if $c->_tried_loading_session_id;
334     $c->_tried_loading_session_id(1);
335
336     if ( defined( my $sid = $c->get_session_id ) ) {
337         if ( $c->validate_session_id($sid) ) {
338             # temporarily set the inner key, so that validation will work
339             $c->_sessionid($sid);
340             return $sid;
341         } else {
342             my $err = "Tried to set invalid session ID '$sid'";
343             $c->log->error($err);
344             Catalyst::Exception->throw($err);
345         }
346     }
347
348     return;
349 }
350
351 sub session_is_valid {
352     my $c = shift;
353
354     # force a check for expiry, but also __address, etc
355     if ( $c->_load_session ) {
356         return 1;
357     } else {
358         return;
359     }
360 }
361
362 sub validate_session_id {
363     my ( $c, $sid ) = @_;
364
365     $sid and $sid =~ /^[a-f\d]+$/i;
366 }
367
368 sub session {
369     my $c = shift;
370
371     $c->_session || $c->_load_session || do {
372         $c->create_session_id_if_needed;
373         $c->initialize_session_data;
374     };
375 }
376
377 sub keep_flash {
378     my ( $c, @keys ) = @_;
379     my $href = $c->_flash_keep_keys || $c->_flash_keep_keys({});
380     (@{$href}{@keys}) = ((undef) x @keys);
381 }
382
383 sub flash {
384     my $c = shift;
385     $c->_flash || $c->_load_flash || do {
386         $c->create_session_id_if_needed;
387         $c->_flash( {} );
388     }
389 }
390
391 sub session_expire_key {
392     my ( $c, %keys ) = @_;
393
394     my $now = time;
395     @{ $c->session->{__expire_keys} }{ keys %keys } =
396       map { $now + $_ } values %keys;
397 }
398
399 sub initialize_session_data {
400     my $c = shift;
401
402     my $now = time;
403
404     return $c->_session(
405         {
406             __created => $now,
407             __updated => $now,
408
409             (
410                 $c->config->{session}{verify_address}
411                 ? ( __address => $c->request->address )
412                 : ()
413             ),
414         }
415     );
416 }
417
418 sub generate_session_id {
419     my $c = shift;
420
421     my $digest = $c->_find_digest();
422     $digest->add( $c->session_hash_seed() );
423     return $digest->hexdigest;
424 }
425
426 sub create_session_id_if_needed {
427     my $c = shift;
428     $c->create_session_id unless $c->sessionid;
429 }
430
431 sub create_session_id {
432     my $c = shift;
433     
434     my $sid = $c->generate_session_id;
435
436     $c->log->debug(qq/Created session "$sid"/) if $c->debug;
437
438     $c->_sessionid($sid);
439     $c->reset_session_expires;
440     $c->set_session_id($sid);
441
442     return $sid;
443 }
444
445 my $counter;
446
447 sub session_hash_seed {
448     my $c = shift;
449
450     return join( "", ++$counter, time, rand, $$, {}, overload::StrVal($c), );
451 }
452
453 my $usable;
454
455 sub _find_digest () {
456     unless ($usable) {
457         foreach my $alg (qw/SHA-1 SHA-256 MD5/) {
458             if ( eval { Digest->new($alg) } ) {
459                 $usable = $alg;
460                 last;
461             }
462         }
463         Catalyst::Exception->throw(
464                 "Could not find a suitable Digest module. Please install "
465               . "Digest::SHA1, Digest::SHA, or Digest::MD5" )
466           unless $usable;
467     }
468
469     return Digest->new($usable);
470 }
471
472 sub dump_these {
473     my $c = shift;
474
475     (
476         $c->NEXT::dump_these(),
477
478         $c->sessionid
479         ? ( [ "Session ID" => $c->sessionid ], [ Session => $c->session ], )
480         : ()
481     );
482 }
483
484
485 sub get_session_id { shift->NEXT::get_session_id(@_) }
486 sub set_session_id { shift->NEXT::set_session_id(@_) }
487 sub delete_session_id { shift->NEXT::delete_session_id(@_) }
488 sub extend_session_id { shift->NEXT::extend_session_id(@_) }
489
490 __PACKAGE__;
491
492 __END__
493
494 =pod
495
496 =head1 NAME
497
498 Catalyst::Plugin::Session - Generic Session plugin - ties together server side
499 storage and client side state required to maintain session data.
500
501 =head1 SYNOPSIS
502
503     # To get sessions to "just work", all you need to do is use these plugins:
504
505     use Catalyst qw/
506       Session
507       Session::Store::FastMmap
508       Session::State::Cookie
509       /;
510
511         # you can replace Store::FastMmap with Store::File - both have sensible
512         # default configurations (see their docs for details)
513
514         # more complicated backends are available for other scenarios (DBI storage,
515         # etc)
516
517
518     # after you've loaded the plugins you can save session data
519     # For example, if you are writing a shopping cart, it could be implemented
520     # like this:
521
522     sub add_item : Local {
523         my ( $self, $c ) = @_;
524
525         my $item_id = $c->req->param("item");
526
527         # $c->session is a hash ref, a bit like $c->stash
528         # the difference is that it' preserved across requests
529
530         push @{ $c->session->{items} }, $item_id;
531
532         $c->forward("MyView");
533     }
534
535     sub display_items : Local {
536         my ( $self, $c ) = @_;
537
538         # values in $c->session are restored
539         $c->stash->{items_to_display} =
540           [ map { MyModel->retrieve($_) } @{ $c->session->{items} } ];
541
542         $c->forward("MyView");
543     }
544
545 =head1 DESCRIPTION
546
547 The Session plugin is the base of two related parts of functionality required
548 for session management in web applications.
549
550 The first part, the State, is getting the browser to repeat back a session key,
551 so that the web application can identify the client and logically string
552 several requests together into a session.
553
554 The second part, the Store, deals with the actual storage of information about
555 the client. This data is stored so that the it may be revived for every request
556 made by the same client.
557
558 This plugin links the two pieces together.
559
560 =head1 RECCOMENDED BACKENDS
561
562 =over 4
563
564 =item Session::State::Cookie
565
566 The only really sane way to do state is using cookies.
567
568 =item Session::Store::File
569
570 A portable backend, based on Cache::File.
571
572 =item Session::Store::FastMmap
573
574 A fast and flexible backend, based on Cache::FastMmap.
575
576 =back
577
578 =head1 METHODS
579
580 =over 4
581
582 =item sessionid
583
584 An accessor for the session ID value.
585
586 =item session
587
588 Returns a hash reference that might contain unserialized values from previous
589 requests in the same session, and whose modified value will be saved for future
590 requests.
591
592 This method will automatically create a new session and session ID if none
593 exists.
594
595 =item session_expires
596
597 =item session_expires $reset
598
599 This method returns the time when the current session will expire, or 0 if
600 there is no current session. If there is a session and it already expired, it
601 will delete the session and return 0 as well.
602
603 If the C<$reset> parameter is true, and there is a session ID the expiry time
604 will be reset to the current time plus the time to live (see
605 L</CONFIGURATION>). This is used when creating a new session.
606
607 =item flash
608
609 This is like Ruby on Rails' flash data structure. Think of it as a stash that
610 lasts for longer than one request, letting you redirect instead of forward.
611
612 The flash data will be cleaned up only on requests on which actually use
613 $c->flash (thus allowing multiple redirections), and the policy is to delete
614 all the keys which haven't changed since the flash data was loaded at the end
615 of every request.
616
617     sub moose : Local {
618         my ( $self, $c ) = @_;
619
620         $c->flash->{beans} = 10;
621         $c->response->redirect( $c->uri_for("foo") );
622     }
623
624     sub foo : Local {
625         my ( $self, $c ) = @_;
626
627         my $value = $c->flash->{beans};
628
629         # ...
630
631         $c->response->redirect( $c->uri_for("bar") );
632     }
633
634     sub bar : Local {
635         my ( $self, $c ) = @_;
636
637         if ( exists $c->flash->{beans} ) { # false
638         
639         }
640     }
641
642 =item keep_flash @keys
643
644 If you wawnt to keep a flash key for the next request too, even if it hasn't
645 changed, call C<keep_flash> and pass in the keys as arguments.
646
647 =item delete_session REASON
648
649 This method is used to invalidate a session. It takes an optional parameter
650 which will be saved in C<session_delete_reason> if provided.
651
652 =item session_delete_reason
653
654 This accessor contains a string with the reason a session was deleted. Possible
655 values include:
656
657 =over 4
658
659 =item *
660
661 C<address mismatch>
662
663 =item *
664
665 C<session expired>
666
667 =back
668
669 =item session_expire_key $key, $ttl
670
671 Mark a key to expire at a certain time (only useful when shorter than the
672 expiry time for the whole session).
673
674 For example:
675
676     __PACKAGE__->config->{session}{expires} = 1000000000000; # forever
677
678     # later
679
680     $c->session_expire_key( __user => 3600 );
681
682 Will make the session data survive, but the user will still be logged out after
683 an hour.
684
685 Note that these values are not auto extended.
686
687 =back
688
689 =head1 INTERNAL METHODS
690
691 =over 4
692
693 =item setup
694
695 This method is extended to also make calls to
696 C<check_session_plugin_requirements> and C<setup_session>.
697
698 =item check_session_plugin_requirements
699
700 This method ensures that a State and a Store plugin are also in use by the
701 application.
702
703 =item setup_session
704
705 This method populates C<< $c->config->{session} >> with the default values
706 listed in L</CONFIGURATION>.
707
708 =item prepare_action
709
710 This methoid is extended.
711
712 It's only effect is if the (off by default) C<flash_to_stash> configuration
713 parameter is on - then it will copy the contents of the flash to the stash at
714 prepare time.
715
716 =item finalize
717
718 This method is extended and will extend the expiry time, as well as persist the
719 session data if a session exists.
720
721 =item initialize_session_data
722
723 This method will initialize the internal structure of the session, and is
724 called by the C<session> method if appropriate.
725
726 =item create_session_id
727
728 Creates a new session id using C<generate_session_id> if there is no session ID
729 yet.
730
731 =item validate_session_id SID
732
733 Make sure a session ID is of the right format.
734
735 This currently ensures that the session ID string is any amount of case
736 insensitive hexadecimal characters.
737
738 =item generate_session_id
739
740 This method will return a string that can be used as a session ID. It is
741 supposed to be a reasonably random string with enough bits to prevent
742 collision. It basically takes C<session_hash_seed> and hashes it using SHA-1,
743 MD5 or SHA-256, depending on the availibility of these modules.
744
745 =item session_hash_seed
746
747 This method is actually rather internal to generate_session_id, but should be
748 overridable in case you want to provide more random data.
749
750 Currently it returns a concatenated string which contains:
751
752 =over 4
753
754 =item *
755
756 A counter
757
758 =item *
759
760 The current time
761
762 =item *
763
764 One value from C<rand>.
765
766 =item *
767
768 The stringified value of a newly allocated hash reference
769
770 =item *
771
772 The stringified value of the Catalyst context object
773
774 =back
775
776 In the hopes that those combined values are entropic enough for most uses. If
777 this is not the case you can replace C<session_hash_seed> with e.g.
778
779     sub session_hash_seed {
780         open my $fh, "<", "/dev/random";
781         read $fh, my $bytes, 20;
782         close $fh;
783         return $bytes;
784     }
785
786 Or even more directly, replace C<generate_session_id>:
787
788     sub generate_session_id {
789         open my $fh, "<", "/dev/random";
790         read $fh, my $bytes, 20;
791         close $fh;
792         return unpack("H*", $bytes);
793     }
794
795 Also have a look at L<Crypt::Random> and the various openssl bindings - these
796 modules provide APIs for cryptographically secure random data.
797
798 =item finalize_session
799
800 Clean up the session during C<finalize>.
801
802 This clears the various accessors after saving to the store.
803
804 =item dump_these
805
806 See L<Catalyst/dump_these> - ammends the session data structure to the list of
807 dumped objects if session ID is defined.
808
809 =back
810
811 =head1 USING SESSIONS DURING PREPARE
812
813 The earliest point in time at which you may use the session data is after
814 L<Catalyst::Plugin::Session>'s C<prepare_action> has finished.
815
816 State plugins must set $c->session ID before C<prepare_action>, and during
817 C<prepare_action> L<Catalyst::Plugin::Session> will actually load the data from
818 the store.
819
820         sub prepare_action {
821                 my $c = shift;
822
823                 # don't touch $c->session yet!
824
825                 $c->NEXT::prepare_action( @_ );
826
827                 $c->session;  # this is OK
828                 $c->sessionid; # this is also OK
829         }
830
831 =head1 CONFIGURATION
832
833     $c->config->{session} = {
834         expires => 1234,
835     };
836
837 All configuation parameters are provided in a hash reference under the
838 C<session> key in the configuration hash.
839
840 =over 4
841
842 =item expires
843
844 The time-to-live of each session, expressed in seconds. Defaults to 7200 (two
845 hours).
846
847 =item verify_address
848
849 When true, C<<$c->request->address>> will be checked at prepare time. If it is
850 not the same as the address that initiated the session, the session is deleted.
851
852 =item flash_to_stash
853
854 This option makes it easier to have actions behave the same whether they were
855 forwarded to or redirected to. On prepare time it copies the contents of
856 C<flash> (if any) to the stash.
857
858 =back
859
860 =head1 SPECIAL KEYS
861
862 The hash reference returned by C<< $c->session >> contains several keys which
863 are automatically set:
864
865 =over 4
866
867 =item __expires
868
869 This key no longer exists. Use C<session_expires> instead.
870
871 =item __updated
872
873 The last time a session was saved to the store.
874
875 =item __created
876
877 The time when the session was first created.
878
879 =item __address
880
881 The value of C<< $c->request->address >> at the time the session was created.
882 This value is only populated if C<verify_address> is true in the configuration.
883
884 =back
885
886 =head1 CAVEATS
887
888 =head2 Round the Robin Proxies
889
890 C<verify_address> could make your site inaccessible to users who are behind
891 load balanced proxies. Some ISPs may give a different IP to each request by the
892 same client due to this type of proxying. If addresses are verified these
893 users' sessions cannot persist.
894
895 To let these users access your site you can either disable address verification
896 as a whole, or provide a checkbox in the login dialog that tells the server
897 that it's OK for the address of the client to change. When the server sees that
898 this box is checked it should delete the C<__address> sepcial key from the
899 session hash when the hash is first created.
900
901 =head2 Race Conditions
902
903 In this day and age where cleaning detergents and dutch football (not the
904 american kind) teams roam the plains in great numbers, requests may happen
905 simultaneously. This means that there is some risk of session data being
906 overwritten, like this:
907
908 =over 4
909
910 =item 1.
911
912 request a starts, request b starts, with the same session id
913
914 =item 2.
915
916 session data is loaded in request a
917
918 =item 3.
919
920 session data is loaded in request b
921
922 =item 4.
923
924 session data is changed in request a
925
926 =item 5.
927
928 request a finishes, session data is updated and written to store
929
930 =item 6.
931
932 request b finishes, session data is updated and written to store, overwriting
933 changes by request a
934
935 =back
936
937 If this is a concern in your application, a soon to be developed locking
938 solution is the only safe way to go. This will have a bigger overhead.
939
940 For applications where any given user is only making one request at a time this
941 plugin should be safe enough.
942
943 =head1 AUTHORS
944
945 =over 4
946
947 =item Andy Grundman
948
949 =item Christian Hansen
950
951 =item Yuval Kogman, C<nothingmuch@woobling.org> (current maintainer)
952
953 =item Sebastian Riedel
954
955 =back
956
957 And countless other contributers from #catalyst. Thanks guys!
958
959 =head1 COPYRIGHT & LICENSE
960
961         Copyright (c) 2005 the aforementioned authors. All rights
962         reserved. This program is free software; you can redistribute
963         it and/or modify it under the same terms as Perl itself.
964
965 =cut
966
967