diff --git a/C4/Auth.pm b/C4/Auth.pm index f281def2ce..8c8784317e 100644 --- a/C4/Auth.pm +++ b/C4/Auth.pm @@ -1433,6 +1433,13 @@ sub checkauth { } } + if (C4::Context->preference('AzureADOpenIDConnect')) { + if ($query->param("OpenIDConnectFailed")) { + my $reason = $query->param('OpenIDConnectFailed'); + $template->param(invalidAzureADOpenIDConnectLogin => $reason); + } + } + $template->param( LibraryName => C4::Context->preference("LibraryName"), ); diff --git a/Koha.pm b/Koha.pm index a2653023f5..59a5ba5db5 100644 --- a/Koha.pm +++ b/Koha.pm @@ -29,7 +29,7 @@ use vars qw{ $VERSION }; # - #4 : the developer version. The 4th number is the database subversion. # used by developers when the database changes. updatedatabase take care of the changes itself # and is automatically called by Auth.pm when needed. -$VERSION = "20.12.00.050"; +$VERSION = "20.12.00.051"; sub version { return $VERSION; diff --git a/installer/data/mysql/mandatory/sysprefs.sql b/installer/data/mysql/mandatory/sysprefs.sql index 0a86168ee0..90cd59b9f9 100644 --- a/installer/data/mysql/mandatory/sysprefs.sql +++ b/installer/data/mysql/mandatory/sysprefs.sql @@ -86,6 +86,14 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` ('AutoSelfCheckID','','','Staff ID with circulation rights to be used for automatic web-based self-check. Only applies if AutoSelfCheckAllowed syspref is turned on.','free'), ('AutoSelfCheckPass','','','Password to be used for automatic web-based self-check. Only applies if AutoSelfCheckAllowed syspref is turned on.','free'), ('AutoSwitchPatron', '0', '', 'Auto switch to patron', 'YesNo'), +('AzureADOpenIDConnect', '0', NULL, 'if ON, allows the use of AzureAD OpenID Connect for login', 'YesNo'), +('AzureADOpenIDConnectAutoRegister', '0', NULL, 'If ON autoprovisions new users coming from AzureAD', 'YesNo'), +('AzureADOAuth2TenantID', '', NULL, 'Tenant ID for the web app registered with AzureAD', 'Free'), +('AzureADOAuth2ClientID', '', NULL, 'Client ID for the web app registered with AzureAD', 'Free'), +('AzureADOAuth2ClientSecret', '', NULL, 'Client Secret for the web app registered with AzureAD', 'Free'), +('AzureADOpenIDConnectAutoRegister', '0', NULL, 'if ON autoprovisions users arriving from AzureAD', 'YesNo'), +('AzureADOpenIDConnectDefaultCategory', '', '', 'This category code will be used to create Google OpenID Connect patrons.', 'Textarea'), +('AzureADOpenIDConnectDefaultBranch', '', '', 'This branch code will be used to create Google OpenID Connect patrons.', 'Textarea'), ('Babeltheque','0','','Turn ON Babeltheque content - See babeltheque.com to subscribe to this service','YesNo'), ('Babeltheque_url_js','','','Url for Babeltheque javascript (e.g. http://www.babeltheque.com/bw_XX.js)','Free'), ('Babeltheque_url_update','','','Url for Babeltheque update (E.G. http://www.babeltheque.com/.../file.csv.bz2)','Free'), diff --git a/installer/data/mysql/updatedatabase.pl b/installer/data/mysql/updatedatabase.pl index 6b0a6b53fe..b783345b77 100755 --- a/installer/data/mysql/updatedatabase.pl +++ b/installer/data/mysql/updatedatabase.pl @@ -24274,6 +24274,23 @@ if ( CheckVersion($DBversion) ) { NewVersion( $DBversion, 28108, "Add new systempreference OpacHiddenItemsHidesRecord" ); } +$DBversion = '20.12.00.051'; +if ( C4::Context->preference("Version") < TransformToNum($DBversion) ) { + $dbh->do(q{ + INSERT IGNORE INTO systempreferences (variable,value,explanation,options,type) VALUES + ('AzureADOpenIDConnect', '0', NULL, 'if ON, allows the use of AzureAD OpenID Connect for login', 'YesNo'), + ('AzureADOAuth2TenantID', '', NULL, 'Tenant ID for the web app registered with AzureAD', 'Free'), + ('AzureADOAuth2ClientID', '', NULL, 'Client ID for the web app registered with AzureAD', 'Free'), + ('AzureADOpenIDConnectDefaultCategory', '', '', 'This category code will be used to create Google OpenID Connect patrons.', 'Textarea'), + ('AzureADOpenIDConnectDefaultBranch', '', '', 'This branch code will be used to create Google OpenID Connect patrons.', 'Textarea'), + ('AzureADOpenIDConnectAutoRegister', '0', NULL, 'if ON autoprovisions users arriving from AzureAD', 'YesNo'), + ('AzureADOAuth2ClientSecret', '', NULL, 'Client Secret for the web app registered with AzureAD', 'Free'); + }); + + print "Upgrade to $DBversion done (Bug 28420 - Allow login via AzureAD OAuth2 (OpenID-Connect))\n"; + SetVersion($DBversion); +} + # SEE bug 13068 # if there is anything in the atomicupdate, read and execute it. my $update_dir = C4::Context->config('intranetdir') . '/installer/data/mysql/atomicupdate/'; 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 a858b1d0da..f11fb2af70 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 @@ -196,6 +196,39 @@ Administration: - "Use this branch code when automatically registering a Google Open ID patron: " - pref: GoogleOpenIDConnectDefaultBranch - "." + AzureAD OpenID Connect: + - + - "Use AzureAD OpenID-Connect login: " + - pref: AzureADOpenIDConnect + choices: + 1: "Yes" + 0: "No" + - You will need to create a new app in https://portal.azure.com and a new secret for this integration + - + - "" + - pref: AzureADOpenIDConnectAutoRegister + choices: + 1: "Allow" + 0: "Don't allow" + - Patrons logging in with AzureAD OpenID to automatically register + - + - "AzureAD Tenant ID: " + - pref: AzureADOAuth2TenantID + - + - "AzureAD Client ID: " + - pref: AzureADOAuth2ClientID + - + - "AzureAD Client Secret: " + - pref: AzureADOAuth2ClientSecret + - + - "Use this category code when automatically registering a AzureAD OpenID patron: " + - pref: AzureADOpenIDConnectDefaultCategory + choices: patron-categories + - "." + - + - "Use this branch code when automatically registering a AzureAD OpenID patron: " + - pref: AzureADOpenIDConnectDefaultBranch + - "." Share anonymous usage statistics: - - "Share anonymous Koha usage data with the Koha community: " diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc index b58703b8d0..27d90f1a20 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 @@ Log in to your account [% ELSIF ( Koha.Preference('GoogleOpenIDConnect') == 1 ) %] Log in to your account + [% ELSIF ( Koha.Preference('AzureADOpenIDConnect') == 1 ) %] + Log in to your account [% ELSE %] Log in to your account [% END %] 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 f15beea5fc..d89b97b86e 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,18 @@ 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('AzureADOpenIDConnect') == 1 ) %] + [% IF ( invalidAzureADOpenIDConnectLogin ) %] +

AzureAD login

+

Sorry, your AzureAD login failed. [% invalidAzureADOpenIDConnectLogin | html %]

+

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

+

If you want to, you can try to log in using a different account + [% END %] + Log in with AzureAD +

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

+ [% END # /IF AzureADOpenIDConnect %] + [% END # /UNLESS OPACShibOnly %] [% IF !Koha.Preference('OPACShibOnly') or SCO_login or SCI_login %] diff --git a/opac/svc/auth/azureadopenidconnect b/opac/svc/auth/azureadopenidconnect new file mode 100755 index 0000000000..1c89819836 --- /dev/null +++ b/opac/svc/auth/azureadopenidconnect @@ -0,0 +1,272 @@ +#!/usr/bin/perl +# Copyright mark.jaroski@gmail.com, jaroskim@who.int 2021 +# based on the google Oauth code from vanoudt@gmail.com 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, see . +# +# +# Basic OAuth2/OpenID Connect authentication for AzureAD goes like this +# +# The first thing that happens when this script is called is +# that one gets redirected to an authentication url from AzureAD +# +# 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 AzureAD to decrypt. Finally, we can extract +# the email address from this. +# +# NOTE: This is not in fact the OpenID-Connect workflow, but a different +# workflow workflow called the Code Grant flow. + +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 Data::Dumper; # FIXME + +my $tenantID = C4::Context->preference('AzureADOAuth2TenantID'); +my $authority= "https://login.microsoftonline.com/${tenantID}/v2.0"; +my $discoveryDocURL = "${authority}/.well-known/openid-configuration"; +warn $discoveryDocURL; +my $authendpoint = ''; +my $tokenendpoint = ''; +my $scope = 'openid email profile'; +my $host = C4::Context->preference('OPACBaseURL') // q{}; +# TODO either implement or delete $restricttodomain +# normally handled in AzureAD with user/group assignment +my $restricttodomain = ""; + +# protocol is assumed in OPACBaseURL see bug 5010. +my $redirecturl = $host . '/cgi-bin/koha/svc/auth/azureadopenidconnect'; +my $clientid = C4::Context->preference('AzureADOAuth2ClientID'); +my $clientsecret = C4::Context->preference('AzureADOAuth2ClientSecret'); + +my $ua = LWP::UserAgent->new(); +my $response = $ua->get($discoveryDocURL); +if ( $response->is_success ) { + my $json = decode_json( $response->decoded_content ); + 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( 'invalidAzureOpenIDConnectLogin' => $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('azuread-openid-state') ne $stateclaim ) { + $session->clear( ["azuread-openid-state"] ); + $session->flush(); + loginfailed( $query, + 'Authentication failed. Your session has an unexpected state.' ); + } + $session->clear( ["azuread-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'}; + + 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 ( ( $claims_json->{'iss'} ne ( 'https://' . $authority) ) + && ( $claims_json->{'iss'} ne $authority) ) + { + loginfailed( $query, + "Authentication failed. Issuer of authentication : " . $claims_json->{'iss'} . " does not match ${authority}" + ); + } + 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: -" . $claims_json->{'aud'} . "- : -" . $clientid . "-"); + } + 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.'; + $error_feedback = Dumper($claims_json); # FIXME + my $auto_registration = C4::Context->preference('AzureADOpenIDConnectAutoRegister') // 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('AzureADOpenIDConnectDefaultCategory') // q{}; + my $patron_category = Koha::Patron::Categories->find( $categorycode ); + my $branchcode = C4::Context->preference('AzureADOpenIDConnectDefaultBranch') // 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 AzureADOpenIDConnectDefaultBranch or AzureADOpenIDConnectDefaultCategory 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 from AzureAD.' ); + } +} +else { + my $session = get_session( $query->cookie('CGISESSID') ); + my $openidstate = 'auth_'; + $openidstate .= sprintf( "%x", rand 16 ) for 1 .. 32; + $session->param( 'azuread-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); +}