From db9272b8b2aea6abf01ba23d2589ac4ab3c64b54 Mon Sep 17 00:00:00 2001 From: Shi Yao Wang Date: Thu, 16 Jun 2022 16:28:30 -0400 Subject: [PATCH] Bug 30988: Adding a more generic version of googleopenidconnect More generic OpenID Connect based off of googleopenidconnect Test plan: 1- Set up an OAuth2 provider that has a discovery doc (e.g. for google it's https://accounts.google.com/.well-known/openid-configuration) and note down the client id and client secret. Docs to help setup: google: https://developers.google.com/identity/protocols/oauth2/openid-connect https://koha-community.org/manual/20.11/en/html/administrationpreferences.html gitlab: https://docs.gitlab.com/ee/integration/oauth_provider.html 2- Apply the patch 3- Run atomicupdate to update database 4- Configure system preferences: a- Set OpenIDConnect to Yes b- Enter the url to the discovery doc of your OAuth2 provider c- Fill OpenIDOAuth2ClientID with noted client id d- Fill OpenIDOAuth2ClientSecret with noted client secret e- Configure the other related system preferences as you wish. They shoud work the same way as the similarly named GoogleOpenID* ones. 5- If OpenIDConnectAutoRegister is set to Don't allow, have a koha account with the same email as the one used by your OAuth2 provider. 6- In OPAC, sign in using OpenID. 7- You may have to log into your OpenID provider account and accept conditions. It should finally log you into the koha account. --- C4/Auth.pm | 7 + .../data/mysql/atomicupdate/openidconnect.sql | 10 + installer/data/mysql/mandatory/sysprefs.sql | 8 + .../en/modules/admin/preferences/admin.pref | 39 +++ .../bootstrap/en/includes/masthead.inc | 2 + .../bootstrap/en/modules/opac-auth.tt | 9 + opac/svc/auth/openidconnect | 271 ++++++++++++++++++ 7 files changed, 346 insertions(+) create mode 100644 installer/data/mysql/atomicupdate/openidconnect.sql create mode 100755 opac/svc/auth/openidconnect diff --git a/C4/Auth.pm b/C4/Auth.pm index a44cd09767..7063d4655a 100644 --- a/C4/Auth.pm +++ b/C4/Auth.pm @@ -1478,6 +1478,13 @@ sub checkauth { } } + if (C4::Context->preference('OpenIDConnect')) { + if ($query->param("OpenIDConnectFailed")) { + my $reason = $query->param('OpenIDConnectFailed'); + $template->param(invalidOpenIDConnectLogin => $reason); + } + } + $template->param( LibraryName => C4::Context->preference("LibraryName"), ); diff --git a/installer/data/mysql/atomicupdate/openidconnect.sql b/installer/data/mysql/atomicupdate/openidconnect.sql new file mode 100644 index 0000000000..6a32a0f30e --- /dev/null +++ b/installer/data/mysql/atomicupdate/openidconnect.sql @@ -0,0 +1,10 @@ +INSERT INTO systempreferences (variable,value,explanation,options,type) +VALUES +('OpenIDConnect','0',NULL,'if ON, allows the use of OpenID Connect for login','YesNo'), +('OpenIDConfigURL','',NULL,"URL to identity provider's OpenID config",'Free'), +('OpenIDOAuth2ClientID','', NULL,'Client ID for the web app','Free'), +('OpenIDOAuth2ClientSecret','',NULL,'Client Secret for the web app','Free'), +('OpenIDConnectAutoRegister','0',NULL,'OpenID Connect logins to auto-register patrons','YesNo'), +('OpenIDConnectDefaultBranch', '','','This branch code will be used to create OpenID Connect patrons.','Textarea'), +('OpenIDConnectDefaultCategory','','','This category code will be used to create OpenID Connect patrons.','Textarea'), +('OpenIDConnectDomain', '', NULL, 'Restrict OpenID Connect to this domain (or subdomains of this domain). Leave blank for all domains', 'Free'); \ No newline at end of file diff --git a/installer/data/mysql/mandatory/sysprefs.sql b/installer/data/mysql/mandatory/sysprefs.sql index a15eb6c112..03c1b6a656 100644 --- a/installer/data/mysql/mandatory/sysprefs.sql +++ b/installer/data/mysql/mandatory/sysprefs.sql @@ -497,6 +497,14 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` ('OPACXSLTDetailsDisplay','default','','Enable XSL stylesheet control over details page display on OPAC','Free'), ('OPACXSLTListsDisplay','default','','Enable XSLT stylesheet control over lists pages display on OPAC','Free'), ('OPACXSLTResultsDisplay','default','','Enable XSL stylesheet control over results page display on OPAC','Free'), +('OpenIDConfigURL','',NULL,"URL to identity provider's OpenID config",'Free'), +('OpenIDConnect','0',NULL,'if ON, allows the use of OpenID Connect for login','YesNo'), +('OpenIDConnectAutoRegister','0',NULL,'OpenID Connect logins to auto-register patrons','YesNo'), +('OpenIDConnectDefaultBranch', '','','This branch code will be used to create OpenID Connect patrons.','Textarea'), +('OpenIDConnectDefaultCategory','','','This category code will be used to create OpenID Connect patrons.','Textarea'), +('OpenIDConnectDomain', '', NULL, 'Restrict OpenID Connect to this domain (or subdomains of this domain). Leave blank for all domains', 'Free'), +('OpenIDOAuth2ClientID', '', NULL, 'Client ID for the web app', 'Free'), +('OpenIDOAuth2ClientSecret', '', NULL, 'Client Secret for the web app', 'Free'), ('OpenLibraryCovers','0',NULL,'If ON Openlibrary book covers will be show','YesNo'), ('OpenLibrarySearch','0',NULL,'If Yes Open Library search results will show in OPAC','YesNo'), ('OpenURLResolverURL', '', NULL, 'URL of OpenURL Resolver', 'Free'), diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/admin.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/admin.pref index 593c8c3417..f29a136492 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/admin.pref +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/admin.pref @@ -171,6 +171,45 @@ Administration: None: "None" Common Name: Common Name emailAddress: emailAddress + OpenID Connect: + - + - "Use OpenID Connect login in the OPAC: " + - pref: OpenIDConnect + choices: + 1: "Yes" + 0: "No" + - You will need to create an app in your chosen identity provider and activate OAuth2. Requires OpenIDConfigURL, OAuth2ClientID and OAuth2ClientSecret + - + - "URL to your identity provider's OpenID configuration: " + - pref: OpenIDConfigURL + - Along with OAuth2ClientID and OAuth2ClientSecret. Enables OpenIDConnect. + - + - "OAuth2 Client ID: " + - pref: OpenIDOAuth2ClientID + - Along with OpenIDConfigURL and OAuth2ClientSecret. Enables OpenIDConnect. + - + - "OAuth2 Client Secret: " + - pref: OpenIDOAuth2ClientSecret + - Along with OpenIDConfigURL and OAuth2ClientID. Enables OpenIDConnect. + - + - pref: OpenIDConnectAutoRegister + choices: + 1: Allow + 0: "Don't allow" + - patrons logging in with OpenID to automatically register. Requires OpenIDConnect. + - + - "Use this category code when automatically registering a patron using OpenID: " + - pref: OpenIDConnectDefaultCategory + choices: patron-categories + - Requires OpenIDConnect. + - + - "Use this branch code when automatically registering a patron using OpenID: " + - pref: OpenIDConnectDefaultBranch + - Requires OpenIDConnect. + - + - "Restrict OpenID Connect to this domain (or subdomain of this domain): " + - pref: OpenIDConnectDomain + - Leave blank for all domains. Requires OpenIDConnect. Google OpenID Connect: - - "Use Google OpenID Connect login in the OPAC: " diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc index b60c696d56..77d87376e8 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc +++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc @@ -99,6 +99,8 @@ [% IF Koha.Preference('casAuthentication') %] [%# CAS authentication is too complicated for modal window %] Log in to your account + [% ELSIF ( Koha.Preference('OpenIDConnect') == 1 ) %] + Log in to your account [% ELSIF ( Koha.Preference('GoogleOpenIDConnect') == 1 ) %] Log in to your account [% ELSE %] 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 0fd0d32bba..698db809b7 100644 --- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt +++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt @@ -165,6 +165,15 @@ Log in with Google

If you do not have a Google account, but do have a local account, you can still log in:

[% END # /IF GoogleOpenIDConnect %] + [% IF ( Koha.Preference('OpenIDConnect') == 1 ) %] + [% IF ( invalidOpenIDConnectLogin ) %] +

OpenID login

+

Sorry, your login using OpenID Connect failed. [% invalidOpenIDConnectLogin | html %]

+

Please note that the login will only work if you are using the e-mail address registered with this library.

+ [% END %] + Log in with OpenID +

If you do not have an OpenID account from the provider specified in this library, but do have a local account, you can still log in:

+ [% END # /IF OpenIDConnect %] [% END # /UNLESS OPACShibOnly %] [% IF !(invalid_username_or_password || too_many_login_attempts) and password_has_expired %] diff --git a/opac/svc/auth/openidconnect b/opac/svc/auth/openidconnect new file mode 100755 index 0000000000..7cd8c4dc51 --- /dev/null +++ b/opac/svc/auth/openidconnect @@ -0,0 +1,271 @@ +#!/usr/bin/perl +# +# 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, see . +# +# +# Basic OAuth2/OpenID Connect authentication goes like this +# +# The first thing that happens when this script is called is +# that one gets redirected to an authentication url +# +# If successful, that then redirects back to this script, setting +# a CODE parameter which we use to look up a json authentication +# token. This token includes an encrypted json id_token, which we +# round-trip back to decrypt. Finally, we can extract +# the email address from this. +# + +use Modern::Perl; +use CGI qw ( -utf8 escape ); +use C4::Auth qw{ checkauth get_session get_template_and_user }; +use C4::Context; +use C4::Output; +use Koha::Patrons; + +use LWP::UserAgent; +use HTTP::Request::Common qw{ POST }; +use JSON; +use MIME::Base64 qw{ decode_base64url }; + +my $discoveryDocURL = C4::Context->preference('OpenIDConfigURL'); +my $issuer = ''; +my $authendpoint = ''; +my $tokenendpoint = ''; +my $scope = 'openid email profile'; +my $host = C4::Context->preference('OPACBaseURL') // q{}; +my $restricttodomain = C4::Context->preference('OpenIDConnectDomain') + // q{}; + +# protocol is assumed in OPACBaseURL see bug 5010. +my $redirecturl = $host . '/cgi-bin/koha/svc/auth/openidconnect'; +my $clientid = C4::Context->preference('OpenIDOAuth2ClientID'); +my $clientsecret = C4::Context->preference('OpenIDOAuth2ClientSecret'); + +my $ua = LWP::UserAgent->new(); +my $response = $ua->get($discoveryDocURL); +if ( $response->is_success ) { + my $json = decode_json( $response->decoded_content ); + if ( exists( $json->{'issuer'} ) ) { + $issuer = $json->{'issuer'}; + } + if ( exists( $json->{'authorization_endpoint'} ) ) { + $authendpoint = $json->{'authorization_endpoint'}; + } + if ( exists( $json->{'token_endpoint'} ) ) { + $tokenendpoint = $json->{'token_endpoint'}; + } +} + +my $query = CGI->new; + +sub loginfailed { + my $cgi_query = shift; + my $reason = shift; + $cgi_query->delete('code'); + $cgi_query->param( 'OpenIDConnectFailed' => $reason ); + my ( $template, $borrowernumber, $cookie ) = get_template_and_user( + { + template_name => 'opac-user.tt', + query => $cgi_query, + type => 'opac', + } + ); + $template->param( 'invalidOpenIDConnectLogin' => $reason ); + $template->param( 'loginprompt' => 1 ); + output_html_with_http_headers $cgi_query, $cookie, $template->output, undef, { force_no_caching => 1 }; + return; +} + +if ( defined $query->param('error') ) { + loginfailed( $query, + 'An authentication error occurred. (Error:' + . $query->param('error') + . ')' ); +} +elsif ( defined $query->param('code') ) { + my $stateclaim = $query->param('state'); + my $session = get_session( $query->cookie('CGISESSID') ); + if ( $session->param('openid-state') ne $stateclaim ) { + $session->clear( ["openid-state"] ); + $session->flush(); + loginfailed( $query, + 'Authentication failed. Your session has an unexpected state.' ); + } + $session->clear( ["openid-state"] ); + $session->flush(); + + my $code = $query->param('code'); + my $ua = LWP::UserAgent->new(); + if ( $tokenendpoint eq q{} ) { + loginfailed( $query, 'Unable to discover token endpoint.' ); + } + my $request = POST( + $tokenendpoint, + [ + code => $code, + client_id => $clientid, + client_secret => $clientsecret, + redirect_uri => $redirecturl, + grant_type => 'authorization_code', + $scope => $scope + ] + ); + my $response = $ua->request($request)->decoded_content; + my $json = decode_json($response); + if ( exists( $json->{'id_token'} ) ) { + if ( lc( $json->{'token_type'} ) ne 'bearer' ) { + loginfailed( $query, + 'Authentication failed. Incorrect token type.' ); + } + my $idtoken = $json->{'id_token'}; + + # need to validate the token here + + my @segments = split( '\.', $idtoken ); + unless ( scalar(@segments) == 3 ) { + loginfailed( $query, + 'Login token broken: either too many or too few segments.' ); + } + my ( $header, $claims, $validation ) = @segments; + $claims = decode_base64url($claims); + my $claims_json = decode_json($claims); + if ( $issuer eq q{} ) { + loginfailed( $query, 'Unable to discover issuer.' ); + } + if ( ( $claims_json->{'iss'} ne ( 'https://' . $issuer ) ) + && ( $claims_json->{'iss'} ne $issuer ) ) + { + loginfailed( $query, + "Authentication failed. Issuer of authentication is different from what we expected." + ); + } + if ( ref( $claims_json->{'aud'} ) eq 'ARRAY' ) { + warn "Audience is an array of size: " + . scalar( @$claims_json->{'aud'} ); + if ( scalar( @$claims_json->{'aud'} ) > 1 ) + { # We don't want any other audiences + loginfailed( $query, + "Authentication failed. Unexpected audience provided." ); + } + } + if ( ( $claims_json->{'aud'} ne $clientid ) + || ( $claims_json->{'azp'} ne $clientid ) ) + { + loginfailed( $query, + "Authentication failed. Unexpected audience." ); + } + if ( $claims_json->{'exp'} < time() ) { + loginfailed( $query, 'Sorry, your authentication has timed out.' ); + } + + if ( exists( $claims_json->{'email'} ) ) { + my $email = $claims_json->{'email'}; + if ( ( $restricttodomain ne q{} ) + && ( index( $email, $restricttodomain ) < 0 ) ) + { + loginfailed( $query, +'The email you have used is not valid for this library. Email addresses should conclude with ' + . $restricttodomain + . ' .' ); + } + else { + my $error_feedback = +'The email address you are trying to use is not associated with a borrower at this library.'; + my $auto_registration = C4::Context->preference('OpenIDConnectAutoRegister') // q{0}; + my $borrower = Koha::Patrons->find( { email => $email } ); + if (! $borrower && $auto_registration==1) { + my $firstname = $claims_json->{'given_name'} // q{}; + my $surname = $claims_json->{'family_name'} // q{}; + my $delimiter = $firstname ? q{.} : q{}; + my $userid = $firstname . $delimiter . $surname; + my $categorycode = C4::Context->preference('OpenIDConnectDefaultCategory') // q{}; + my $patron_category = Koha::Patron::Categories->find( $categorycode ); + my $branchcode = C4::Context->preference('OpenIDConnectDefaultBranch') // q{}; + my $library = Koha::Libraries->find( $branchcode ); + if (defined $patron_category && defined $library) { + my $password = undef; + # TODO errors handling! + my $borrower = Koha::Patron->new({ + firstname => $firstname, + surname => $surname, + email => $email, + categorycode => $categorycode, + branchcode => $branchcode, + userid => $userid, + password => $password + })->store; + } else { + $error_feedback = 'The OpenIDConnectDefaultBranch or OpenIDConnectDefaultCategory system preferences are not configured properly. Please contact the library with this error message.'; + } + } + my ( $userid, $cookie, $session_id ) = + checkauth( $query, 1, {}, 'opac', $email ); + if ($userid) { # A user with this email is registered in koha + + #handle redirect to main.pl, for private opac + my $uri; + if (C4::Context->preference('OpacPublic') ) { + $uri = '/cgi-bin/koha/opac-user.pl'; + } else { + $uri = '/cgi-bin/koha/opac-main.pl'; + } + print $query->redirect( + -uri => $uri, + -cookie => $cookie + ); + } + else { + loginfailed( $query, $error_feedback ); + } + } + } + else { + loginfailed( $query, +'Unexpectedly, no email seems to be associated with that acccount.' + ); + } + } + else { + loginfailed( $query, 'Failed to get proper credentials.' ); + } +} +else { + my $session = get_session( $query->cookie('CGISESSID') ); + my $openidstate = 'auth_'; + $openidstate .= sprintf( "%x", rand 16 ) for 1 .. 32; + $session->param( 'openid-state', $openidstate ); + $session->flush(); + + my $prompt = $query->param('reauthenticate') // q{}; + if ( $authendpoint eq q{} ) { + loginfailed( $query, 'Unable to discover authorisation endpoint.' ); + } + my $authorisationurl = + $authendpoint . '?' + . 'response_type=code&' + . 'redirect_uri=' + . escape($redirecturl) . q{&} + . 'client_id=' + . escape($clientid) . q{&} + . 'scope=' + . escape($scope) . q{&} + . 'state=' + . escape($openidstate); + if ( $prompt || ( defined $prompt && length $prompt > 0 ) ) { + $authorisationurl .= '&prompt=' . escape($prompt); + } + print $query->redirect($authorisationurl); +} -- 2.25.1