From 37f1af8a19985b027e5c91a8abf149c92e4549aa Mon Sep 17 00:00:00 2001 From: Jonathan Druart Date: Wed, 5 Jan 2022 12:20:28 +0100 Subject: [PATCH] Bug 29543: Add JWT token handling Mojo::JWT is installed already, it's not a new dependency. We need a way to send the patron a token when it's correctly logged in, and not assumed it's logged in only if patronid is passed Signed-off-by: Nick Clemens Signed-off-by: Katrin Fischer Signed-off-by: Kyle M Hall (cherry picked from commit d978bf1506d761a6962d949f35b71f1740d0052a) Signed-off-by: Victor Grousset/tuxayo --- Koha/Token.pm | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++- t/Token.t | 18 ++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/Koha/Token.pm b/Koha/Token.pm index a3f226d74c..9d69e2d07a 100644 --- a/Koha/Token.pm +++ b/Koha/Token.pm @@ -1,6 +1,6 @@ package Koha::Token; -# Created as wrapper for CSRF tokens, but designed for more general use +# Created as wrapper for CSRF and JWT tokens, but designed for more general use # Copyright 2016 Rijksmuseum # @@ -52,6 +52,7 @@ use Modern::Perl; use Bytes::Random::Secure (); use String::Random (); use WWW::CSRF (); +use Mojo::JWT; use Digest::MD5 qw(md5_base64); use Encode qw( encode ); use Koha::Exceptions::Token; @@ -78,6 +79,9 @@ sub new { my $csrf_token = $tokenizer->generate({ type => 'CSRF', id => $id, secret => $secret, }); + my $jwt = $tokenizer->generate({ + type => 'JWT, id => $id, secret => $secret, + }); Generate several types of tokens. Now includes CSRF. For non-CSRF tokens an optional pattern parameter overrides length. @@ -101,6 +105,8 @@ sub generate { my ( $self, $params ) = @_; if( $params->{type} && $params->{type} eq 'CSRF' ) { $self->{lasttoken} = _gen_csrf( $params ); + } elsif( $params->{type} && $params->{type} eq 'JWT' ) { + $self->{lasttoken} = _gen_jwt( $params ); } else { $self->{lasttoken} = _gen_rand( $params ); } @@ -122,6 +128,21 @@ sub generate_csrf { return $self->generate({ %$params, type => 'CSRF' }); } +=head2 generate_jwt + + Like: generate({ type => 'JWT', ... }) + Note that JWT is designed to encode a structure but here we are actually only allowing a value + that will be store in the key 'id'. + +=cut + +sub generate_jwt { + my ( $self, $params ) = @_; + return if !$params->{id}; + $params = _add_default_jwt_params( $params ); + return $self->generate({ %$params, type => 'JWT' }); +} + =head2 check my $result = $tokenizer->check({ @@ -138,6 +159,9 @@ sub check { if( $params->{type} && $params->{type} eq 'CSRF' ) { return _chk_csrf( $params ); } + elsif( $params->{type} && $params->{type} eq 'JWT' ) { + return _chk_jwt( $params ); + } return; } @@ -156,6 +180,33 @@ sub check_csrf { return $self->check({ %$params, type => 'CSRF' }); } +=head2 check_jwt + + Like: check({ type => 'JWT', id => $id, token => $token }) + + Will return true if the token contains the passed id + +=cut + +sub check_jwt { + my ( $self, $params ) = @_; + $params = _add_default_jwt_params( $params ); + return $self->check({ %$params, type => 'JWT' }); +} + +=head2 decode_jwt + + $tokenizer->decode_jwt({ type => 'JWT', token => $token }) + + Will return the value of the id stored in the token. + +=cut +sub decode_jwt { + my ( $self, $params ) = @_; + $params = _add_default_jwt_params( $params ); + return _decode_jwt( $params ); +} + # --- Internal routines --- sub _add_default_csrf_params { @@ -220,6 +271,41 @@ sub _gen_rand { return $token; } +sub _add_default_jwt_params { + my ( $params ) = @_; + my $pw = C4::Context->config('pass'); + $params->{secret} //= md5_base64( Encode::encode( 'UTF-8', $pw ) ), + return $params; +} + +sub _gen_jwt { + my ( $params ) = @_; + return if !$params->{id} || !$params->{secret}; + + return Mojo::JWT->new( + claims => { id => $params->{id} }, + secret => $params->{secret} + )->encode; +} + +sub _chk_jwt { + my ( $params ) = @_; + return if !$params->{id} || !$params->{secret} || !$params->{token}; + + my $claims = Mojo::JWT->new(secret => $params->{secret})->decode($params->{token}); + + return 1 if exists $claims->{id} && $claims->{id} == $params->{id}; +} + +sub _decode_jwt { + my ( $params ) = @_; + return if !$params->{token} || !$params->{secret}; + + my $claims = Mojo::JWT->new(secret => $params->{secret})->decode($params->{token}); + + return $claims->{id}; +} + =head1 AUTHOR Marcel de Rooy, Rijksmuseum Amsterdam, The Netherlands diff --git a/t/Token.t b/t/Token.t index 89154a39fd..d7de3113cf 100755 --- a/t/Token.t +++ b/t/Token.t @@ -20,7 +20,7 @@ # along with Koha; if not, see . use Modern::Perl; -use Test::More tests => 11; +use Test::More tests => 12; use Test::Exception; use Time::HiRes qw|usleep|; use C4::Context; @@ -101,3 +101,19 @@ subtest 'Pattern parameter' => sub { ok( $id !~ /[^A-Z]/, 'Only uppercase letters' ); throws_ok( sub { $tokenizer->generate({ pattern => 'abc{d,e}', }) }, 'Koha::Exceptions::Token::BadPattern', 'Exception should be thrown when wrong pattern is used'); }; + +subtest 'JWT' => sub { + plan tests => 3; + + my $id = 42; + my $jwt = $tokenizer->generate_jwt({ id => $id }); + + my $is_valid = $tokenizer->check_jwt({ id => $id, token => $jwt }); + is( $is_valid, 1, 'valid token should return 1' ); + + $is_valid = $tokenizer->check_jwt({ id => 24, token => $jwt }); + isnt( $is_valid, 1, 'invalid token should not return 1' ); + + my $retrieved_id = $tokenizer->decode_jwt({ token => $jwt }); + is( $retrieved_id, $id, 'id stored in jwt should be correct' ); +}; -- 2.39.5