Bug 10988 - Fixes for comments 57 and 58
[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 # First:
23 # get your clientid, clientsecret from google. At this stage, tell
24 # google that your redirect url is /cgi-bin/koha/svc/oauthlogin
25 #
26 # The first thing that happens when this script is called is
27 # that one gets redirected to an authentication url from google
28 #
29 # If successful, that then redirects back to this script, setting
30 # a CODE parameter which we use to look up a json authentication
31 # token. This token includes an encrypted json id_token, which we
32 # round-trip back to google to decrypt. Finally, we can extract
33 # the email address from this.
34 #
35 # There is some room for improvement here.  In particular, Google
36 # recommends verifying and decrypting the id_token locally, which
37 # means caching some information and updating it daily. But that
38 # would make things a lot faster
39
40 use Modern::Perl;
41 use CGI qw ( -utf8 escape );
42 use C4::Auth qw{ checkauth get_session get_template_and_user };
43 use C4::Context;
44 use C4::Output;
45
46 use LWP::UserAgent;
47 use HTTP::Request::Common qw{ POST };
48 use JSON;
49 use MIME::Base64 qw{ decode_base64url };
50
51 my $discoveryDocURL =
52   'https://accounts.google.com/.well-known/openid-configuration';
53 my $authendpoint     = '';
54 my $tokenendpoint    = '';
55 my $scope            = 'openid email profile';
56 my $host             = C4::Context->preference('OPACBaseURL') // q{};
57 my $restricttodomain = C4::Context->preference('GoogleOpenIDConnectDomain')
58   // q{};
59
60 # protocol is assumed in OPACBaseURL see bug 5010.
61 my $redirecturl  = $host . '/cgi-bin/koha/svc/auth/googleopenidconnect';
62 my $issuer       = 'accounts.google.com';
63 my $clientid     = C4::Context->preference('GoogleOAuth2ClientID');
64 my $clientsecret = C4::Context->preference('GoogleOAuth2ClientSecret');
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             authnotrequired => 0,
91         }
92     );
93     $template->param( 'invalidGoogleOpenIDConnectLogin' => $reason );
94     $template->param( 'loginprompt'                     => 1 );
95     output_html_with_http_headers $cgi_query, $cookie, $template->output;
96     return;
97 }
98
99 if ( defined $query->param('error') ) {
100     loginfailed( $query,
101             'An authentication error occurred. (Error:'
102           . $query->param('error')
103           . ')' );
104 }
105 elsif ( defined $query->param('code') ) {
106     my $stateclaim = $query->param('state');
107     my $session    = get_session( $query->cookie('CGISESSID') );
108     if ( $session->param('google-openid-state') ne $stateclaim ) {
109         $session->clear( ["google-openid-state"] );
110         $session->flush();
111         loginfailed( $query,
112             'Authentication failed. Your session has an unexpected state.' );
113     }
114     $session->clear( ["google-openid-state"] );
115     $session->flush();
116
117     my $code = $query->param('code');
118     my $ua   = LWP::UserAgent->new();
119     if ( $tokenendpoint eq q{} ) {
120         loginfailed( $query, 'Unable to discover token endpoint.' );
121     }
122     my $request = POST(
123         $tokenendpoint,
124         [
125             code          => $code,
126             client_id     => $clientid,
127             client_secret => $clientsecret,
128             redirect_uri  => $redirecturl,
129             grant_type    => 'authorization_code',
130             $scope        => $scope
131         ]
132     );
133     my $response = $ua->request($request)->decoded_content;
134     my $json     = decode_json($response);
135     if ( exists( $json->{'id_token'} ) ) {
136         if ( lc( $json->{'token_type'} ) ne 'bearer' ) {
137             loginfailed( $query,
138                 'Authentication failed. Incorrect token type.' );
139         }
140         my $idtoken = $json->{'id_token'};
141
142 # Normally we'd have to validate the token - but google says not to worry here (Avoids another library!)
143 # See https://developers.google.com/identity/protocols/OpenIDConnect#obtainuserinfo for rationale
144         my @segments = split( '\.', $idtoken );
145         unless ( scalar(@segments) == 3 ) {
146             loginfailed( $query,
147                 'Login token broken: either too many or too few segments.' );
148         }
149         my ( $header, $claims, $validation ) = @segments;
150         $claims = decode_base64url($claims);
151         my $claims_json = decode_json($claims);
152         if (   ( $claims_json->{'iss'} ne ( 'https://' . $issuer ) )
153             && ( $claims_json->{'iss'} ne $issuer ) )
154         {
155             loginfailed( $query,
156                 "Authentication failed. Issuer of authentication isn't Google."
157             );
158         }
159         if ( ref( $claims_json->{'aud'} ) eq 'ARRAY' ) {
160             warn "Audience is an array of size: "
161               . scalar( @$claims_json->{'aud'} );
162             if ( scalar( @$claims_json->{'aud'} ) > 1 )
163             {    # We don't want any other audiences
164                 loginfailed( $query,
165                     "Authentication failed. Unexpected audience provided." );
166             }
167         }
168         if (   ( $claims_json->{'aud'} ne $clientid )
169             || ( $claims_json->{'azp'} ne $clientid ) )
170         {
171             loginfailed( $query,
172                 "Authentication failed. Unexpected audience." );
173         }
174         if ( $claims_json->{'exp'} < time() ) {
175             loginfailed( $query, 'Sorry, your authentication has timed out.' );
176         }
177
178         if ( exists( $claims_json->{'email'} ) ) {
179             my $email = $claims_json->{'email'};
180             if (   ( $restricttodomain ne q{} )
181                 && ( index( $email, $restricttodomain ) < 0 ) )
182             {
183                 loginfailed( $query,
184 'The email you have used is not valid for this library. Email addresses should conclude with '
185                       . $restricttodomain
186                       . ' .' );
187             }
188             else {
189                 my ( $userid, $cookie, $session_id ) =
190                   checkauth( $query, 1, {}, 'opac', $email );
191                 if ($userid) {    # A user with this email is registered in koha
192                     print $query->redirect(
193                         -uri    => '/cgi-bin/koha/opac-user.pl',
194                         -cookie => $cookie
195                     );
196                 }
197                 else {
198                     loginfailed( $query,
199 'The email address you are trying to use is not associated with a borrower at this library.'
200                     );
201                 }
202             }
203         }
204         else {
205             loginfailed( $query,
206 'Unexpectedly, no email seems to be associated with that acccount.'
207             );
208         }
209     }
210     else {
211         loginfailed( $query, 'Failed to get proper credentials from Google.' );
212     }
213 }
214 else {
215     my $session     = get_session( $query->cookie('CGISESSID') );
216     my $openidstate = 'auth_';
217     $openidstate .= sprintf( "%x", rand 16 ) for 1 .. 32;
218     $session->param( 'google-openid-state', $openidstate );
219     $session->flush();
220
221     my $prompt = $query->param('reauthenticate') // q{};
222     if ( $authendpoint eq q{} ) {
223         loginfailed( $query, 'Unable to discover authorisation endpoint.' );
224     }
225     my $authorisationurl =
226         $authendpoint . '?'
227       . 'response_type=code&'
228       . 'redirect_uri='
229       . escape($redirecturl) . q{&}
230       . 'client_id='
231       . escape($clientid) . q{&}
232       . 'scope='
233       . escape($scope) . q{&}
234       . 'state='
235       . escape($openidstate);
236     if ( $prompt || ( defined $prompt && length $prompt > 0 ) ) {
237         $authorisationurl .= '&prompt=' . escape($prompt);
238     }
239     print $query->redirect($authorisationurl);
240 }