From fb2115edd126da954fd0ff355063a4c57fa314fc Mon Sep 17 00:00:00 2001 From: David Cook Date: Wed, 17 Oct 2018 19:41:29 +1100 Subject: [PATCH] Bug 21586: Add generic OpenIDConnect client implementation BEWARE: This patch is not ready sign off! This is partially since it exists within Prosentient namespaces, and partially because a lot of things could probably be improved, since I originally wrote this code 4 years ago for 1 specific client. I'm adding this patch so that others can look at this work, and either adapt it themselves, or test it and give me feedback so that I can update it. I can't guarantee that it works in its current state, as the latest version I support with this code is 17.05, and I've touched up a few things here and there that differ from my running code. --- C4/Auth.pm | 44 ++- Koha/Prosentient/Auth/OpenIDConnect.pm | 378 +++++++++++++++++++++ Koha/Prosentient/Auth/OpenIDConnect/UserInfo.pm | 97 ++++++ Koha/Prosentient/Borrowers.pm | 76 +++++ .../opac-tmpl/bootstrap/en/modules/opac-auth.tt | 37 ++ opac/svc/login_openidc | 368 ++++++++++++++++++++ 6 files changed, 999 insertions(+), 1 deletion(-) create mode 100644 Koha/Prosentient/Auth/OpenIDConnect.pm create mode 100644 Koha/Prosentient/Auth/OpenIDConnect/UserInfo.pm create mode 100644 Koha/Prosentient/Borrowers.pm create mode 100755 opac/svc/login_openidc diff --git a/C4/Auth.pm b/C4/Auth.pm index 2435e734d3..6c2c8a3700 100644 --- a/C4/Auth.pm +++ b/C4/Auth.pm @@ -788,6 +788,10 @@ sub checkauth { my $flagsrequired = shift; my $type = shift; my $emailaddress = shift; + + #Add OpenID Connect 1.0 external authentication + my $external_authen = shift // {}; + $type = 'opac' unless $type; my $dbh = C4::Context->dbh; @@ -861,6 +865,7 @@ sub checkauth { if ( ( $query->param('koha_login_context') && ( $q_userid ne $s_userid ) ) || ( $cas && $query->param('ticket') && !C4::Context->userenv->{'id'} ) || ( $shib && $shib_login && !$logout && !C4::Context->userenv->{'id'} ) + || ( %$external_authen ) #Add OpenID Connect 1.0 external authentication ) { #if a user enters an id ne to the id in the current session, we need to log them in... @@ -874,6 +879,9 @@ sub checkauth { $userid = undef; } elsif ($logout) { + #OIDC + my $oidc_flag = $session->param("oidc_pid"); + #/OIDC # voluntary logout the user # check wether the user was using their shibboleth session or a local one @@ -886,6 +894,31 @@ sub checkauth { $sessionID = undef; $userid = undef; + #OIDC + if ($oidc_flag){ + my $oidc_context = new C4::Context; + if (my $providers = $oidc_context->{prosentient}->{OpenIdConnect}->{provider}){ + my $oidc; + #FIXME: This should probably be done by a function here and in login_openidc... + if ( ($providers->{id}) && ($providers->{id} eq $oidc_flag) ){ + $oidc = $providers; + } elsif ( $oidc_context->{prosentient}->{OpenIdConnect}->{provider}->{$oidc_flag} ) { + $oidc = $oidc_context->{prosentient}->{OpenIdConnect}->{provider}->{$oidc_flag}; + } + if ($oidc){ + my $logout_url = $oidc->{logout_url}; + my $redirect_url = $oidc->{redirect_url}; + if ($logout_url && $redirect_url){ + my $escaped_url = uri_escape($redirect_url); + my $complete_logout_url = $logout_url."?post_logout_redirect_uri=$escaped_url"; + print $query->redirect(-uri => $complete_logout_url); + safe_exit; + } + } + } + } + #/OIDC + if ($cas and $caslogout) { logout_cas($query, $type); } @@ -965,6 +998,7 @@ sub checkauth { } if ( ( $cas && $query->param('ticket') ) || $q_userid + || %$external_authen #Add OpenID Connect 1.0 external authentication || ( $shib && $shib_login ) || $pki_field ne 'None' || $emailaddress ) @@ -994,6 +1028,14 @@ sub checkauth { $info{'invalidCasLogin'} = 1 unless ($return); } + #Add OpenID Connect 1.0 external authentication + elsif ( %$external_authen ){ + if (my $openidcc_userinfo = $external_authen->{"OpenIDConnect1.0"}){ + $return = $openidcc_userinfo->{return} if $openidcc_userinfo->{return}; + $userid = $openidcc_userinfo->{koha_identifier} if $openidcc_userinfo->{koha_identifier}; + $info{'OpenIDConnect1'} = $openidcc_userinfo->{errors} if $openidcc_userinfo->{errors}; + } + } elsif ( $emailaddress ) { my $value = $emailaddress; @@ -1747,7 +1789,7 @@ sub _get_session_params { } else { # catch all defaults to tmp should work on all systems - my $dir = C4::Context::temporary_directory; + my $dir = C4::Context::temporary_directory(); my $instance = C4::Context->config( 'database' ); #actually for packages not exactly the instance name, but generally safer to leave it as it is return { dsn => "driver:File;serializer:yaml;id:md5", dsn_args => { Directory => "$dir/cgisess_$instance" } }; } diff --git a/Koha/Prosentient/Auth/OpenIDConnect.pm b/Koha/Prosentient/Auth/OpenIDConnect.pm new file mode 100644 index 0000000000..6a83a86113 --- /dev/null +++ b/Koha/Prosentient/Auth/OpenIDConnect.pm @@ -0,0 +1,378 @@ +package Koha::Prosentient::Auth::OpenIDConnect; + +# This file is part of Koha. +# +# Copyright Prosentient Systems 2014 +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; +use LWP::UserAgent; +use HTTP::Request::Common; #Used for HTTP Basic Authorization +use MIME::Base64 qw( encode_base64url decode_base64url encode_base64 ); +use JSON qw( decode_json ); +use Data::Dumper; + +use base qw(Class::Accessor); +__PACKAGE__->mk_accessors(qw( debugging auth_url token_url userinfo_url response_type scope client_id client_secret client_authentication_method redirect_uri issuer http_username http_password bearer_access_token_method)); + +=head1 NAME + +Koha::Prosentient::Auth::OpenIDConnect - OpenID Connect 1.0 Client + +=head1 SYNOPSIS + + use Koha::Prosentient::Auth::OpenIDConnect; + my $OpenIDC = Koha::Prosentient::Auth::OpenIDConnect->new(({ + auth_url => $auth_url, + token_url => $token_url, + userinfo_url => $userinfo_url, + response_type => $response_type, + scope => $scope, + client_id => $client_id, + redirect_uri => $redirect_uri, + }); + +=head1 DESCRIPTION + +This class creates OpenID Connect 1.0 authentication requests, +token requests, and fetches user info. + +=cut + +sub new { + my ($class, $args) = @_; + $args = {} unless defined $args; + return bless ($args, $class); +} + +=head2 ValidateToken + + my $token_response_valid = $OpenIDC->ValidateToken({ + token => $token, + token_type => $token_type, + }); + + Check the validity of an id_token in token response according to OpenID Connect + protocol criteria. + +=cut + +sub ValidateToken { + my ($self, $args) = @_; + my $token = $args->{token}; + my $token_type = $args->{token_type}; + my $valid = 0; + + if ($token){ + $valid = 1; + + #Validate: Check token type (3.1.3.3. Successful Token Response) + #NOTE: The OAuth 2.0 token_type response parameter value MUST be Bearer, as specified in OAuth 2.0 Bearer Token Usage [RFC6750], unless another Token Type has been negotiated with the Client. + if ($token_type){ + #According to RFC 6749 Section 5.1, the "token_type" value is case insensitive. + if ($token_type !~ /^Bearer$/i){ + warn "Token type doesn't equal 'Bearer'"; + $valid = 0; + } + } else { + warn "No token type provided"; + $valid = 0; + } + + #Validate: Token issuer (3.1.3.7. ID Token Validation) + #NOTE: The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) MUST exactly match the value of the iss (issuer) Claim. + if ($token->{iss}){ + if ($self->issuer ne $token->{iss}){ + $valid = 0 ; + warn "OIDC issuer in configuration doesn't match token."; + warn "Configuration = " . $self->issuer; + warn "Token = " . $token->{iss}; + } + } else { + warn "No OIDC issuer provided in token."; + $valid = 0; + } + + #Validate: Token audience (3.1.3.7. ID Token Validation) + #NOTE: The Client MUST validate that the aud (audience) Claim contains its client_id value registered at the Issuer identified by the iss (issuer) Claim as an audience. + #NOTE: The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, or if it contains additional audiences not trusted by the Client. http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation + if ($token->{aud}){ + if (ref $token->{aud} && ref $token->{aud} eq 'ARRAY'){ + + #Token invalid if the client_id isn't in the array of possible audiences + $valid = 0 if ! grep { $_ eq $self->client_id } @$token->{aud}; + #NOTE: The token should also be considered invalid if the array "contains additional audiences not trusted by the Client." (http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation) + #However, we don't know which audiences are not trusted by the Client... + + if ($token->{azp}){ + $valid = 0 if $token->{azp} ne $self->client_id; + } + } else { + $valid = 0 if $self->client_id ne $token->{aud}; + } + } else { + warn "No OIDC aud provided in token."; + $valid = 0; + } + + #Validate: Token expiry time (3.1.3.7. ID Token Validation) + #NOTE: The current time MUST be before the time represented by the exp Claim. + if ($token->{exp}){ + if (time() > $token->{exp}){ + $valid = 0; + warn "OIDC token expired."; + } + } else { + warn "No OIDC expiry provided in token."; + $valid = 0; + } + + #TODO: The iat Claim can be used to reject tokens that were issued too far away from the current time, limiting the amount of time that nonces need to be stored to prevent attacks. The acceptable range is Client specific. + #NOTE: This isn't a requirement...this would probably just be an improvement. + + #NOTE: If a nonce claim was made in the authentication request, then it must be checked here. However, we haven't implemented that, so no reason to do it yet. + + } else { + warn "No token available to validate."; + } + + if ($self->debugging){ + warn "DEBUGGING -> Token Validity = $valid"; + } + return $valid; +} + +=head2 ParseIdToken + + my ($token,$header) = $OpenIDC->ParseIdToken({id_token => $id_token,}); + + +=cut + +sub ParseIdToken { + my ($self, $args) = @_; + my $id_token = $args->{id_token}; + + my ($token,$header); + + if ($id_token){ + my ($jose_header, $json_web_token) = split('\.',$id_token); + if ($jose_header){ + my $decoded_jose_header = decode_json(decode_base64url($jose_header)); + $header = $decoded_jose_header if $decoded_jose_header; #e.g. {"alg":"none","typ":"JWT"} + } + if ($json_web_token){ + my $decoded_json_web_token = decode_json(decode_base64url($json_web_token)); + $token = $decoded_json_web_token if $decoded_json_web_token; #e.g. {"iss":"http://www.URL.com.au","sub":"cccccccccccccccccc1111111111111111111","aud":null,"exp":1405348350,"iat":1405345050,"session_state":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9999999999"} + } + } + if ($self->debugging){ + warn "DEBUGGING Token = ".Dumper($token); + warn "DEBUGGING Token Header = ".Dumper($header); + } + return ($token,$header); +} + +=head2 GetTokenResponse + + my ($token_response) = $OpenIDC->GetTokenResponse({code => $code,}); + + Send a Token Request using the code retrieved via the Authentication Request + +=cut + +sub GetTokenResponse { + my ($self, $args) = @_; + my $token_response = {}; + if ( (my $code = $args->{code}) && ($self->token_url) && ($self->client_id) && ($self->redirect_uri) ){ + my $client_authentication_method = $self->client_authentication_method || "client_secret_basic"; + + my $ua = LWP::UserAgent->new(); + #By default, LWP::UserAgent uses a string like 'libwww-perl/#.###'. + #NOTE: Cloudflare doesn't like this, but it accepts an empty User-Agent string. + $ua->agent(""); + my %headers = (); + #NOTE: #When sending a request using a "grant_type" of "authorization_code" to the token endpoint, an unauthenticated client MUST send its "client_id" to prevent itself from inadvertently accepting a code intended for a client with a different "client_id". + #See RFC 6749 (OAuth 2.0) http://tools.ietf.org/html/rfc6749#section-3.2.1 + my $form = { + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $self->redirect_uri, + }; + + if ($client_authentication_method && $client_authentication_method eq "client_secret_post"){ + $form->{client_id} = $self->client_id; + $form->{client_secret} = $self->client_secret; + } + else { + if ($self->http_username && $self->http_password){ + my $credentials = encode_base64url($self->http_username.":".$self->http_password, ''); + if ($credentials){ + $headers{'Authorization'} = "Basic $credentials"; + #TODO: Not sure whether or not to delete the "client_id" hash key from $form at this point when doing HTTP Basic Authorization... + #This would probably just be an improvement rather than a fix... + + if ($self->debugging){ + warn "DEBUGGING Decoded Credentials = ".decode_base64url($credentials); + } + } + } + } + + my $request = POST $self->token_url, $form, %headers; #Uses HTTP::Request::Common + if ($self->debugging){ + warn "DEBUGGING Token Request String = ".Dumper($request->as_string); + } + #Send Request + my $response = $ua->request($request); + + #Get Successful Response Content + if ($response->is_success){ + my $content = $response->decoded_content(); + if ($content){ + my $decoded_json = decode_json($content); + if ($decoded_json){ + $token_response = $decoded_json; + } + } + } + else { + if ($self->debugging){ + warn "DEBUGGING token request was not successful"; + warn "DEBUGGING " . $response->status_line; + warn "DEBUGGING " . $response->decoded_content(); + } + } + } + if ($self->debugging){ + warn "DEBUGGING -> token_response = ".Dumper($token_response); + } + return $token_response; +} + +sub ValidateUserInfoResponse { + my ($self, $args) = @_; + my $userinfo_response = $args->{userinfo_response}; + my $token_subject = $args->{token_subject}; + my $valid = 0; + + if ($userinfo_response && $token_subject){ + $valid = 1; + #VALIDATION: 5.3.2. Successful UserInfo Response + #NOTE: Due to the possibility of token substitution attacks (see Section 16.11), the UserInfo Response is not guaranteed to be about the End-User identified by the sub (subject) element of the ID Token. The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; if they do not match, the UserInfo Response values MUST NOT be used. + if ( $userinfo_response->{sub} ){ + if ( $userinfo_response->{sub} ne $token_subject ){ + warn "'sub' in userinfo does not match 'sub' in id_token"; + $valid = 0; + } + } else { + warn "No sub in userinfo_response"; + $valid = 0; + } + } else { + warn "No userinfo_response" if !$userinfo_response; + warn "No token subject" if !$token_subject; + } + + return $valid; +} + + +=head2 SendUserInfoRequest + + my $userinfo = $OpenIDC->SendUserInfoRequest({access_token => $access_token,}); + + Retrieve userinfo from an OpenID Connect 1.0 server using an Access Token + +=cut + +sub SendUserInfoRequest { + my ($self, $args) = @_; + + #https://tools.ietf.org/html/rfc6750#section-2 + my $bearer_access_token_method = $self->bearer_access_token_method || "header"; + + my $userinfo; + if ( (my $access_token = $args->{access_token}) && $self->userinfo_url && $bearer_access_token_method ){ + my $ua = LWP::UserAgent->new(); + #By default, LWP::UserAgent uses a string like 'libwww-perl/#.###'. + #NOTE: Cloudflare doesn't like this, but it accepts an empty User-Agent string. + $ua->agent(""); + #"5.3.4. UserInfo Response Validation" requires a TLS service certificate, which should be handled automatically by LWP::UserAgent. + #The following verification should be on by default, but it doesn't hurt to make this explicit here. + $ua->ssl_opts("verify_hostname" => 1); + + my $userinfo_response; + if ($bearer_access_token_method eq "header"){ + $userinfo_response = $ua->get( $self->userinfo_url, Authorization => "Bearer $access_token"); + } + elsif ($bearer_access_token_method eq "form"){ + my $form = { + access_token => $access_token, + }; + my $request = POST($self->userinfo_url, $form); + $userinfo_response = $ua->request($request); + } + elsif ($bearer_access_token_method eq "querystring"){ + $userinfo_response = $ua->get( $self->userinfo_url . "?access_token=$access_token"); + } + + if ($userinfo_response->is_success){ + my $userinfo_content = $userinfo_response->decoded_content(); + if ($userinfo_content){ + $userinfo = decode_json($userinfo_content); + if ($self->debugging){ + warn "DEBUGGING Decoded content as JSON = ".Dumper($userinfo_content); + warn "DEBUGGING Decoded JSON as Perl = ".Dumper($userinfo); + } + } + } + else { + if ($self->debugging){ + warn "DEBUGGING " . $userinfo_response->status_line; + warn "DEBUGGING " . $userinfo_response->decoded_content(); + } + } + } + return $userinfo; +} + +=head2 GenerateAuthenticationRequest + + my $authentication_request = $OpenIDC->GenerateAuthenticationRequest(); + + Build Authentication Request URL as per http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + +=cut + +sub GenerateAuthenticationRequest { + my ($self) = @_; + my $authentication_request; + if ( $self->{'auth_url'} && $self->{'response_type'} && $self->{'scope'} && $self->{'client_id'} && $self->{'redirect_uri'} ){ + $authentication_request = + $self->auth_url . "?" . + "response_type=" . $self->response_type . + "&scope=" . $self->scope . #N.B. Scope needs to be URI escaped at some stage. It should've already been escaped in this case. + "&client_id=" . $self->client_id . + "&redirect_uri=" . $self->redirect_uri #redirect_uri should also be URI escaped prior to this point + #. "&state=af0ifjsldkj" #NOTE: Currently, clients' OIDC servers don't support state. NOTE: the current value here is only an example - not a real state value + ; + } + warn "DEBUGGING -> Authentication Request = $authentication_request" if $self->debugging; + return $authentication_request; +} + + +1; diff --git a/Koha/Prosentient/Auth/OpenIDConnect/UserInfo.pm b/Koha/Prosentient/Auth/OpenIDConnect/UserInfo.pm new file mode 100644 index 0000000000..a7f3f4ea93 --- /dev/null +++ b/Koha/Prosentient/Auth/OpenIDConnect/UserInfo.pm @@ -0,0 +1,97 @@ +package Koha::Prosentient::Auth::OpenIDConnect::UserInfo; + +# This file is part of Koha. +# +# Copyright Prosentient Systems 2015 +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; +use base qw(Class::Accessor); +use Data::Dumper; +use Hash::Flatten qw(:all); #We use the flatten() function on the nested hash returned by a UserInfo endpoint + +my @canonical_fields = qw(firstname surname email phone dateofbirth address city state zipcode country verified_email nickname); +#FIXME: you should rename canonical fields to something more appropriate +push(@canonical_fields,"koha_sort1"); +__PACKAGE__->mk_ro_accessors(@canonical_fields); + +=head1 NAME + +Koha::Prosentient::Auth::OpenIDConnect::UserInfo - A helper class for handling UserInfo responses + +=head1 SYNOPSIS + + use Koha::Prosentient::Auth::OpenIDConnect; + Koha::Prosentient::Auth::OpenIDConnect::UserInfo + my $userinfo = Koha::Prosentient::Auth::OpenIDConnect::UserInfo->new({ + userinfo => $userinfo_response, + mapping => $mapping, + debugging => 1, + }); +=head1 DESCRIPTION + +This class takes a nested hash (returned from a decoded UserInfo JSON response) and +translates it so that it can be turned into data which is useful for Koha. + +=cut + +sub new { + my ($class, $args) = @_; + $args = {} unless defined $args; + return bless ($args, $class); +} + +sub map_userinfo { + my ($self, $args) = @_; + my $flattened_userinfo = {}; + if ( ( $args->{userinfo} ) && ( ref $args->{userinfo} eq 'HASH' ) ){ + #Flatten the hash so that you can always do 1 to 1 mappings + $flattened_userinfo = Hash::Flatten::flatten($args->{userinfo}); + if ($self->{debugging}){ + warn "DEBUGGING Flattened userinfo = ".Dumper($flattened_userinfo); + } + } + if ( ( $args->{mapping} ) && ( ref $args->{mapping} eq 'HASH' ) ){ + if ($self->{debugging}){ + warn "DEBUGGING Mapping = ".Dumper($args->{mapping}); + } + } + + #NOTE: Canonical fields are the same as accessor methods... so they're somewhat arbitrary + foreach my $canonical_field (@canonical_fields){ + my $real_userinfo_value = undef; + #This initial value will be used if: + #1) No mapping exists for the $canonical_field; or + #2) A mapping exists, but it's empty (i.e. blank) and thus not mapped to an existing hash key in $flattened_userinfo + #NOTE: The matching method used below is CASE SENSITIVE because hash lookups are CASE SENSITIVE. If you wanted it to be CASE INSENSITIVE, you'd have to iterate through the hash keys. + if ( ( $args->{mapping} ) && ( ref $args->{mapping} eq 'HASH' ) ){ + #First, check if there is a mapping for this $canonical_field + if ( exists $args->{mapping}->{$canonical_field} ){ + #Second, check if the mapping exists in the actual $flattened_userinfo + if ( exists $flattened_userinfo->{ $args->{mapping}->{$canonical_field} } ){ + #Third, store the real value obtained via the mapping + $real_userinfo_value = $flattened_userinfo->{ $args->{mapping}->{$canonical_field} }; + } + } + } + $self->{$canonical_field} = $real_userinfo_value; + } + if ($self->{debugging}){ + warn "DEBUGGING Userinfo object = ".Dumper($self); + } + return $self; #Returning the $self allows for method chaining +} + +1; diff --git a/Koha/Prosentient/Borrowers.pm b/Koha/Prosentient/Borrowers.pm new file mode 100644 index 0000000000..006b2f99b3 --- /dev/null +++ b/Koha/Prosentient/Borrowers.pm @@ -0,0 +1,76 @@ +package Koha::Prosentient::Borrowers; + +# This file is part of Koha. +# +# Copyright Prosentient Systems 2014 +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; +use C4::Context; + +use base qw(Class::Accessor); +__PACKAGE__->mk_accessors(qw( X )); + +=head1 NAME + +Koha::Prosentient::Borrowers + +=head1 SYNOPSIS + + use Koha::Prosentient::Borrowers::OpenIDConnect; + my $ProsentientBorrowers = Koha::Prosentient::Borrowers->new(); + +=head1 DESCRIPTION + +This class adds third-party (ie Prosentient) borrower management +functionality + +=cut + +sub new { + my ($class, $args) = @_; + $args = {} unless defined $args; + return bless ($args, $class); +} + +=head2 FindBorrowerByEmails + + my $borrowers = $ProsentientBorrowers->FindBorrowerByEmails({ + email = $email, + }); + + Fetch an arrayref of borrowers based on an email address, which searches + every email field in the borrower table. + +=cut + +sub FindBorrowerByEmails { + my ($self, $args) = @_; + my @borrowers; + my $email = $args->{email}; + my $dbh = C4::Context->dbh; + my $query = "SELECT userid, cardnumber, borrowernumber FROM borrowers WHERE email = ? OR emailpro = ? OR B_email = ?"; + my $sth=$dbh->prepare($query); + $sth->execute($email,$email,$email); + while (my $row = $sth->fetchrow_hashref){ + my $userid = $row->{userid}; + my $cardnumber = $row->{cardnumber}; + my $borrowernumber = $row->{borrowernumber}; + push(@borrowers,{userid => $userid, cardnumber => $cardnumber, borrowernumber => $borrowernumber}); + } + return (\@borrowers); +} + +1; diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt index ccaddde454..be2beee89f 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt +++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt @@ -42,6 +42,43 @@

Log in to your account

+ [%# Add OpenID Connect 1.0 external authentication %] + [% IF ( OpenIDConnect1 ) %] +
+

Sorry! OpenID Connect authentication was not successful!

+ [% IF ( OpenIDConnect1.server_error ) %] +

(Server error)

+ [% END %] + [% IF ( OpenIDConnect1.config_fail ) %] +

(Configuration error)

+ [% END %] + [% IF ( OpenIDConnect1.auth_fail ) %] +

(Authentication request failure)

+ [% END %] + [% IF ( OpenIDConnect1.no_koha_identifier ) %] +

(Unable to retrieve Koha details)

+ [% END %] + [% IF ( OpenIDConnect1.token_invalid || OpenIDConnect1.token_fail || OpenIDConnect1.no_subject ) %] +

(Token error)

+ [% END %] + [% IF ( OpenIDConnect1.no_user ) %] +

(Unable to retrieve your information from remote server)

+ [% END %] + [% IF ( OpenIDConnect1.no_email ) %] +

(No email address provided by remote server)

+ [% END %] + [% IF ( OpenIDConnect1.email_not_unique ) %] +

(Your email address is not unique on this system)

+ [% END %] + [% IF ( OpenIDConnect1.not_verified ) %] +

(Your email address has not been verified on the remote server)

+ [% END %] +
+ [% END #/OpenID Connect 1.0 %] + [% IF ( Koha.Preference('OpenIDConnectLoginPrompt') ) %] + [% Koha.Preference('OpenIDConnectLoginPrompt') %] + [% END #OpenID Connect 1.0 %] + [% IF ( timed_out ) %]
diff --git a/opac/svc/login_openidc b/opac/svc/login_openidc new file mode 100755 index 0000000000..b8a6a9a259 --- /dev/null +++ b/opac/svc/login_openidc @@ -0,0 +1,368 @@ +#!/usr/bin/perl + +# Copyright Prosentient Systems 2014 +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with Koha; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use CGI; +use Modern::Perl; +use URI::Escape; +use Data::Dumper; + +use C4::Auth; +use C4::Context; +use C4::Members; +use Koha::Prosentient::Auth::OpenIDConnect; +use Koha::Prosentient::Auth::OpenIDConnect::UserInfo; +use Koha::Prosentient::Borrowers; + +=head1 DESCRIPTION + +This script adds an authentication option using OpenID Connect 1.0. + +It relies on having the following elements set in koha-conf.xml: + + + + + + https://xxxxxxxxxxxxxxx/ + xxxxxxxxxxxxxxxxxxxxxxx + + https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + code + openid profile email + http://xxxxxxxxxxxxxxxxx/cgi-bin/koha/svc/login_openidc + + XXXXX + XXXXX + + + given_name + family_name + email + phone_number + birthdate +
address.street_address
+ address.locality + address.region + address.postal_code + address.country + email_verified + +
+
+
+
+ +=cut + +################################################################################################## +#DECLARE VARIABLES + +my ($scope, $client_id, $response_type, $issuer, $redirect_uri, $state, $auth_url, $token_url, $userinfo_url); #Send to OpenID Connect server +my ($http_username, $http_password, $client_secret); #Send to OpenID Connect server +my ($categorycode, $branchcode, $mapping); #For processing UserInfo && passing data to Koha APIs +my ($errors, $koha_user, $koha_identifier, $return); #Send to Auth.pm +my ($openid); +my $client_authentication_method = "client_secret_basic"; +my $bearer_access_token_method = "header"; + +my $query = CGI->new(); + +#Restore or create new Koha session... +my $koha_sessionID = $query->cookie('CGISESSID') // ''; +my $koha_session = C4::Auth::get_session($koha_sessionID); + +#We need a session cookie so that we know which endpoints to use... +my $session_cookie = $query->cookie( + -name => 'CGISESSID', + -value => $koha_session->id, + -HttpOnly => 1 + ); + +my $cgi_pid = $query->param("pid"); +my $oidc_login_pid = $koha_session->param("oidc_login_pid"); +my $provider_id = $cgi_pid ? $cgi_pid : $oidc_login_pid; + +my $code = $query->param("code"); #Authorization code from OpenID Connect server +my $server_error = $query->param("error"); +my $server_error_description = $query->param("error_description"); + +if ($provider_id){ + my $context = new C4::Context; + my $providers = $context->{prosentient}->{OpenIdConnect}->{provider}; + if ( ($providers->{id}) && ($providers->{id} eq $provider_id) ){ + #NOTE: This scenario happens when there is only one provider in koha-conf.xml + $openid = $providers; + } elsif ( $context->{prosentient}->{OpenIdConnect}->{provider}->{$provider_id} ) { + #NOTE: This scenario happens when there is more than one provider in koha-conf.xml + $openid = $context->{prosentient}->{OpenIdConnect}->{provider}->{$provider_id}; + } +} + +if ($openid){ + if ($provider_id){ + #Store the provider id in the user's Koha session, so that we know which provider to use to process the Authentication Response! + #NOTE: Alternatively, we could include the provider_id in the redirect_url that we issue to the Authentication endpoint... + $koha_session->param("oidc_login_pid",$provider_id); + } + + #FIXME: This $hack_username shouldn't be necessary in a standard OpenID Connect implementation... it's an unfortunate requirement for one server's non-standard implementation. + $http_username = $openid->{hack_username} ? $openid->{hack_username} : $openid->{client_id}; + $http_password = $openid->{client_secret}; + + $client_id = $openid->{client_id}; + $client_secret = $openid->{client_secret}; + $client_authentication_method = $openid->{client_authentication_method} if $openid->{client_authentication_method}; + $bearer_access_token_method = $openid->{bearer_access_token_method} if $openid->{bearer_access_token_method}; + + $issuer = $openid->{issuer}; + + $auth_url = $openid->{auth_url}; + $token_url = $openid->{token_url}; + $userinfo_url = $openid->{userinfo_url}; + $scope = uri_escape($openid->{scope}); #escape scope + $response_type = uri_escape($openid->{response_type}); #escape response_type + $redirect_uri = uri_escape($openid->{redirect_url}); #escape uri (including reserved characters) + + $categorycode = $openid->{categorycode}; #Categorycode for borrowers added via OpenID Connect... + $branchcode = $openid->{branchcode}; #Branchcode for bororwers added via OpenID Connect... + $mapping = $openid->{mapping}; #Map canonical hash keys to the hash keys returned in a UserInfo response +} + +my $insecure = 0; #FIXME: Change this to 1 if you want to authenticate using unverified email addresses +my $debugging = 0; #NOTE: This should be 0 in production + +################################################################################################## +#SCRIPT ACTION + +unless ( $cgi_pid || $code || $server_error ){ + print $query->redirect(-uri => "/cgi-bin/koha/opac-user.pl"); + exit; +} + + + +#Create an OpenIDC object using values we set in koha-conf.xml +my $OpenIDC = Koha::Prosentient::Auth::OpenIDConnect->new({ + debugging => $debugging, + auth_url => $auth_url, + token_url => $token_url, + userinfo_url => $userinfo_url, + response_type => $response_type, + scope => $scope, + client_id => $client_id, + client_secret => $client_secret, + client_authentication_method => $client_authentication_method, + bearer_access_token_method => $bearer_access_token_method, + redirect_uri => $redirect_uri, + issuer => $issuer, + http_username => $http_username, + http_password => $http_password, + state => $state, +}); + +if ($OpenIDC->auth_url && $OpenIDC->response_type && $OpenIDC->scope && $OpenIDC->client_id && $OpenIDC->redirect_uri && $categorycode && $branchcode && $OpenIDC->issuer){ + if (!$code && $cgi_pid){ + #Step One: Send an Authentication Request + my $authentication_request = $OpenIDC->GenerateAuthenticationRequest(); + if ($authentication_request){ + print $query->redirect(-uri => $authentication_request, -cookie => $session_cookie); + #NOTE: At this stage, the authorization server "MUST" ask the user if they're OK sharing their details with Koha http://openid.net/specs/openid-connect-core-1_0.html#Consent + } else { + $errors->{auth_fail} = 1; + } + } elsif ($code){ + + #Step Two: Send a Token Request + my $token_response = $OpenIDC->GetTokenResponse({code => $code,}); + if ($token_response){ + my ($token,$header) = $OpenIDC->ParseIdToken({ id_token => $token_response->{id_token}, }); + my $token_type = $token_response->{token_type} if $token_response->{token_type}; + + my $token_response_valid = $OpenIDC->ValidateToken({ + token => $token, + token_type => $token_type, + }); + + my $access_token = $token_response->{access_token} if $token_response->{access_token}; + my $subject = $token->{sub} if $token && $token->{sub}; + + if ($token_response_valid && $access_token && $subject){ + #Step Three: Send a UserInfo Request + my $userinfo_response = $OpenIDC->SendUserInfoRequest({ + access_token => $access_token, + }); + + my $userinfo_response_valid = $OpenIDC->ValidateUserInfoResponse({ + userinfo_response => $userinfo_response, + token_subject => $subject, + }); + + #Step Four: Process the UserInfo and find/create borrower in Koha + if ($userinfo_response && $userinfo_response_valid){ + + my $userinfo = Koha::Prosentient::Auth::OpenIDConnect::UserInfo->new({ + debugging => $debugging, + }); + + #Process the UserInfo into a canonical data structure using a 1 to 1 hash mapping + $userinfo->map_userinfo({ + userinfo => $userinfo_response, + mapping => $mapping, + }); + + #If we have at least a userinfo email, then we try to find them in Koha or we add them to Koha + if ( $userinfo->email && ( ($userinfo->verified_email) || ($insecure) ) ){ + + ($koha_user,$errors) = _process_user({ + userinfo => $userinfo, + categorycode => $categorycode, + branchcode => $branchcode, + errors => $errors, + }); + if ($koha_user->{koha_identifier}){ + #NOTE: This section essentially says that it's OK to log this user into Koha + $koha_identifier = $koha_user->{koha_identifier}; + $return = 1; + } else { + $errors->{no_koha_identifier} = 1; + } + + } else { + $errors->{no_email} = 1 if !$userinfo->email; #Tell user + $errors->{not_verified} = 1 if !$userinfo->verified_email && !$insecure; #Tell user + } + + } else { + $errors->{no_user} = 1; #Tell user + } + + } else { + $errors->{token_fail} = 1 if !$access_token; + $errors->{token_invalid} = 1 if !$token_response_valid; + $errors->{no_subject} = 1 if !$subject; + } + + } else { + $errors->{token_fail} = 1; + } + + } elsif ($server_error){ + $errors->{server_error} = 1; #Tell user + warn "OpenID Connect Server Error = $server_error"; + warn "OpenID Connect Server Error Description = $server_error_description" if $server_error_description; + #NOTE: For more information, consult http://openid.net/specs/openid-connect-core-1_0.html#AuthError + } +} else { + $errors->{config_fail} = 1; #Tell user + if ($OpenIDC->debugging){ + warn "DEBUGGING OpenID Connect object = ".Dumper($OpenIDC); + } +} + +if ($OpenIDC->debugging){ + warn "DEBUGGING OpenIDConnect1.0 errors = ".Dumper($errors); +} + +my $external_authen = { + "OpenIDConnect1.0" => { + errors => $errors, + return => $return, + koha_identifier => $koha_identifier, + }, +}; + +my ( $userid, $cookie, $sessionID ) = checkauth( $query, 0, {}, 'opac', '', $external_authen ); + +if ($provider_id){ + my $post_auth_session = C4::Auth::get_session($sessionID); + #Set the oidc provider id in the user's active logged-in session + $post_auth_session->param("oidc_pid",$provider_id); +} + + +#NOTE: If we are unable to authenticate into Koha, we'll be shown the opac-auth.tt page but have the service URL in the address bar +print $query->redirect(-uri => "/cgi-bin/koha/opac-user.pl", -cookie => $cookie); +exit; + +################################################################################################## +#FUNCTIONS + +sub _process_user { + my ($args) = @_; + my ( $koha_identifier ); + my $userinfo = $args->{userinfo}; #This should be a Koha::Prosentient::Auth::OpenIDConnect::UserInfo object + my $categorycode = $args->{categorycode}; #This should be set in koha-conf.xml + my $branchcode = $args->{branchcode}; #This should be set in koha-conf.xml + my $errors = $args->{errors}; + + #Get borrowers + my $ProsentientBorrowers = Koha::Prosentient::Borrowers->new(); + my $borrowers = $ProsentientBorrowers->FindBorrowerByEmails({ + email => $userinfo->{email}, + }); + + if (scalar @$borrowers == 1){ + $koha_identifier = $borrowers->[0]->{cardnumber} if $borrowers->[0]->{cardnumber}; + $koha_identifier = $borrowers->[0]->{userid} if $borrowers->[0]->{userid}; + } elsif (scalar @$borrowers > 1) { + $errors->{email_not_unique} = 1; + } else { + if ($categorycode && $branchcode && $userinfo){ + my %memberdata = ( + firstname => $userinfo->{firstname}, + surname => $userinfo->{surname}, + email => $userinfo->{email}, + phone => $userinfo->{phone}, + dateofbirth => $userinfo->{dateofbirth}, + categorycode => $categorycode, + branchcode => $branchcode, + #dateexpiry + #dateenrolled + address => $userinfo->{address}, + city => $userinfo->{city} // '', + state => $userinfo->{state}, + zipcode => $userinfo->{zipcode}, + country => $userinfo->{country}, + #TODO: I wonder if we should be using Nickname/nickname for the cardnumber... + #Nickname appears to be the same as their memberID... + #cardnumber => $userinfo->{nickname}, + ); + #company_name + $memberdata{sort1} = $userinfo->{koha_sort1} if $userinfo->{koha_sort1}; + + + my $borrowernumber = C4::Members::AddMember(%memberdata); + if ($borrowernumber){ + my $new_borrowers = $ProsentientBorrowers->FindBorrowerByEmails({ + email => $userinfo->{email}, + }); + if (scalar @$new_borrowers == 1){ + $koha_identifier = $new_borrowers->[0]->{cardnumber} if $new_borrowers->[0]->{cardnumber}; + $koha_identifier = $new_borrowers->[0]->{userid} if $new_borrowers->[0]->{userid}; + } else { + $errors->{email_not_unique} = 1; + } + } + } + } + + return ( {koha_identifier => $koha_identifier}, $errors ); +} -- 2.13.7