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