e3d7df434a0daa8d3cf21c8eeb1b89264099fd41
[koha.git] / opac / svc / auth / googleopenidconnect
1 #!/usr/bin/perl
2 # Copyright vanoudt@gmail.com 2014
3 # Based on persona code from chris@bigballofwax.co.nz 2013
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 google 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 google
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 google to decrypt. Finally, we can extract
30 # the email address from this.
31 #
32
33 use Modern::Perl;
34 use CGI qw ( -utf8 escape );
35 use C4::Auth qw{ checkauth get_session get_template_and_user };
36 use C4::Context;
37 use C4::Members;
38 use C4::Output;
39 use Koha::Patrons;
40
41 use LWP::UserAgent;
42 use HTTP::Request::Common qw{ POST };
43 use JSON;
44 use MIME::Base64 qw{ decode_base64url };
45
46 my $discoveryDocURL =
47   'https://accounts.google.com/.well-known/openid-configuration';
48 my $authendpoint     = '';
49 my $tokenendpoint    = '';
50 my $scope            = 'openid email profile';
51 my $host             = C4::Context->preference('OPACBaseURL') // q{};
52 my $restricttodomain = C4::Context->preference('GoogleOpenIDConnectDomain')
53   // q{};
54
55 # protocol is assumed in OPACBaseURL see bug 5010.
56 my $redirecturl  = $host . '/cgi-bin/koha/svc/auth/googleopenidconnect';
57 my $issuer       = 'accounts.google.com';
58 my $clientid     = C4::Context->preference('GoogleOAuth2ClientID');
59 my $clientsecret = C4::Context->preference('GoogleOAuth2ClientSecret');
60
61 my $ua       = LWP::UserAgent->new();
62 my $response = $ua->get($discoveryDocURL);
63 if ( $response->is_success ) {
64     my $json = decode_json( $response->decoded_content );
65     if ( exists( $json->{'authorization_endpoint'} ) ) {
66         $authendpoint = $json->{'authorization_endpoint'};
67     }
68     if ( exists( $json->{'token_endpoint'} ) ) {
69         $tokenendpoint = $json->{'token_endpoint'};
70     }
71 }
72
73 my $query = CGI->new;
74
75 sub loginfailed {
76     my $cgi_query = shift;
77     my $reason    = shift;
78     $cgi_query->delete('code');
79     $cgi_query->param( 'OpenIDConnectFailed' => $reason );
80     my ( $template, $borrowernumber, $cookie ) = get_template_and_user(
81         {
82             template_name   => 'opac-user.tt',
83             query           => $cgi_query,
84             type            => 'opac',
85             authnotrequired => 0,
86         }
87     );
88     $template->param( 'invalidGoogleOpenIDConnectLogin' => $reason );
89     $template->param( 'loginprompt'                     => 1 );
90     output_html_with_http_headers $cgi_query, $cookie, $template->output;
91     return;
92 }
93
94 if ( defined $query->param('error') ) {
95     loginfailed( $query,
96             'An authentication error occurred. (Error:'
97           . $query->param('error')
98           . ')' );
99 }
100 elsif ( defined $query->param('code') ) {
101     my $stateclaim = $query->param('state');
102     my $session    = get_session( $query->cookie('CGISESSID') );
103     if ( $session->param('google-openid-state') ne $stateclaim ) {
104         $session->clear( ["google-openid-state"] );
105         $session->flush();
106         loginfailed( $query,
107             'Authentication failed. Your session has an unexpected state.' );
108     }
109     $session->clear( ["google-openid-state"] );
110     $session->flush();
111
112     my $code = $query->param('code');
113     my $ua   = LWP::UserAgent->new();
114     if ( $tokenendpoint eq q{} ) {
115         loginfailed( $query, 'Unable to discover token endpoint.' );
116     }
117     my $request = POST(
118         $tokenendpoint,
119         [
120             code          => $code,
121             client_id     => $clientid,
122             client_secret => $clientsecret,
123             redirect_uri  => $redirecturl,
124             grant_type    => 'authorization_code',
125             $scope        => $scope
126         ]
127     );
128     my $response = $ua->request($request)->decoded_content;
129     my $json     = decode_json($response);
130     if ( exists( $json->{'id_token'} ) ) {
131         if ( lc( $json->{'token_type'} ) ne 'bearer' ) {
132             loginfailed( $query,
133                 'Authentication failed. Incorrect token type.' );
134         }
135         my $idtoken = $json->{'id_token'};
136
137 # Normally we'd have to validate the token - but google says not to worry here (Avoids another library!)
138 # See https://developers.google.com/identity/protocols/OpenIDConnect#obtainuserinfo for rationale
139         my @segments = split( '\.', $idtoken );
140         unless ( scalar(@segments) == 3 ) {
141             loginfailed( $query,
142                 'Login token broken: either too many or too few segments.' );
143         }
144         my ( $header, $claims, $validation ) = @segments;
145         $claims = decode_base64url($claims);
146         my $claims_json = decode_json($claims);
147         if (   ( $claims_json->{'iss'} ne ( 'https://' . $issuer ) )
148             && ( $claims_json->{'iss'} ne $issuer ) )
149         {
150             loginfailed( $query,
151                 "Authentication failed. Issuer of authentication isn't Google."
152             );
153         }
154         if ( ref( $claims_json->{'aud'} ) eq 'ARRAY' ) {
155             warn "Audience is an array of size: "
156               . scalar( @$claims_json->{'aud'} );
157             if ( scalar( @$claims_json->{'aud'} ) > 1 )
158             {    # We don't want any other audiences
159                 loginfailed( $query,
160                     "Authentication failed. Unexpected audience provided." );
161             }
162         }
163         if (   ( $claims_json->{'aud'} ne $clientid )
164             || ( $claims_json->{'azp'} ne $clientid ) )
165         {
166             loginfailed( $query,
167                 "Authentication failed. Unexpected audience." );
168         }
169         if ( $claims_json->{'exp'} < time() ) {
170             loginfailed( $query, 'Sorry, your authentication has timed out.' );
171         }
172
173         if ( exists( $claims_json->{'email'} ) ) {
174             my $email = $claims_json->{'email'};
175             if (   ( $restricttodomain ne q{} )
176                 && ( index( $email, $restricttodomain ) < 0 ) )
177             {
178                 loginfailed( $query,
179 'The email you have used is not valid for this library. Email addresses should conclude with '
180                       . $restricttodomain
181                       . ' .' );
182             }
183             else {
184                 my $error_feedback =
185 'The email address you are trying to use is not associated with a borrower at this library.';
186                 my $auto_registration = C4::Context->preference('GoogleOpenIDConnectAutoRegister') // q{0};
187                 my $borrower = Koha::Patrons->find( { email => $email } );
188                 if (! $borrower && $auto_registration==1) {
189                     my $cardnumber = fixup_cardnumber();
190                     my $firstname = $claims_json->{'given_name'} // q{};
191                     my $surname = $claims_json->{'family_name'} // q{};
192                     my $delimiter = $firstname ? q{.} : q{};
193                     my $userid = $firstname . $delimiter . $surname;
194                     my $categorycode = C4::Context->preference('GoogleOpenIDConnectDefaultCategory') // q{};
195                     my $patron_category = Koha::Patron::Categories->find( $categorycode );
196                     my $branchcode = C4::Context->preference('GoogleOpenIDConnectDefaultBranch') // q{};
197                     my $library = Koha::Libraries->find( $branchcode );
198                     if (defined $patron_category && defined $library) {
199                         my $password = undef;
200                         my $borrowernumber = C4::Members::AddMember(
201                             cardnumber   => $cardnumber,
202                             firstname    => $firstname,
203                             surname      => $surname,
204                             email        => $email,
205                             categorycode => $categorycode,
206                             branchcode   => $branchcode,
207                             userid       => $userid,
208                             password     => $password
209                         );
210                         $borrower = Koha::Patrons->find( {
211                             borrowernumber => $borrowernumber } );
212                     } else {
213                         $error_feedback = 'The GoogleOpenIDConnectDefaultBranch or GoogleOpenIDConnectDefaultCategory system preferences are not configured properly. Please contact the library with this error message.';
214                     }
215                 }
216                 my ( $userid, $cookie, $session_id ) =
217                   checkauth( $query, 1, {}, 'opac', $email );
218                 if ($userid) {    # A user with this email is registered in koha
219
220                     #handle redirect to main.pl, for private opac
221                     my $uri;
222                     if (C4::Context->preference('OpacPublic') ) {
223                         $uri    =  '/cgi-bin/koha/opac-user.pl';
224                     } else {
225                         $uri    =  '/cgi-bin/koha/opac-main.pl';
226                     }
227                     print $query->redirect(
228                         -uri    => $uri,
229                         -cookie => $cookie
230                     );
231                 }
232                 else {
233                     loginfailed( $query, $error_feedback );
234                 }
235             }
236         }
237         else {
238             loginfailed( $query,
239 'Unexpectedly, no email seems to be associated with that acccount.'
240             );
241         }
242     }
243     else {
244         loginfailed( $query, 'Failed to get proper credentials from Google.' );
245     }
246 }
247 else {
248     my $session     = get_session( $query->cookie('CGISESSID') );
249     my $openidstate = 'auth_';
250     $openidstate .= sprintf( "%x", rand 16 ) for 1 .. 32;
251     $session->param( 'google-openid-state', $openidstate );
252     $session->flush();
253
254     my $prompt = $query->param('reauthenticate') // q{};
255     if ( $authendpoint eq q{} ) {
256         loginfailed( $query, 'Unable to discover authorisation endpoint.' );
257     }
258     my $authorisationurl =
259         $authendpoint . '?'
260       . 'response_type=code&'
261       . 'redirect_uri='
262       . escape($redirecturl) . q{&}
263       . 'client_id='
264       . escape($clientid) . q{&}
265       . 'scope='
266       . escape($scope) . q{&}
267       . 'state='
268       . escape($openidstate);
269     if ( $prompt || ( defined $prompt && length $prompt > 0 ) ) {
270         $authorisationurl .= '&prompt=' . escape($prompt);
271     }
272     print $query->redirect($authorisationurl);
273 }