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