diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index ecf8c9dae0..3a368b1ac3 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -32,7 +32,7 @@ jobs: - name: Install dependencies run: | cpanm -n --installdeps . - cpanm -n Cpanel::JSON::XS EV Role::Tiny + cpanm -n Cpanel::JSON::XS EV Role::Tiny CryptX cpanm -n Test::Pod Test::Pod::Coverage TAP::Formatter::GitHubActions - name: Run tests run: prove --merge --formatter TAP::Formatter::GitHubActions -l t t/mojo t/mojolicious diff --git a/lib/Mojo/Util.pm b/lib/Mojo/Util.pm index 069cf9cf9c..86ec4abc62 100644 --- a/lib/Mojo/Util.pm +++ b/lib/Mojo/Util.pm @@ -21,6 +21,17 @@ use Symbol qw(delete_package); use Time::HiRes (); use Unicode::Normalize (); +# Encryption support requires CryptX 0.080+ +use constant CRYPTX => $ENV{MOJO_NO_CRYPTX} ? 0 : !!(eval { + require CryptX; + require Crypt::AuthEnc::ChaCha20Poly1305; + require Crypt::KeyDerivation; + require Crypt::Misc; + require Crypt::PRNG; + CryptX->VERSION('0.080'); + 1; +}); + # Check for monotonic clock support use constant MONOTONIC => !!eval { Time::HiRes::clock_gettime(Time::HiRes::CLOCK_MONOTONIC()) }; @@ -64,15 +75,15 @@ my $UNQUOTED_VALUE_RE = qr/\G=\s*([^;, ]*)/; # HTML entities my $ENTITY_RE = qr/&(?:\#((?:[0-9]{1,7}|x[0-9a-fA-F]{1,6}));|(\w+[;=]?))/; -# Encoding and pattern cache -my (%ENCODING, %PATTERN); +# Encoding, encryption and pattern caches +my (%ENCODING, %ENCRYPTION, %PATTERN); our @EXPORT_OK = ( - qw(b64_decode b64_encode camelize class_to_file class_to_path decamelize decode deprecated dumper encode), - qw(extract_usage getopt gunzip gzip header_params hmac_sha1_sum html_attr_unescape html_unescape humanize_bytes), - qw(md5_bytes md5_sum monkey_patch network_contains punycode_decode punycode_encode quote scope_guard secure_compare), - qw(sha1_bytes sha1_sum slugify split_cookie_header split_header steady_time tablify term_escape trim unindent), - qw(unquote url_escape url_unescape xml_escape xor_encode) + qw(b64_decode b64_encode camelize class_to_file class_to_path decamelize decode decrypt_cookie deprecated dumper), + qw(encode encrypt_cookie extract_usage generate_secret getopt gunzip gzip header_params hmac_sha1_sum), + qw(html_attr_unescape html_unescape humanize_bytes md5_bytes md5_sum monkey_patch network_contains punycode_decode), + qw(punycode_encode quote scope_guard secure_compare sha1_bytes sha1_sum slugify split_cookie_header split_header), + qw(steady_time tablify term_escape trim unindent unquote url_escape url_unescape xml_escape xor_encode) ); # Aliases @@ -115,6 +126,18 @@ sub decamelize { } split /::/, $str; } +sub decrypt_cookie { + my ($value, $key, $salt) = @_; + croak 'CryptX 0.080+ required for encrypted cookie support' unless CRYPTX; + + return undef unless $value =~ /^([^-]+)-([^-]+)-([^-]+)$/; + my ($ct, $iv, $tag) = ($1, $2, $3); + ($ct, $iv, $tag) = (Crypt::Misc::decode_b64($ct), Crypt::Misc::decode_b64($iv), Crypt::Misc::decode_b64($tag)); + + my $dk = $ENCRYPTION{$key}{$salt} ||= Crypt::KeyDerivation::pbkdf2($key, $salt); + return Crypt::AuthEnc::ChaCha20Poly1305::chacha20poly1305_decrypt_verify($dk, $iv, '', $ct, $tag); +} + sub decode { my ($encoding, $bytes) = @_; return undef unless eval { $bytes = _encoding($encoding)->decode("$bytes", 1); 1 }; @@ -130,6 +153,17 @@ sub dumper { Data::Dumper->new([@_])->Indent(1)->Sortkeys(1)->Terse(1)->Useqq(1) sub encode { _encoding($_[0])->encode("$_[1]", 0) } +sub encrypt_cookie { + my ($value, $key, $salt) = @_; + croak 'CryptX 0.080+ required for encrypted cookie support' unless CRYPTX; + + my $dk = $ENCRYPTION{$key}{$salt} ||= Crypt::KeyDerivation::pbkdf2($key, $salt); + my $iv = Crypt::PRNG::random_bytes(12); + my ($ct, $tag) = Crypt::AuthEnc::ChaCha20Poly1305::chacha20poly1305_encrypt_authenticate($dk, $iv, '', $value); + + return join '-', Crypt::Misc::encode_b64($ct), Crypt::Misc::encode_b64($iv), Crypt::Misc::encode_b64($tag); +} + sub extract_usage { my $file = @_ ? "$_[0]" : (caller)[1]; @@ -141,6 +175,12 @@ sub extract_usage { return unindent($output); } +sub generate_secret { + return Crypt::Misc::encode_b64u(Crypt::PRNG::random_bytes(128)) if CRYPTX; + srand; + return sha1_sum($$ . steady_time() . rand); +} + sub getopt { my ($array, $opts) = map { ref $_[0] eq 'ARRAY' ? shift : $_ } \@ARGV, []; @@ -634,6 +674,13 @@ Convert C string to C and replace C<::> with C<->. Decode bytes to characters with L, or return C if decoding failed. +=head2 decrypt_cookie + + my $value = decrypt_cookie $encrypted, 'passw0rd', 'salt'; + +Decrypt cookie value encrypted with L, returns the decrypted value or C. Note that this +function is B and might change without warning! + =head2 deprecated deprecated 'foo is DEPRECATED in favor of bar'; @@ -653,6 +700,12 @@ Dump a Perl data structure with L. Encode characters to bytes with L. +=head2 encrypt_cookie + + my $encrypted = encrypt_cookie $value, 'passw0rd', 'salt'; + +Encrypt cookie value. Note that this function is B and might change without warning! + =head2 extract_usage my $usage = extract_usage; @@ -670,6 +723,13 @@ function was called from. =cut +=head2 generate_secret + + my $secret = generate_secret; + +Generate a random secret with a cryptographically secure random number generator if available, and a less secure +fallback if not. Note that this function is B and might change without warning! + =head2 getopt getopt diff --git a/lib/Mojolicious.pm b/lib/Mojolicious.pm index 2eb879c74b..1d3780797a 100644 --- a/lib/Mojolicious.pm +++ b/lib/Mojolicious.pm @@ -513,6 +513,9 @@ rotating passphrases, just add new ones to the front and remove old ones from th Signed cookie based session manager, defaults to a L object. You can usually leave this alone, see L for more information about working with session data. + # Enable encrypted sessions + $app->sessions->encrypted(1); + # Change name of cookie used for all sessions $app->sessions->cookie_name('mysession'); diff --git a/lib/Mojolicious/Command/Author/generate/app.pm b/lib/Mojolicious/Command/Author/generate/app.pm index 7e42ccfb03..361d6d91fa 100644 --- a/lib/Mojolicious/Command/Author/generate/app.pm +++ b/lib/Mojolicious/Command/Author/generate/app.pm @@ -196,7 +196,7 @@ done_testing();

@@ config -% use Mojo::Util qw(sha1_sum steady_time); +% use Mojo::Util qw(generate_secret); --- secrets: - - <%= sha1_sum $$ . steady_time . rand %> + - <%= generate_secret() %> diff --git a/lib/Mojolicious/Command/version.pm b/lib/Mojolicious/Command/version.pm index 4943336300..2ce70456b9 100644 --- a/lib/Mojolicious/Command/version.pm +++ b/lib/Mojolicious/Command/version.pm @@ -4,6 +4,7 @@ use Mojo::Base 'Mojolicious::Command'; use Mojo::IOLoop::Client; use Mojo::IOLoop::TLS; use Mojo::JSON; +use Mojo::Util; use Mojolicious; has description => 'Show versions of available modules'; @@ -12,13 +13,14 @@ has usage => sub { shift->extract_usage }; sub run { my $self = shift; - my $json = Mojo::JSON->JSON_XS ? $Cpanel::JSON::XS::VERSION : 'n/a'; - my $ev = eval { require Mojo::Reactor::EV; 1 } ? $EV::VERSION : 'n/a'; - my $socks = Mojo::IOLoop::Client->can_socks ? $IO::Socket::Socks::VERSION : 'n/a'; - my $tls = Mojo::IOLoop::TLS->can_tls ? $IO::Socket::SSL::VERSION : 'n/a'; - my $nnr = Mojo::IOLoop::Client->can_nnr ? $Net::DNS::Native::VERSION : 'n/a'; - my $roles = Mojo::Base->ROLES ? $Role::Tiny::VERSION : 'n/a'; - my $async = Mojo::Base->ASYNC ? $Future::AsyncAwait::VERSION : 'n/a'; + my $json = Mojo::JSON->JSON_XS ? $Cpanel::JSON::XS::VERSION : 'n/a'; + my $cryptx = Mojo::Util->CRYPTX ? $CryptX::VERSION : 'n/a'; + my $ev = eval { require Mojo::Reactor::EV; 1 } ? $EV::VERSION : 'n/a'; + my $socks = Mojo::IOLoop::Client->can_socks ? $IO::Socket::Socks::VERSION : 'n/a'; + my $tls = Mojo::IOLoop::TLS->can_tls ? $IO::Socket::SSL::VERSION : 'n/a'; + my $nnr = Mojo::IOLoop::Client->can_nnr ? $Net::DNS::Native::VERSION : 'n/a'; + my $roles = Mojo::Base->ROLES ? $Role::Tiny::VERSION : 'n/a'; + my $async = Mojo::Base->ASYNC ? $Future::AsyncAwait::VERSION : 'n/a'; print <value; } +sub encrypted_cookie { + my ($self, $name, $value, $options) = @_; + + # Request cookie + return $self->every_encrypted_cookie($name)->[-1] unless defined $value; + + # Response cookie + my $app = $self->app; + my $secret = $app->secrets->[0]; + my $moniker = $app->moniker; + return $self->cookie($name, Mojo::Util::encrypt_cookie($value, $secret, $moniker), $options); +} + sub every_cookie { [map { $_->value } @{shift->req->every_cookie(shift)}] } +sub every_encrypted_cookie { + my ($self, $name) = @_; + + my $app = $self->app; + my $secrets = $app->secrets; + my $moniker = $app->moniker; + my @results; + for my $value (@{$self->every_cookie($name)}) { + my $decrypted; + for my $secret (@$secrets) { + last if defined($decrypted = Mojo::Util::decrypt_cookie($value, $secret, $moniker)); + } + if (defined $decrypted) { push @results, $decrypted } + + else { $self->helpers->log->trace(qq{Cookie "$name" is not encrypted}) } + } + + return \@results; +} + sub every_param { my ($self, $name) = @_; @@ -399,6 +432,17 @@ you want to access more than just the last one, you can use L. # Create secure response cookie $c->cookie(secret => 'I <3 Mojolicious', {secure => 1, httponly => 1}); +=head2 encrypted_cookie + + my $value = $c->encrypted_cookie('foo'); + $c = $c->encrypted_cookie(foo => 'bar'); + $c = $c->encrypted_cookie(foo => 'bar', {path => '/'}); + +Access encrypted request cookie values and create new encrypted response cookies. If there are multiple values sharing +the same name, and you want to access more than just the last one, you can use L. Cookies +are encrypted with ChaCha20-Poly1305, to prevent tampering, and the ones failing decryption will be automatically +discarded. Note that this method is B and might change without warning! + =head2 every_cookie my $values = $c->every_cookie('foo'); @@ -408,6 +452,16 @@ Similar to L, but returns all request cookie values sharing the same $ Get first cookie value my $first = $c->every_cookie('foo')->[0]; +=head2 every_encrypted_cookie + + my $values = $c->every_encrypted_cookie('foo'); + +Similar to L, but returns all encrypted request cookie values sharing the same name as an array +reference. Note that this method is B and might change without warning! + + # Get first encrypted cookie value + my $first = $c->every_encrypted_cookie('foo')->[0]; + =head2 every_param my $values = $c->every_param('foo'); diff --git a/lib/Mojolicious/Guides/FAQ.pod b/lib/Mojolicious/Guides/FAQ.pod index 3da035091e..cc76f57f27 100644 --- a/lib/Mojolicious/Guides/FAQ.pod +++ b/lib/Mojolicious/Guides/FAQ.pod @@ -27,8 +27,8 @@ frameworks, it is more of a web toolkit and can even be used as the foundation f We are optimizing L for user-friendliness and development speed, without compromises. While there are no rules in L that forbid dependencies, we do currently discourage adding non-optional ones in favor of a faster and more painless installation process. And we do in fact already use several optional CPAN -modules such as L, L, L, L, L, L and -L to provide advanced functionality if possible. +modules such as L, L, L, L, L, L, +L and L to provide advanced functionality if possible. =head2 Why reinvent wheels? diff --git a/lib/Mojolicious/Guides/Growing.pod b/lib/Mojolicious/Guides/Growing.pod index 384e739311..0c864b593c 100644 --- a/lib/Mojolicious/Guides/Growing.pod +++ b/lib/Mojolicious/Guides/Growing.pod @@ -104,8 +104,8 @@ web server in the form of cookies. Set-Cookie: session=hmac-sha256(base64(json($session))) In L however we are taking this concept one step further by storing everything JSON serialized and Base64 -encoded in HMAC-SHA256 signed cookies, which is more compatible with the REST philosophy and reduces infrastructure -requirements. +encoded in HMAC-SHA256 signed, or ChaCha20-Poly1305 encrypted cookies, which is more compatible with the REST +philosophy and reduces infrastructure requirements. =head2 Test-Driven Development diff --git a/lib/Mojolicious/Sessions.pm b/lib/Mojolicious/Sessions.pm index 989ad5faaa..e070e441a9 100644 --- a/lib/Mojolicious/Sessions.pm +++ b/lib/Mojolicious/Sessions.pm @@ -4,7 +4,7 @@ use Mojo::Base -base; use Mojo::JSON; use Mojo::Util qw(b64_decode b64_encode); -has [qw(cookie_domain secure)]; +has [qw(cookie_domain encrypted secure)]; has cookie_name => 'mojolicious'; has cookie_path => '/'; has default_expiration => 3600; @@ -15,7 +15,8 @@ has serialize => sub { \&_serialize }; sub load { my ($self, $c) = @_; - return unless my $value = $c->signed_cookie($self->cookie_name); + my $method = $self->encrypted ? 'encrypted_cookie' : 'signed_cookie'; + return unless my $value = $c->$method($self->cookie_name); $value =~ y/-/=/; return unless my $session = $self->deserialize->(b64_decode $value); @@ -58,16 +59,14 @@ sub store { samesite => $self->samesite, secure => $self->secure }; - $c->signed_cookie($self->cookie_name, $value, $options); + my $method = $self->encrypted ? 'encrypted_cookie' : 'signed_cookie'; + $c->$method($self->cookie_name, $value, $options); } +# DEPRECATED! (Remove once old sessions with padding are no longer a concern) sub _deserialize { Mojo::JSON::decode_json($_[0] =~ s/\}\KZ*$//r) } -sub _serialize { - no warnings 'numeric'; - my $out = Mojo::JSON::encode_json($_[0]); - return $out . 'Z' x (1025 - length $out); -} +sub _serialize { Mojo::JSON::encode_json($_[0]) } 1; @@ -143,6 +142,14 @@ A callback used to deserialize sessions, defaults to L. $sessions->deserialize(sub ($bytes) { return {} }); +=head2 encrypted + + my $bool = $sessions->encrypted; + $sessions = $sessions->encrypted($bool); + +Use encrypted session cookies instead of merely cryptographically signed ones. Note that this attribute is +B and might change without warning! + =head2 samesite my $samesite = $sessions->samesite; diff --git a/t/mojo/util.t b/t/mojo/util.t index e7905c97af..d92a53651e 100644 --- a/t/mojo/util.t +++ b/t/mojo/util.t @@ -8,11 +8,11 @@ use Mojo::ByteStream qw(b); use Mojo::DeprecationTest; use Sub::Util qw(subname); -use Mojo::Util qw(b64_decode b64_encode camelize class_to_file class_to_path decamelize decode dumper encode), - qw(extract_usage getopt gunzip gzip header_params hmac_sha1_sum html_unescape html_attr_unescape humanize_bytes), - qw(md5_bytes md5_sum monkey_patch network_contains punycode_decode punycode_encode quote scope_guard secure_compare), - qw(sha1_bytes sha1_sum slugify split_cookie_header split_header steady_time tablify term_escape trim unindent), - qw(unquote url_escape url_unescape xml_escape xor_encode); +use Mojo::Util qw(b64_decode b64_encode camelize class_to_file class_to_path decamelize decode decrypt_cookie dumper), + qw(encode encrypt_cookie extract_usage generate_secret getopt gunzip gzip header_params hmac_sha1_sum html_unescape), + qw(html_attr_unescape humanize_bytes md5_bytes md5_sum monkey_patch network_contains punycode_decode), + qw(punycode_encode quote scope_guard secure_compare sha1_bytes sha1_sum slugify split_cookie_header split_header), + qw(steady_time tablify term_escape trim unindent unquote url_escape url_unescape xml_escape xor_encode); subtest 'camelize' => sub { is camelize('foo_bar_baz'), 'FooBarBaz', 'right camelized result'; @@ -656,6 +656,27 @@ subtest 'humanize_bytes' => sub { is humanize_bytes( 245760), '240KiB', 'less than a MiB'; }; +subtest 'encrypt_cookie/decrypt_cookie' => sub { + plan skip_all => 'CryptX required!' unless Mojo::Util->CRYPTX; + + subtest 'Roundtrip' => sub { + my $encrypted = encrypt_cookie('test', 'foo', 'salt'); + isnt $encrypted, 'test', 'encrypted'; + is decrypt_cookie($encrypted, 'foo', 'salt'), 'test', 'decrypted'; + }; + + is decrypt_cookie('test', 'foo', 'salt'), undef, 'not encrypted'; + is decrypt_cookie('6Y+LKA==-ROhxLDrUBVkXRKTM-v7Qm+Xgoi1t94GLSHYGkaW==', 'foo', 'salt'), undef, 'wrong tag'; + is decrypt_cookie('6Y+LKA==-ROhxLDrUBVkXRKTm-v7Qm+Xgoi1t94GLSHYGkaw==', 'foo', 'salt'), undef, 'wrong random bytes'; + is decrypt_cookie('6Y+LKA==-ROhxLDrUBVkXRKTM-v7Qm+Xgoi1t94GLSHYGkaw==', 'bar', 'salt'), undef, 'wrong password'; + is decrypt_cookie('6Y+LKA==-ROhxLDrUBVkXRKTM-v7Qm+Xgoi1t94GLSHYGkaw==', 'foo', 'bar'), undef, 'wrong salt'; + is decrypt_cookie('6Y+LKA==-ROhxLDrUBVkXRKTM-v7Qm+Xgoi1t94GLSHYGkaw==', 'foo', 'salt'), 'test', 'decrypted'; +}; + +subtest 'generate_secret' => sub { + like generate_secret, qr/^[A-Za-z0-9_-]{32,}$/, 'right format'; +}; + subtest 'Hide DATA usage from error messages' => sub { eval { die 'whatever' }; unlike $@, qr/DATA/, 'DATA has been hidden'; diff --git a/t/mojolicious/lite_app.t b/t/mojolicious/lite_app.t index 15159b5a62..088f88b7fa 100644 --- a/t/mojolicious/lite_app.t +++ b/t/mojolicious/lite_app.t @@ -273,12 +273,6 @@ get '/session_cookie/2' => sub { $c->render(text => "Session is $value!"); }; -get '/session_length' => sub { - my $c = shift; - $c->session->{q} = $c->param('q'); - $c->rendered(204); -}; - get '/foo' => sub { my $c = shift; $c->render(text => 'Yea baby!'); @@ -802,30 +796,6 @@ ok !$t->tx, 'session reset'; $t->get_ok('/session_cookie/2')->status_is(200)->header_is(Server => 'Mojolicious (Perl)') ->content_is('Session is missing!'); - -subtest 'Session length' => sub { - my $extract = sub { - my $value = $_[0]->tx->res->cookie('mojolicious')->value; - $value =~ s/--([^\-]+)$//; - $value =~ y/-/=/; - return Mojo::Util::b64_decode($value); - }; - - subtest 'Short session' => sub { - $t->reset_session; - my $value = $t->get_ok('/session_length?q=a')->status_is(204)->$extract; - cmp_ok length($value), '>', 1024, 'session is long enough'; - ok $value =~ /Z+$/, 'session is padded'; - }; - - subtest 'Long session' => sub { - $t->reset_session; - my $value = $t->get_ok('/session_length?q=' . 'a' x 1025)->status_is(204)->$extract; - cmp_ok length($value), '>', 1024, 'session is long enough'; - ok $value !~ /Z+$/, 'session is not padded'; - }; -}; - # Text $t->get_ok('/foo')->status_is(200)->header_is(Server => 'Mojolicious (Perl)')->content_is('Yea baby!'); diff --git a/t/mojolicious/session_lite_app.t b/t/mojolicious/session_lite_app.t new file mode 100644 index 0000000000..5d329b07d2 --- /dev/null +++ b/t/mojolicious/session_lite_app.t @@ -0,0 +1,94 @@ +use Mojo::Base -strict; + +BEGIN { $ENV{MOJO_REACTOR} = 'Mojo::Reactor::Poll' } + +use Test::Mojo; +use Test::More; + +use Mojo::Util; +use Mojolicious::Lite; + +app->secrets(['test1']); + +get '/login' => sub { + my $c = shift; + $c->session(user => 'sri'); + $c->render(text => 'logged in'); +}; + +get '/session' => sub { + my $c = shift; + my $user = $c->session->{user} // 'nobody'; + $c->render(text => "user:$user"); +}; + +get '/logout' => sub { + my $c = shift; + delete $c->session->{user}; + $c->render(text => 'logged out'); +}; + +my $t = Test::Mojo->new; + +subtest 'User session (signed cookie)' => sub { + is $t->app->sessions->encrypted, undef, 'not encrypted by default'; + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + $t->get_ok('/login')->status_is(200)->content_is('logged in'); + $t->get_ok('/session')->status_is(200)->content_is('user:sri'); + like $t->tx->res->cookies->[0]->value, qr/^[^-]+-+[^-]+$/, 'signed cookie format'; + $t->get_ok('/session')->status_is(200)->content_is('user:sri'); + $t->get_ok('/logout')->status_is(200)->content_is('logged out'); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); +}; + +subtest 'User session (encrypted cookie)' => sub { + plan skip_all => 'CryptX required!' unless Mojo::Util->CRYPTX; + $t->reset_session; + $t->app->sessions->encrypted(1); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + $t->get_ok('/login')->status_is(200)->content_is('logged in'); + $t->get_ok('/session')->status_is(200)->content_is('user:sri'); + like $t->tx->res->cookies->[0]->value, qr/^[^-]+-[^-]+-[^-]+$/, 'encrypted cookie format'; + $t->get_ok('/session')->status_is(200)->content_is('user:sri'); + $t->get_ok('/logout')->status_is(200)->content_is('logged out'); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); +}; + +subtest 'Rotating secrets' => sub { + subtest 'User session (signed cookie)' => sub { + $t->reset_session; + $t->app->secrets(['test1']); + $t->app->sessions->encrypted(0); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + $t->get_ok('/login')->status_is(200)->content_is('logged in'); + $t->get_ok('/session')->status_is(200)->content_is('user:sri'); + $t->app->secrets(['test2', 'test1']); + $t->get_ok('/session')->status_is(200)->content_is('user:sri'); + $t->get_ok('/logout')->status_is(200)->content_is('logged out'); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + }; + + subtest 'User session (encrypted cookie)' => sub { + plan skip_all => 'CryptX required!' unless Mojo::Util->CRYPTX; + $t->reset_session; + $t->app->secrets(['test1']); + $t->app->sessions->encrypted(1); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + $t->get_ok('/login')->status_is(200)->content_is('logged in'); + $t->get_ok('/session')->status_is(200)->content_is('user:sri'); + $t->app->secrets(['test2', 'test1']); + $t->get_ok('/session')->status_is(200)->content_is('user:sri'); + $t->get_ok('/logout')->status_is(200)->content_is('logged out'); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + $t->get_ok('/session')->status_is(200)->content_is('user:nobody'); + }; +}; + +done_testing();