From aa04a7c20442e3e11371973dca31fea2aeb7b994 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 | 273 ++++++++++++++++++ 7 files changed, 348 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..2a3eed1537 100644 --- a/C4/Auth.pm +++ b/C4/Auth.pm @@ -1478,6 +1478,13 @@ sub checkauth { } } + if (C4::Context->preference('OIDC')) { + if ($query->param("OIDCFailed")) { + my $reason = $query->param('OIDCFailed'); + $template->param(invalidOIDCLogin => $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..0abc5d1967 --- /dev/null +++ b/installer/data/mysql/atomicupdate/openidconnect.sql @@ -0,0 +1,10 @@ +INSERT INTO systempreferences (variable,value,explanation,options,type) +VALUES +('OIDC','0',NULL,'if ON, allows the use of OpenID Connect for login','YesNo'), +('OIDCConfigURL','',NULL,"URL to identity provider's OpenID config",'Free'), +('OIDCOAuth2ClientID','', NULL,'Client ID for the web app','Free'), +('OIDCOAuth2ClientSecret','',NULL,'Client Secret for the web app','Free'), +('OIDCAutoRegister','0',NULL,'OpenID Connect logins to auto-register patrons','YesNo'), +('OIDCDefaultBranch', '','','This branch code will be used to create OpenID Connect patrons.','Textarea'), +('OIDCDefaultCategory','','','This category code will be used to create OpenID Connect patrons.','Textarea'), +('OIDCDomain', '', 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..a27e2f7958 100644 --- a/installer/data/mysql/mandatory/sysprefs.sql +++ b/installer/data/mysql/mandatory/sysprefs.sql @@ -391,6 +391,14 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` ('OAI-PMH:ConfFile','',NULL,'If empty, Koha OAI Server operates in normal mode, otherwise it operates in extended mode.','File'), ('OAI-PMH:DeletedRecord','persistent','Koha\'s deletedbiblio table will never be deleted (persistent), might be deleted (transient), or will never have any data in it (no)','transient|persistent|no','Choice'), ('OAI-PMH:MaxCount','50',NULL,'OAI-PMH maximum number of records by answer to ListRecords and ListIdentifiers queries','Integer'), +('OIDC','0',NULL,'if ON, allows the use of OpenID Connect for login','YesNo'), +('OIDCAutoRegister','0',NULL,'OpenID Connect logins to auto-register patrons','YesNo'), +('OIDCConfigURL','',NULL,"URL to identity provider's OpenID config",'Free'), +('OIDCDefaultBranch', '','','This branch code will be used to create OpenID Connect patrons.','Textarea'), +('OIDCDefaultCategory','','','This category code will be used to create OpenID Connect patrons.','Textarea'), +('OIDCDomain', '', NULL, 'Restrict OpenID Connect to this domain (or subdomains of this domain). Leave blank for all domains', 'Free'), +('OIDCOAuth2ClientID', '', NULL, 'Client ID for the web app', 'Free'), +('OIDCOAuth2ClientSecret', '', NULL, 'Client Secret for the web app', 'Free'), ('OPACAcquisitionDetails','0','','Show the acquisition details at the OPAC','YesNo'), ('OpacAddMastheadLibraryPulldown','0','','Adds a pulldown menu to select the library to search on the opac masthead.','YesNo'), ('OpacAdvSearchMoreOptions','pubdate,itemtype,language,subtype,sorting,location','Show search options for the expanded view (More options)','pubdate|itemtype|language|subtype|sorting|location','multiple'), 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..7b79ca7bd6 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: OIDC + choices: + 1: "Yes" + 0: "No" + - You will need to create an app in your chosen identity provider and activate OAuth2. Requires OIDCConfigURL, OAuth2ClientID and OAuth2ClientSecret + - + - "URL to your identity provider's OpenID configuration: " + - pref: OIDCConfigURL + - Along with OAuth2ClientID and OAuth2ClientSecret. Enables OIDC. + - + - "OAuth2 Client ID: " + - pref: OIDCOAuth2ClientID + - Along with OIDCConfigURL and OAuth2ClientSecret. Enables OIDC. + - + - "OAuth2 Client Secret: " + - pref: OIDCOAuth2ClientSecret + - Along with OIDCConfigURL and OAuth2ClientID. Enables OIDC. + - + - pref: OIDCAutoRegister + choices: + 1: Allow + 0: "Don't allow" + - patrons logging in with OpenID to automatically register. Requires OIDC. + - + - "Use this category code when automatically registering a patron using OpenID: " + - pref: OIDCDefaultCategory + choices: patron-categories + - Requires OIDC. + - + - "Use this branch code when automatically registering a patron using OpenID: " + - pref: OIDCDefaultBranch + - Requires OIDC. + - + - "Restrict OpenID Connect to this domain (or subdomain of this domain): " + - pref: OIDCDomain + - Leave blank for all domains. Requires OIDC. 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..fa77cfec37 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('OIDC') == 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..2933410ed8 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('OIDC') == 1 ) %] + [% IF ( invalidOIDCLogin ) %] +

OpenID login

+

Sorry, your login using OpenID Connect failed. [% invalidOIDCLogin | 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 OIDC %] [% 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..83ae392035 --- /dev/null +++ b/opac/svc/auth/openidconnect @@ -0,0 +1,273 @@ +#!/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 }; +use URI; +use URI::QueryParam; + +my $discoveryDocURL = C4::Context->preference('OIDCConfigURL'); +my $issuer = ''; +my $authendpoint = ''; +my $tokenendpoint = ''; +my $scope = 'openid email profile'; +my $host = C4::Context->preference('OPACBaseURL') // q{}; +my $restricttodomain = C4::Context->preference('OIDCDomain') + // q{}; + +# protocol is assumed in OPACBaseURL see bug 5010. +my $redirecturl = $host . '/cgi-bin/koha/svc/auth/openidconnect'; +my $clientid = C4::Context->preference('OIDCOAuth2ClientID'); +my $clientsecret = C4::Context->preference('OIDCOAuth2ClientSecret'); + +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( 'OIDCFailed' => $reason ); + my ( $template, $borrowernumber, $cookie ) = get_template_and_user( + { + template_name => 'opac-user.tt', + query => $cgi_query, + type => 'opac', + } + ); + $template->param( 'invalidOIDCLogin' => $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 ) ) + { + 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('OIDCAutoRegister') // q{0}; + my $borrower = Koha::Patrons->find( { email => $email }, { emailpro => $email } ); + if (! $borrower && $auto_registration==1) { + my $firstname = $claims_json->{'given_name'} // q{}; + my $surname = $claims_json->{'family_name'} // q{}; + if ($firstname && $surname) { + my $delimiter = $firstname ? q{.} : q{}; + my $userid = $firstname . $delimiter . $surname; + my $categorycode = C4::Context->preference('OIDCDefaultCategory') // q{}; + my $patron_category = Koha::Patron::Categories->find( $categorycode ); + my $branchcode = C4::Context->preference('OIDCDefaultBranch') // 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 OIDCDefaultBranch or OIDCDefaultCategory system preferences are not configured properly. Please contact the library with this error message.'; + } + } else { + $error_feedback = 'We could not find given name and/or family name.' + } + } + 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 = URI->new($authendpoint); + $authorisationurl->query_form( + response_type => "code", + redirect_uri => $redirecturl, + client_id => $clientid, + scope => $scope, + state => $openidstate + ); + if ( $prompt || ( defined $prompt && length $prompt > 0 ) ) { + $authorisationurl->query_param_append( prompt => $prompt ); + } + print $query->redirect($authorisationurl->as_string); +} -- 2.25.1