ed1762bfd93b2c4efc8934c16df11d6cd4adccb4
[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
14 our $VERSION = "0.02";
15
16 BEGIN {
17     __PACKAGE__->mk_accessors(qw/_sessionid _session _session_delete_reason _flash _flash_stale_keys/);
18 }
19
20 sub setup {
21     my $c = shift;
22
23     $c->NEXT::setup(@_);
24
25     $c->check_session_plugin_requirements;
26     $c->setup_session;
27
28     return $c;
29 }
30
31 sub check_session_plugin_requirements {
32     my $c = shift;
33
34     unless ( $c->isa("Catalyst::Plugin::Session::State")
35         && $c->isa("Catalyst::Plugin::Session::Store") )
36     {
37         my $err =
38           (     "The Session plugin requires both Session::State "
39               . "and Session::Store plugins to be used as well." );
40
41         $c->log->fatal($err);
42         Catalyst::Exception->throw($err);
43     }
44 }
45
46 sub setup_session {
47     my $c = shift;
48
49     my $cfg = ( $c->config->{session} ||= {} );
50
51     %$cfg = (
52         expires        => 7200,
53         verify_address => 1,
54         %$cfg,
55     );
56
57     $c->NEXT::setup_session();
58 }
59
60 sub finalize {
61     my $c = shift;
62
63     $c->_save_session;
64     $c->_save_flash;
65
66     $c->NEXT::finalize(@_);
67 }
68
69 sub _save_session {
70     my $c = shift;
71     
72     if ( my $sid = $c->_sessionid ) {
73         if ( my $session_data = $c->_session ) {
74
75             # all sessions are extended at the end of the request
76             my $now = time;
77             @{ $session_data }{qw/__updated __expires/} =
78               ( $now, $c->config->{session}{expires} + $now );
79
80             $c->store_session_data( "session:$sid", $session_data );
81         }
82     }
83 }
84
85 sub _save_flash {
86     my $c = shift;
87
88     if ( my $sid = $c->_sessionid ) {
89         if ( my $flash_data = $c->_flash ) {
90             if ( %$flash_data ) { # damn 'my' declarations
91                 delete @{ $flash_data }{ @{ $c->_flash_stale_keys || [] } };
92                 $c->store_session_data( "flash:$sid", $flash_data );
93             }
94         } else {
95             $c->delete_session_data( "flash:$sid" );
96         }
97     }
98 }
99
100 sub _load_session {
101     my $c = shift;
102
103     if ( my $sid = $c->_sessionid ) {
104                 no warnings 'uninitialized'; # ne __address
105
106         my $session_data = $c->_session || $c->_session( $c->get_session_data( "session:$sid" ) );
107         if ( !$session_data or $session_data->{__expires} < time ) {
108
109             # session expired
110             $c->log->debug("Deleting session $sid (expired)") if $c->debug;
111             $c->delete_session("session expired");
112         }
113         elsif ($c->config->{session}{verify_address}
114             && $session_data->{__address} ne $c->request->address )
115         {
116             $c->log->warn(
117                     "Deleting session $sid due to address mismatch ("
118                   . $session_data->{__address} . " != "
119                   . $c->request->address . ")",
120             );
121             $c->delete_session("address mismatch");
122         }
123         else {
124             $c->log->debug(qq/Restored session "$sid"/) if $c->debug;
125         }
126        
127         $c->_expire_ession_keys;
128
129         return $session_data;
130     }
131
132     return undef;
133 }
134
135 sub _load_flash {
136     my $c = shift;
137
138     if ( my $sid = $c->_sessionid ) {
139         if ( my $flash_data = $c->_flash || $c->_flash( $c->get_session_data( "flash:$sid" ) ) ) {
140             $c->_flash_stale_keys([ keys %$flash_data ]);
141             return $flash_data;
142         }
143     }
144
145     return undef;
146 }
147
148 sub _expire_ession_keys {
149     my ( $c, $data ) = @_;
150
151     my $now = time;
152
153     my $expiry = ($data || $c->_session || {})->{__expire_keys} || {};
154     foreach my $key (grep { $expiry->{$_} < $now } keys %$expiry ) {
155         delete $c->_session->{$key};
156         delete $expiry->{$key};
157     }
158 }
159
160 sub delete_session {
161     my ( $c, $msg ) = @_;
162
163     # delete the session data
164     my $sid = $c->_sessionid || return;
165     $c->delete_session_data( "session:$sid" );
166
167     # reset the values in the context object
168     $c->_session(undef);
169     $c->_sessionid(undef);
170     $c->_session_delete_reason($msg);
171 }
172
173 sub session_delete_reason {
174     my $c = shift;
175
176     $c->_load_session if ( $c->_sessionid && !$c->_session ); # must verify session data
177
178     $c->_session_delete_reason( @_ );
179 }
180
181 sub sessionid {
182         my $c = shift;
183     
184         if ( @_ ) {
185                 if ( $c->validate_session_id( my $sid = shift ) ) {
186                         $c->_sessionid( $sid );
187             return unless defined wantarray;
188                 } else {
189                         my $err = "Tried to set invalid session ID '$sid'";
190                         $c->log->error( $err );
191                         Catalyst::Exception->throw( $err );
192                 }
193         }
194     
195     $c->_load_session if ( $c->_sessionid && !$c->_session ); # must verify session data
196
197         return $c->_sessionid;
198 }
199
200 sub validate_session_id {
201         my ( $c, $sid ) = @_;
202
203         $sid and $sid =~ /^[a-f\d]+$/i;
204 }
205
206 sub session {
207     my $c = shift;
208
209     $c->_session || $c->_load_session || do {
210         $c->create_session_id;
211
212         $c->initialize_session_data;
213         };
214 }
215
216 sub flash {
217     my $c = shift;
218     $c->_flash || $c->_load_flash || do {
219         $c->create_session_id;
220         $c->_flash( {} );
221     }
222 }
223
224 sub session_expire_key {
225     my ( $c, %keys ) = @_;
226
227     my $now = time;
228     @{ $c->session->{__expire_keys} }{keys %keys} = map { $now + $_ } values %keys;
229 }
230
231 sub initialize_session_data {
232     my $c = shift;
233
234     my $now = time;
235
236     return $c->_session({
237         __created => $now,
238         __updated => $now,
239         __expires => $now + $c->config->{session}{expires},
240
241         (
242             $c->config->{session}{verify_address}
243             ? ( __address => $c->request->address )
244             : ()
245         ),
246     });
247 }
248
249 sub generate_session_id {
250     my $c = shift;
251
252     my $digest = $c->_find_digest();
253     $digest->add( $c->session_hash_seed() );
254     return $digest->hexdigest;
255 }
256
257 sub create_session_id {
258     my $c = shift;
259
260     if ( !$c->_sessionid ) {
261         my $sid = $c->generate_session_id;
262
263         $c->log->debug(qq/Created session "$sid"/) if $c->debug;
264
265         $c->sessionid($sid);
266     }
267 }
268
269 my $counter;
270
271 sub session_hash_seed {
272     my $c = shift;
273
274     return join( "", ++$counter, time, rand, $$, {}, overload::StrVal($c), );
275 }
276
277 my $usable;
278
279 sub _find_digest () {
280     unless ($usable) {
281         foreach my $alg (qw/SHA-1 MD5 SHA-256/) {
282             eval {
283                 my $obj = Digest->new($alg);
284                 $usable = $alg;
285                 return $obj;
286             };
287         }
288         $usable
289           or Catalyst::Exception->throw(
290                 "Could not find a suitable Digest module. Please install "
291               . "Digest::SHA1, Digest::SHA, or Digest::MD5" );
292     }
293
294     return Digest->new($usable);
295 }
296
297 sub dump_these {
298     my $c = shift;
299
300     (
301         $c->NEXT::dump_these(),
302
303         $c->sessionid
304         ? ( [ "Session ID" => $c->sessionid ], [ Session => $c->session ], )
305         : ()
306     );
307 }
308
309 __PACKAGE__;
310
311 __END__
312
313 =pod
314
315 =head1 NAME
316
317 Catalyst::Plugin::Session - Generic Session plugin - ties together server side
318 storage and client side state required to maintain session data.
319
320 =head1 SYNOPSIS
321
322     # To get sessions to "just work", all you need to do is use these plugins:
323
324     use Catalyst qw/
325       Session
326       Session::Store::FastMmap
327       Session::State::Cookie
328       /;
329
330         # you can replace Store::FastMmap with Store::File - both have sensible
331         # default configurations (see their docs for details)
332
333         # more complicated backends are available for other scenarios (DBI storage,
334         # etc)
335
336
337     # after you've loaded the plugins you can save session data
338     # For example, if you are writing a shopping cart, it could be implemented
339     # like this:
340
341     sub add_item : Local {
342         my ( $self, $c ) = @_;
343
344         my $item_id = $c->req->param("item");
345
346         # $c->session is a hash ref, a bit like $c->stash
347         # the difference is that it' preserved across requests
348
349         push @{ $c->session->{items} }, $item_id;
350
351         $c->forward("MyView");
352     }
353
354     sub display_items : Local {
355         my ( $self, $c ) = @_;
356
357         # values in $c->session are restored
358         $c->stash->{items_to_display} =
359           [ map { MyModel->retrieve($_) } @{ $c->session->{items} } ];
360
361         $c->forward("MyView");
362     }
363
364 =head1 DESCRIPTION
365
366 The Session plugin is the base of two related parts of functionality required
367 for session management in web applications.
368
369 The first part, the State, is getting the browser to repeat back a session key,
370 so that the web application can identify the client and logically string
371 several requests together into a session.
372
373 The second part, the Store, deals with the actual storage of information about
374 the client. This data is stored so that the it may be revived for every request
375 made by the same client.
376
377 This plugin links the two pieces together.
378
379 =head1 RECCOMENDED BACKENDS
380
381 =over 4
382
383 =item Session::State::Cookie
384
385 The only really sane way to do state is using cookies.
386
387 =item Session::Store::File
388
389 A portable backend, based on Cache::File.
390
391 =item Session::Store::FastMmap
392
393 A fast and flexible backend, based on Cache::FastMmap.
394
395 =back
396
397 =head1 METHODS
398
399 =over 4
400
401 =item sessionid
402
403 An accessor for the session ID value.
404
405 =item session
406
407 Returns a hash reference that might contain unserialized values from previous
408 requests in the same session, and whose modified value will be saved for future
409 requests.
410
411 This method will automatically create a new session and session ID if none
412 exists.
413
414 =item flash
415
416 This is like Ruby on Rails' flash data structure. Think of it as a stash that
417 lasts a single redirect, not only a forward.
418
419     sub moose : Local {
420         my ( $self, $c ) = @_;
421
422         $c->flash->{beans} = 10;
423         $c->response->redirect( $c->uri_for("foo") );
424     }
425
426     sub foo : Local {
427         my ( $self, $c ) = @_;
428
429         my $value = $c->flash->{beans};
430
431         # ...
432
433         $c->response->redirect( $c->uri_for("bar") );
434     }
435
436     sub bar : Local {
437         my ( $self, $c ) = @_;
438
439         if ( exists $c->flash->{beans} ) { # false
440         
441         }
442     }
443
444 =item session_delete_reason
445
446 This accessor contains a string with the reason a session was deleted. Possible
447 values include:
448
449 =over 4
450
451 =item *
452
453 C<address mismatch>
454
455 =item *
456
457 C<session expired>
458
459 =back
460
461 =item session_expire_key $key, $ttl
462
463 Mark a key to expire at a certain time (only useful when shorter than the
464 expiry time for the whole session).
465
466 For example:
467
468     __PACKAGE__->config->{session}{expires} = 1000000000000; # forever
469
470     # later
471
472     $c->session_expire_key( __user => 3600 );
473
474 Will make the session data survive, but the user will still be logged out after
475 an hour.
476
477 Note that these values are not auto extended.
478
479 =back
480
481 =item INTERNAL METHODS
482
483 =over 4
484
485 =item setup
486
487 This method is extended to also make calls to
488 C<check_session_plugin_requirements> and C<setup_session>.
489
490 =item check_session_plugin_requirements
491
492 This method ensures that a State and a Store plugin are also in use by the
493 application.
494
495 =item setup_session
496
497 This method populates C<< $c->config->{session} >> with the default values
498 listed in L</CONFIGURATION>.
499
500 =item prepare_action
501
502 This methoid is extended, and will restore session data and check it for
503 validity if a session id is defined. It assumes that the State plugin will
504 populate the C<sessionid> key beforehand.
505
506 =item finalize
507
508 This method is extended and will extend the expiry time, as well as persist the
509 session data if a session exists.
510
511 =item delete_session REASON
512
513 This method is used to invalidate a session. It takes an optional parameter
514 which will be saved in C<session_delete_reason> if provided.
515
516 =item initialize_session_data
517
518 This method will initialize the internal structure of the session, and is
519 called by the C<session> method if appropriate.
520
521 =item generate_session_id
522
523 This method will return a string that can be used as a session ID. It is
524 supposed to be a reasonably random string with enough bits to prevent
525 collision. It basically takes C<session_hash_seed> and hashes it using SHA-1,
526 MD5 or SHA-256, depending on the availibility of these modules.
527
528 =item session_hash_seed
529
530 This method is actually rather internal to generate_session_id, but should be
531 overridable in case you want to provide more random data.
532
533 Currently it returns a concatenated string which contains:
534
535 =item validate_session_id SID
536
537 Make sure a session ID is of the right format.
538
539 This currently ensures that the session ID string is any amount of case
540 insensitive hexadecimal characters.
541
542 =over 4
543
544 =item *
545
546 A counter
547
548 =item *
549
550 The current time
551
552 =item *
553
554 One value from C<rand>.
555
556 =item *
557
558 The stringified value of a newly allocated hash reference
559
560 =item *
561
562 The stringified value of the Catalyst context object
563
564 =back
565
566 In the hopes that those combined values are entropic enough for most uses. If
567 this is not the case you can replace C<session_hash_seed> with e.g.
568
569     sub session_hash_seed {
570         open my $fh, "<", "/dev/random";
571         read $fh, my $bytes, 20;
572         close $fh;
573         return $bytes;
574     }
575
576 Or even more directly, replace C<generate_session_id>:
577
578     sub generate_session_id {
579         open my $fh, "<", "/dev/random";
580         read $fh, my $bytes, 20;
581         close $fh;
582         return unpack("H*", $bytes);
583     }
584
585 Also have a look at L<Crypt::Random> and the various openssl bindings - these
586 modules provide APIs for cryptographically secure random data.
587
588 =item dump_these
589
590 See L<Catalyst/dump_these> - ammends the session data structure to the list of
591 dumped objects if session ID is defined.
592
593 =back
594
595 =head1 USING SESSIONS DURING PREPARE
596
597 The earliest point in time at which you may use the session data is after
598 L<Catalyst::Plugin::Session>'s C<prepare_action> has finished.
599
600 State plugins must set $c->session ID before C<prepare_action>, and during
601 C<prepare_action> L<Catalyst::Plugin::Session> will actually load the data from
602 the store.
603
604         sub prepare_action {
605                 my $c = shift;
606
607                 # don't touch $c->session yet!
608
609                 $c->NEXT::prepare_action( @_ );
610
611                 $c->session;  # this is OK
612                 $c->sessionid; # this is also OK
613         }
614
615 =head1 CONFIGURATION
616
617     $c->config->{session} = {
618         expires => 1234,
619     };
620
621 All configuation parameters are provided in a hash reference under the
622 C<session> key in the configuration hash.
623
624 =over 4
625
626 =item expires
627
628 The time-to-live of each session, expressed in seconds. Defaults to 7200 (two
629 hours).
630
631 =item verify_address
632
633 When true, C<<$c->request->address>> will be checked at prepare time. If it is
634 not the same as the address that initiated the session, the session is deleted.
635
636 =back
637
638 =head1 SPECIAL KEYS
639
640 The hash reference returned by C<< $c->session >> contains several keys which
641 are automatically set:
642
643 =over 4
644
645 =item __expires
646
647 A timestamp whose value is the last second when the session is still valid. If
648 a session is restored, and __expires is less than the current time, the session
649 is deleted.
650
651 =item __updated
652
653 The last time a session was saved. This is the value of
654 C<< $c->session->{__expires} - $c->config->session->{expires} >>.
655
656 =item __created
657
658 The time when the session was first created.
659
660 =item __address
661
662 The value of C<< $c->request->address >> at the time the session was created.
663 This value is only populated if C<verify_address> is true in the configuration.
664
665 =back
666
667 =head1 CAVEATS
668
669 =head2 Round the Robin Proxies
670
671 C<verify_address> could make your site inaccessible to users who are behind
672 load balanced proxies. Some ISPs may give a different IP to each request by the
673 same client due to this type of proxying. If addresses are verified these
674 users' sessions cannot persist.
675
676 To let these users access your site you can either disable address verification
677 as a whole, or provide a checkbox in the login dialog that tells the server
678 that it's OK for the address of the client to change. When the server sees that
679 this box is checked it should delete the C<__address> sepcial key from the
680 session hash when the hash is first created.
681
682 =head2 Race Conditions
683
684 In this day and age where cleaning detergents and dutch football (not the
685 american kind) teams roam the plains in great numbers, requests may happen
686 simultaneously. This means that there is some risk of session data being
687 overwritten, like this:
688
689 =over 4
690
691 =item 1.
692
693 request a starts, request b starts, with the same session id
694
695 =item 2.
696
697 session data is loaded in request a
698
699 =item 3.
700
701 session data is loaded in request b
702
703 =item 4.
704
705 session data is changed in request a
706
707 =item 5.
708
709 request a finishes, session data is updated and written to store
710
711 =item 6.
712
713 request b finishes, session data is updated and written to store, overwriting
714 changes by request a
715
716 =back
717
718 If this is a concern in your application, a soon to be developed locking
719 solution is the only safe way to go. This will have a bigger overhead.
720
721 For applications where any given user is only making one request at a time this
722 plugin should be safe enough.
723
724 =head1 AUTHORS
725
726 =over 4
727
728 =item Andy Grundman
729
730 =item Christian Hansen
731
732 =item Yuval Kogman, C<nothingmuch@woobling.org> (current maintainer)
733
734 =item Sebastian Riedel
735
736 =back
737
738 And countless other contributers from #catalyst. Thanks guys!
739
740 =head1 COPYRIGHT & LICENSE
741
742         Copyright (c) 2005 the aforementioned authors. All rights
743         reserved. This program is free software; you can redistribute
744         it and/or modify it under the same terms as Perl itself.
745
746 =cut
747
748