View | Details | Raw Unified | Return to bug 28420
Collapse All | Expand All

(-)a/C4/Auth.pm (+7 lines)
Lines 1433-1438 sub checkauth { Link Here
1433
        }
1433
        }
1434
    }
1434
    }
1435
1435
1436
    if (C4::Context->preference('AzureADOpenIDConnect')) {
1437
        if ($query->param("OpenIDConnectFailed")) {
1438
            my $reason = $query->param('OpenIDConnectFailed');
1439
            $template->param(invalidAzureADOpenIDConnectLogin => $reason);
1440
        }
1441
    }
1442
1436
    $template->param(
1443
    $template->param(
1437
        LibraryName => C4::Context->preference("LibraryName"),
1444
        LibraryName => C4::Context->preference("LibraryName"),
1438
    );
1445
    );
(-)a/Koha.pm (-1 / +1 lines)
Lines 29-35 use vars qw{ $VERSION }; Link Here
29
# - #4 : the developer version. The 4th number is the database subversion.
29
# - #4 : the developer version. The 4th number is the database subversion.
30
#        used by developers when the database changes. updatedatabase take care of the changes itself
30
#        used by developers when the database changes. updatedatabase take care of the changes itself
31
#        and is automatically called by Auth.pm when needed.
31
#        and is automatically called by Auth.pm when needed.
32
$VERSION = "20.12.00.050";
32
$VERSION = "20.12.00.051";
33
33
34
sub version {
34
sub version {
35
    return $VERSION;
35
    return $VERSION;
(-)a/installer/data/mysql/mandatory/sysprefs.sql (+8 lines)
Lines 86-91 INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` Link Here
86
('AutoSelfCheckID','','','Staff ID with circulation rights to be used for automatic web-based self-check. Only applies if AutoSelfCheckAllowed syspref is turned on.','free'),
86
('AutoSelfCheckID','','','Staff ID with circulation rights to be used for automatic web-based self-check. Only applies if AutoSelfCheckAllowed syspref is turned on.','free'),
87
('AutoSelfCheckPass','','','Password to be used for automatic web-based self-check. Only applies if AutoSelfCheckAllowed syspref is turned on.','free'),
87
('AutoSelfCheckPass','','','Password to be used for automatic web-based self-check. Only applies if AutoSelfCheckAllowed syspref is turned on.','free'),
88
('AutoSwitchPatron', '0', '', 'Auto switch to patron', 'YesNo'),
88
('AutoSwitchPatron', '0', '', 'Auto switch to patron', 'YesNo'),
89
('AzureADOpenIDConnect', '0', NULL, 'if ON, allows the use of AzureAD OpenID Connect for login', 'YesNo'),
90
('AzureADOpenIDConnectAutoRegister', '0', NULL, 'If ON autoprovisions new users coming from AzureAD', 'YesNo'),
91
('AzureADOAuth2TenantID', '', NULL, 'Tenant ID for the web app registered with AzureAD', 'Free'),
92
('AzureADOAuth2ClientID', '', NULL, 'Client ID for the web app registered with AzureAD', 'Free'),
93
('AzureADOAuth2ClientSecret', '', NULL, 'Client Secret for the web app registered with AzureAD', 'Free'),
94
('AzureADOpenIDConnectAutoRegister', '0', NULL, 'if ON autoprovisions users arriving from AzureAD', 'YesNo'),
95
('AzureADOpenIDConnectDefaultCategory', '', '', 'This category code will be used to create Google OpenID Connect patrons.', 'Textarea'),
96
('AzureADOpenIDConnectDefaultBranch', '', '', 'This branch code will be used to create Google OpenID Connect patrons.', 'Textarea'),
89
('Babeltheque','0','','Turn ON Babeltheque content  - See babeltheque.com to subscribe to this service','YesNo'),
97
('Babeltheque','0','','Turn ON Babeltheque content  - See babeltheque.com to subscribe to this service','YesNo'),
90
('Babeltheque_url_js','','','Url for Babeltheque javascript (e.g. http://www.babeltheque.com/bw_XX.js)','Free'),
98
('Babeltheque_url_js','','','Url for Babeltheque javascript (e.g. http://www.babeltheque.com/bw_XX.js)','Free'),
91
('Babeltheque_url_update','','','Url for Babeltheque update (E.G. http://www.babeltheque.com/.../file.csv.bz2)','Free'),
99
('Babeltheque_url_update','','','Url for Babeltheque update (E.G. http://www.babeltheque.com/.../file.csv.bz2)','Free'),
(-)a/installer/data/mysql/updatedatabase.pl (+17 lines)
Lines 24274-24279 if ( CheckVersion($DBversion) ) { Link Here
24274
    NewVersion( $DBversion, 28108, "Add new systempreference OpacHiddenItemsHidesRecord" );
24274
    NewVersion( $DBversion, 28108, "Add new systempreference OpacHiddenItemsHidesRecord" );
24275
}
24275
}
24276
24276
24277
$DBversion = '20.12.00.051';
24278
if ( C4::Context->preference("Version") < TransformToNum($DBversion) ) {
24279
    $dbh->do(q{
24280
            INSERT IGNORE INTO systempreferences (variable,value,explanation,options,type) VALUES
24281
            ('AzureADOpenIDConnect', '0', NULL, 'if ON, allows the use of AzureAD OpenID Connect for login', 'YesNo'),
24282
            ('AzureADOAuth2TenantID', '', NULL, 'Tenant ID for the web app registered with AzureAD', 'Free'),
24283
            ('AzureADOAuth2ClientID', '', NULL, 'Client ID for the web app registered with AzureAD', 'Free'),
24284
            ('AzureADOpenIDConnectDefaultCategory', '', '', 'This category code will be used to create Google OpenID Connect patrons.', 'Textarea'),
24285
            ('AzureADOpenIDConnectDefaultBranch', '', '', 'This branch code will be used to create Google OpenID Connect patrons.', 'Textarea'),
24286
            ('AzureADOpenIDConnectAutoRegister', '0', NULL, 'if ON autoprovisions users arriving from AzureAD', 'YesNo'),
24287
            ('AzureADOAuth2ClientSecret', '', NULL, 'Client Secret for the web app registered with AzureAD', 'Free');
24288
            });
24289
24290
    print "Upgrade to $DBversion done (Bug 28420 - Allow login via AzureAD OAuth2 (OpenID-Connect))\n";
24291
    SetVersion($DBversion);
24292
}
24293
24277
# SEE bug 13068
24294
# SEE bug 13068
24278
# if there is anything in the atomicupdate, read and execute it.
24295
# if there is anything in the atomicupdate, read and execute it.
24279
my $update_dir = C4::Context->config('intranetdir') . '/installer/data/mysql/atomicupdate/';
24296
my $update_dir = C4::Context->config('intranetdir') . '/installer/data/mysql/atomicupdate/';
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/admin.pref (+33 lines)
Lines 196-201 Administration: Link Here
196
            - "Use this branch code when automatically registering a Google Open ID patron: "
196
            - "Use this branch code when automatically registering a Google Open ID patron: "
197
            - pref: GoogleOpenIDConnectDefaultBranch
197
            - pref: GoogleOpenIDConnectDefaultBranch
198
            - "."
198
            - "."
199
    AzureAD OpenID Connect:
200
        -
201
            - "Use AzureAD OpenID-Connect login: "
202
            - pref: AzureADOpenIDConnect
203
              choices:
204
                1: "Yes"
205
                0: "No"
206
            - You will need to create a new app in https://portal.azure.com and a new secret for this integration
207
        -
208
            - ""
209
            - pref: AzureADOpenIDConnectAutoRegister
210
              choices:
211
                1: "Allow"
212
                0: "Don't allow"
213
            - Patrons logging in with AzureAD OpenID to automatically register
214
        -
215
            - "AzureAD Tenant ID: "
216
            - pref: AzureADOAuth2TenantID
217
        -
218
            - "AzureAD Client ID: "
219
            - pref: AzureADOAuth2ClientID
220
        -
221
            - "AzureAD Client Secret: "
222
            - pref: AzureADOAuth2ClientSecret
223
        -
224
            - "Use this category code when automatically registering a AzureAD OpenID patron: "
225
            - pref: AzureADOpenIDConnectDefaultCategory
226
              choices: patron-categories
227
            - "."
228
        -
229
            - "Use this branch code when automatically registering a AzureAD OpenID patron: "
230
            - pref: AzureADOpenIDConnectDefaultBranch
231
            - "."
199
    Share anonymous usage statistics:
232
    Share anonymous usage statistics:
200
        -
233
        -
201
            - "Share anonymous Koha usage data with the Koha community: "
234
            - "Share anonymous Koha usage data with the Koha community: "
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc (+2 lines)
Lines 99-104 Link Here
99
                                        <a class="nav-link login-link" href="/cgi-bin/koha/opac-user.pl"><i class="fa fa-user fa-icon-black fa-fw" aria-hidden="true"></i> <span class="userlabel">Log in to your account</span></a>
99
                                        <a class="nav-link login-link" href="/cgi-bin/koha/opac-user.pl"><i class="fa fa-user fa-icon-black fa-fw" aria-hidden="true"></i> <span class="userlabel">Log in to your account</span></a>
100
                                [% ELSIF ( Koha.Preference('GoogleOpenIDConnect') == 1 ) %]
100
                                [% ELSIF ( Koha.Preference('GoogleOpenIDConnect') == 1 ) %]
101
                                    <a class="nav-link login-link" href="/cgi-bin/koha/svc/auth/googleopenidconnect" role="button" data-toggle="modal"><i class="fa fa-user fa-icon-black fa-fw" aria-hidden="true"></i> <span class="userlabel">Log in to your account</span></a>
101
                                    <a class="nav-link login-link" href="/cgi-bin/koha/svc/auth/googleopenidconnect" role="button" data-toggle="modal"><i class="fa fa-user fa-icon-black fa-fw" aria-hidden="true"></i> <span class="userlabel">Log in to your account</span></a>
102
                                [% ELSIF ( Koha.Preference('AzureADOpenIDConnect') == 1 ) %]
103
                                    <a class="nav-link login-link" href="/cgi-bin/koha/svc/auth/azureadopenidconnect" role="button" data-toggle="modal"><i class="fa fa-user fa-icon-black fa-fw" aria-hidden="true"></i> <span class="userlabel">Log in to your account</span></a>
102
                                [% ELSE %]
104
                                [% ELSE %]
103
                                    <a href="/cgi-bin/koha/opac-user.pl" class="nav-link login-link loginModal-trigger"><i class="fa fa-user fa-icon-black fa-fw" aria-hidden="true"></i> <span class="userlabel">Log in to your account</span></a>
105
                                    <a href="/cgi-bin/koha/opac-user.pl" class="nav-link login-link loginModal-trigger"><i class="fa fa-user fa-icon-black fa-fw" aria-hidden="true"></i> <span class="userlabel">Log in to your account</span></a>
104
                                [% END %]
106
                                [% END %]
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt (+12 lines)
Lines 165-170 Link Here
165
                                <a href="/cgi-bin/koha/svc/auth/googleopenidconnect" class="btn btn-primary" id="openid_connect">Log in with Google</a>
165
                                <a href="/cgi-bin/koha/svc/auth/googleopenidconnect" class="btn btn-primary" id="openid_connect">Log in with Google</a>
166
                                <p>If you do not have a Google account, but do have a local account, you can still log in: </p>
166
                                <p>If you do not have a Google account, but do have a local account, you can still log in: </p>
167
                            [% END # /IF GoogleOpenIDConnect %]
167
                            [% END # /IF GoogleOpenIDConnect %]
168
169
                            [% IF ( Koha.Preference('AzureADOpenIDConnect') == 1 ) %]
170
                                [% IF ( invalidAzureADOpenIDConnectLogin ) %]
171
                                    <h2>AzureAD login</h2>
172
                                    <p>Sorry, your AzureAD login failed. <span class="error">[% invalidAzureADOpenIDConnectLogin | html %]</span></p>
173
                                    <p>Please note that the AzureAD login will only work if you are using the e-mail address registered with this library.</p>
174
                                    <p>If you want to, you can try to <a href="/cgi-bin/koha/svc/auth/AzureADopenidconnect?reauthenticate=select_account">log in using a different account</a>
175
                                [% END %]
176
                                <a href="/cgi-bin/koha/svc/auth/AzureADopenidconnect" class="btn btn-primary" id="openid_connect">Log in with AzureAD</a>
177
                                <p>If you do not have a Microsoft account, but do have a local account, you can still log in: </p>
178
                            [% END # /IF AzureADOpenIDConnect %]
179
168
                        [% END # /UNLESS OPACShibOnly %]
180
                        [% END # /UNLESS OPACShibOnly %]
169
181
170
                        [% IF !Koha.Preference('OPACShibOnly') or SCO_login or SCI_login %]
182
                        [% IF !Koha.Preference('OPACShibOnly') or SCO_login or SCI_login %]
(-)a/opac/svc/auth/azureadopenidconnect (+272 lines)
Line 0 Link Here
1
#!/usr/bin/perl
2
# Copyright mark.jaroski@gmail.com, jaroskim@who.int 2021
3
# based on the google Oauth code from vanoudt@gmail.com 2014
4
#
5
# This file is part of Koha.
6
#
7
# Koha is free software; you can redistribute it and/or modify it
8
# under the terms of the GNU General Public License as published by
9
# the Free Software Foundation; either version 3 of the License, or
10
# (at your option) any later version.
11
#
12
# Koha is distributed in the hope that it will be useful, but
13
# WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU General Public License for more details.
16
#
17
# You should have received a copy of the GNU General Public License
18
# along with Koha; if not, see <http://www.gnu.org/licenses>.
19
#
20
#
21
# Basic OAuth2/OpenID Connect authentication for AzureAD goes like this
22
#
23
# The first thing that happens when this script is called is
24
# that one gets redirected to an authentication url from AzureAD
25
#
26
# If successful, that then redirects back to this script, setting
27
# a CODE parameter which we use to look up a json authentication
28
# token. This token includes an encrypted json id_token, which we
29
# round-trip back to AzureAD to decrypt. Finally, we can extract
30
# the email address from this.
31
#
32
# NOTE: This is not in fact the OpenID-Connect workflow, but a different
33
# workflow workflow called the Code Grant flow.
34
35
use Modern::Perl;
36
use CGI qw ( -utf8 escape );
37
use C4::Auth qw{ checkauth get_session get_template_and_user };
38
use C4::Context;
39
use C4::Output;
40
use Koha::Patrons;
41
42
use LWP::UserAgent;
43
use HTTP::Request::Common qw{ POST };
44
use JSON;
45
use MIME::Base64 qw{ decode_base64url };
46
47
use Data::Dumper; # FIXME
48
49
my $tenantID = C4::Context->preference('AzureADOAuth2TenantID');
50
my $authority= "https://login.microsoftonline.com/${tenantID}/v2.0";
51
my $discoveryDocURL = "${authority}/.well-known/openid-configuration";
52
warn $discoveryDocURL;
53
my $authendpoint     = '';
54
my $tokenendpoint    = '';
55
my $scope            = 'openid email profile';
56
my $host             = C4::Context->preference('OPACBaseURL') // q{};
57
# TODO either implement or delete $restricttodomain
58
# normally handled in AzureAD with user/group assignment
59
my $restricttodomain = "";
60
61
# protocol is assumed in OPACBaseURL see bug 5010.
62
my $redirecturl  = $host . '/cgi-bin/koha/svc/auth/azureadopenidconnect';
63
my $clientid     = C4::Context->preference('AzureADOAuth2ClientID');
64
my $clientsecret = C4::Context->preference('AzureADOAuth2ClientSecret');
65
66
my $ua       = LWP::UserAgent->new();
67
my $response = $ua->get($discoveryDocURL);
68
if ( $response->is_success ) {
69
    my $json = decode_json( $response->decoded_content );
70
    if ( exists( $json->{'authorization_endpoint'} ) ) {
71
        $authendpoint = $json->{'authorization_endpoint'};
72
    }
73
    if ( exists( $json->{'token_endpoint'} ) ) {
74
        $tokenendpoint = $json->{'token_endpoint'};
75
    }
76
}
77
78
my $query = CGI->new;
79
80
sub loginfailed {
81
    my $cgi_query = shift;
82
    my $reason    = shift;
83
    $cgi_query->delete('code');
84
    $cgi_query->param( 'OpenIDConnectFailed' => $reason );
85
    my ( $template, $borrowernumber, $cookie ) = get_template_and_user(
86
        {
87
            template_name   => 'opac-user.tt',
88
            query           => $cgi_query,
89
            type            => 'opac',
90
        }
91
    );
92
    $template->param( 'invalidAzureOpenIDConnectLogin' => $reason );
93
    $template->param( 'loginprompt'                     => 1 );
94
    output_html_with_http_headers $cgi_query, $cookie, $template->output, undef, { force_no_caching => 1 };
95
    return;
96
}
97
98
if ( defined $query->param('error') ) {
99
    loginfailed( $query,
100
            'An authentication error occurred. (Error:'
101
          . $query->param('error')
102
          . ')' );
103
}
104
elsif ( defined $query->param('code') ) {
105
    my $stateclaim = $query->param('state');
106
    my $session    = get_session( $query->cookie('CGISESSID') );
107
    if ( $session->param('azuread-openid-state') ne $stateclaim ) {
108
        $session->clear( ["azuread-openid-state"] );
109
        $session->flush();
110
        loginfailed( $query,
111
            'Authentication failed. Your session has an unexpected state.' );
112
    }
113
    $session->clear( ["azuread-openid-state"] );
114
    $session->flush();
115
116
    my $code = $query->param('code');
117
    my $ua   = LWP::UserAgent->new();
118
    if ( $tokenendpoint eq q{} ) {
119
        loginfailed( $query, 'Unable to discover token endpoint.' );
120
    }
121
    my $request = POST(
122
        $tokenendpoint,
123
        [
124
            code          => $code,
125
            client_id     => $clientid,
126
            client_secret => $clientsecret,
127
            redirect_uri  => $redirecturl,
128
            grant_type    => 'authorization_code',
129
            $scope        => $scope
130
        ]
131
    );
132
    my $response = $ua->request($request)->decoded_content;
133
    my $json     = decode_json($response);
134
    if ( exists( $json->{'id_token'} ) ) {
135
        if ( lc( $json->{'token_type'} ) ne 'bearer' ) {
136
            loginfailed( $query,
137
                'Authentication failed. Incorrect token type.' );
138
        }
139
        my $idtoken = $json->{'id_token'};
140
141
        my @segments = split( '\.', $idtoken );
142
        unless ( scalar(@segments) == 3 ) {
143
            loginfailed( $query,
144
                'Login token broken: either too many or too few segments.' );
145
        }
146
        my ( $header, $claims, $validation ) = @segments;
147
        $claims = decode_base64url($claims);
148
        my $claims_json = decode_json($claims);
149
        if (   ( $claims_json->{'iss'} ne ( 'https://' . $authority) )
150
            && ( $claims_json->{'iss'} ne $authority) )
151
        {
152
            loginfailed( $query,
153
                "Authentication failed. Issuer of authentication : " . $claims_json->{'iss'} . " does not match ${authority}"
154
            );
155
        }
156
        if ( ref( $claims_json->{'aud'} ) eq 'ARRAY' ) {
157
            warn "Audience is an array of size: "
158
              . scalar( @$claims_json->{'aud'} );
159
            if ( scalar( @$claims_json->{'aud'} ) > 1 )
160
            {    # We don't want any other audiences
161
                loginfailed( $query,
162
                    "Authentication failed. Unexpected audience provided." );
163
            }
164
        }
165
        if ($claims_json->{'aud'} ne $clientid )
166
        {
167
            loginfailed( $query,
168
                "Authentication failed. Unexpected audience: -" . $claims_json->{'aud'} . "- : -" . $clientid . "-");
169
        }
170
        if ( $claims_json->{'exp'} < time() ) {
171
            loginfailed( $query, 'Sorry, your authentication has timed out.' );
172
        }
173
174
        if ( exists( $claims_json->{'email'} ) ) {
175
            my $email = $claims_json->{'email'};
176
            if (   ( $restricttodomain ne q{} )
177
                && ( index( $email, $restricttodomain ) < 0 ) )
178
            {
179
                loginfailed( $query,
180
'The email you have used is not valid for this library. Email addresses should conclude with '
181
                      . $restricttodomain
182
                      . ' .' );
183
            }
184
            else {
185
                my $error_feedback =
186
'The email address you are trying to use is not associated with a borrower at this library.';
187
                $error_feedback = Dumper($claims_json); # FIXME
188
                my $auto_registration = C4::Context->preference('AzureADOpenIDConnectAutoRegister') // q{0};
189
                my $borrower = Koha::Patrons->find( { email => $email } );
190
                if (! $borrower && $auto_registration==1) {
191
                    my $firstname = $claims_json->{'given_name'} // q{};
192
                    my $surname = $claims_json->{'family_name'} // q{};
193
                    my $delimiter = $firstname ? q{.} : q{};
194
                    my $userid = $firstname . $delimiter . $surname;
195
                    my $categorycode = C4::Context->preference('AzureADOpenIDConnectDefaultCategory') // q{};
196
                    my $patron_category = Koha::Patron::Categories->find( $categorycode );
197
                    my $branchcode = C4::Context->preference('AzureADOpenIDConnectDefaultBranch') // q{};
198
                    my $library = Koha::Libraries->find( $branchcode );
199
                    if (defined $patron_category && defined $library) {
200
                        my $password = undef;
201
                        # TODO errors handling!
202
                        my $borrower = Koha::Patron->new({
203
                            firstname    => $firstname,
204
                            surname      => $surname,
205
                            email        => $email,
206
                            categorycode => $categorycode,
207
                            branchcode   => $branchcode,
208
                            userid       => $userid,
209
                            password     => $password
210
                        })->store;
211
                    } else {
212
                        $error_feedback = 'The AzureADOpenIDConnectDefaultBranch or AzureADOpenIDConnectDefaultCategory system preferences are not configured properly. Please contact the library with this error message.';
213
                    }
214
                }
215
                my ( $userid, $cookie, $session_id ) =
216
                  checkauth( $query, 1, {}, 'opac', $email );
217
                if ($userid) {    # A user with this email is registered in koha
218
219
                    #handle redirect to main.pl, for private opac
220
                    my $uri;
221
                    if (C4::Context->preference('OpacPublic') ) {
222
                        $uri    =  '/cgi-bin/koha/opac-user.pl';
223
                    } else {
224
                        $uri    =  '/cgi-bin/koha/opac-main.pl';
225
                    }
226
                    print $query->redirect(
227
                        -uri    => $uri,
228
                        -cookie => $cookie
229
                    );
230
                }
231
                else {
232
                    loginfailed( $query, $error_feedback );
233
                }
234
            }
235
        }
236
        else {
237
            loginfailed( $query,
238
'Unexpectedly, no email seems to be associated with that acccount.'
239
            );
240
        }
241
    }
242
    else {
243
        loginfailed( $query, 'Failed to get proper credentials from AzureAD.' );
244
    }
245
}
246
else {
247
    my $session     = get_session( $query->cookie('CGISESSID') );
248
    my $openidstate = 'auth_';
249
    $openidstate .= sprintf( "%x", rand 16 ) for 1 .. 32;
250
    $session->param( 'azuread-openid-state', $openidstate );
251
    $session->flush();
252
253
    my $prompt = $query->param('reauthenticate') // q{};
254
    if ( $authendpoint eq q{} ) {
255
        loginfailed( $query, 'Unable to discover authorisation endpoint.' );
256
    }
257
    my $authorisationurl =
258
        $authendpoint . '?'
259
      . 'response_type=code&'
260
      . 'redirect_uri='
261
      . escape($redirecturl) . q{&}
262
      . 'client_id='
263
      . escape($clientid) . q{&}
264
      . 'scope='
265
      . escape($scope) . q{&}
266
      . 'state='
267
      . escape($openidstate);
268
    if ( $prompt || ( defined $prompt && length $prompt > 0 ) ) {
269
        $authorisationurl .= '&prompt=' . escape($prompt);
270
    }
271
    print $query->redirect($authorisationurl);
272
}

Return to bug 28420