--- a/C4/Auth.pm
+++ a/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"),
);
--- a/Koha.pm
+++ a/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;
--- a/installer/data/mysql/mandatory/sysprefs.sql
+++ a/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'),
--- a/installer/data/mysql/updatedatabase.pl
+++ a/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/';
--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/admin.pref
+++ a/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: "
--- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc
+++ a/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 %]
--- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt
+++ a/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 %]
--- a/opac/svc/auth/azureadopenidconnect
+++ a/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);
+}