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