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

(-)a/C4/Auth.pm (-1 / +43 lines)
Lines 788-793 sub checkauth { Link Here
788
    my $flagsrequired   = shift;
788
    my $flagsrequired   = shift;
789
    my $type            = shift;
789
    my $type            = shift;
790
    my $emailaddress    = shift;
790
    my $emailaddress    = shift;
791
792
    #Add OpenID Connect 1.0 external authentication
793
    my $external_authen = shift // {};
794
791
    $type = 'opac' unless $type;
795
    $type = 'opac' unless $type;
792
796
793
    my $dbh     = C4::Context->dbh;
797
    my $dbh     = C4::Context->dbh;
Lines 861-866 sub checkauth { Link Here
861
        if ( ( $query->param('koha_login_context') && ( $q_userid ne $s_userid ) )
865
        if ( ( $query->param('koha_login_context') && ( $q_userid ne $s_userid ) )
862
            || ( $cas && $query->param('ticket') && !C4::Context->userenv->{'id'} )
866
            || ( $cas && $query->param('ticket') && !C4::Context->userenv->{'id'} )
863
            || ( $shib && $shib_login && !$logout && !C4::Context->userenv->{'id'} )
867
            || ( $shib && $shib_login && !$logout && !C4::Context->userenv->{'id'} )
868
			|| ( %$external_authen ) #Add OpenID Connect 1.0 external authentication
864
        ) {
869
        ) {
865
870
866
            #if a user enters an id ne to the id in the current session, we need to log them in...
871
            #if a user enters an id ne to the id in the current session, we need to log them in...
Lines 874-879 sub checkauth { Link Here
874
            $userid    = undef;
879
            $userid    = undef;
875
        }
880
        }
876
        elsif ($logout) {
881
        elsif ($logout) {
882
			#OIDC
883
            my $oidc_flag = $session->param("oidc_pid");
884
            #/OIDC
877
885
878
            # voluntary logout the user
886
            # voluntary logout the user
879
            # check wether the user was using their shibboleth session or a local one
887
            # check wether the user was using their shibboleth session or a local one
Lines 886-891 sub checkauth { Link Here
886
            $sessionID = undef;
894
            $sessionID = undef;
887
            $userid    = undef;
895
            $userid    = undef;
888
896
897
			#OIDC
898
            if ($oidc_flag){
899
                my $oidc_context = new C4::Context;
900
                if (my $providers = $oidc_context->{prosentient}->{OpenIdConnect}->{provider}){
901
                    my $oidc;
902
                    #FIXME: This should probably be done by a function here and in login_openidc...
903
                    if ( ($providers->{id}) && ($providers->{id} eq $oidc_flag) ){
904
                        $oidc = $providers;
905
                    } elsif ( $oidc_context->{prosentient}->{OpenIdConnect}->{provider}->{$oidc_flag} ) {
906
                        $oidc = $oidc_context->{prosentient}->{OpenIdConnect}->{provider}->{$oidc_flag};
907
                    }
908
                    if ($oidc){
909
                        my $logout_url = $oidc->{logout_url};
910
                        my $redirect_url = $oidc->{redirect_url};
911
                        if ($logout_url && $redirect_url){
912
                            my $escaped_url = uri_escape($redirect_url);
913
                            my $complete_logout_url = $logout_url."?post_logout_redirect_uri=$escaped_url";
914
                            print $query->redirect(-uri => $complete_logout_url);
915
                            safe_exit;
916
                        }
917
                    }
918
                }
919
            }
920
            #/OIDC
921
889
            if ($cas and $caslogout) {
922
            if ($cas and $caslogout) {
890
                logout_cas($query, $type);
923
                logout_cas($query, $type);
891
            }
924
            }
Lines 965-970 sub checkauth { Link Here
965
        }
998
        }
966
        if ( ( $cas && $query->param('ticket') )
999
        if ( ( $cas && $query->param('ticket') )
967
            || $q_userid
1000
            || $q_userid
1001
			|| %$external_authen #Add OpenID Connect 1.0 external authentication
968
            || ( $shib && $shib_login )
1002
            || ( $shib && $shib_login )
969
            || $pki_field ne 'None'
1003
            || $pki_field ne 'None'
970
            || $emailaddress )
1004
            || $emailaddress )
Lines 994-999 sub checkauth { Link Here
994
                    $info{'invalidCasLogin'} = 1 unless ($return);
1028
                    $info{'invalidCasLogin'} = 1 unless ($return);
995
                }
1029
                }
996
1030
1031
				#Add OpenID Connect 1.0 external authentication
1032
                elsif ( %$external_authen ){
1033
                    if (my $openidcc_userinfo = $external_authen->{"OpenIDConnect1.0"}){
1034
                    $return = $openidcc_userinfo->{return} if $openidcc_userinfo->{return};
1035
                    $userid = $openidcc_userinfo->{koha_identifier} if $openidcc_userinfo->{koha_identifier};
1036
                    $info{'OpenIDConnect1'} = $openidcc_userinfo->{errors} if $openidcc_userinfo->{errors};
1037
                    }
1038
                }
997
                elsif ( $emailaddress ) {
1039
                elsif ( $emailaddress ) {
998
                    my $value = $emailaddress;
1040
                    my $value = $emailaddress;
999
1041
Lines 1747-1753 sub _get_session_params { Link Here
1747
    }
1789
    }
1748
    else {
1790
    else {
1749
        # catch all defaults to tmp should work on all systems
1791
        # catch all defaults to tmp should work on all systems
1750
        my $dir = C4::Context::temporary_directory;
1792
        my $dir = C4::Context::temporary_directory();
1751
        my $instance = C4::Context->config( 'database' ); #actually for packages not exactly the instance name, but generally safer to leave it as it is
1793
        my $instance = C4::Context->config( 'database' ); #actually for packages not exactly the instance name, but generally safer to leave it as it is
1752
        return { dsn => "driver:File;serializer:yaml;id:md5", dsn_args => { Directory => "$dir/cgisess_$instance" } };
1794
        return { dsn => "driver:File;serializer:yaml;id:md5", dsn_args => { Directory => "$dir/cgisess_$instance" } };
1753
    }
1795
    }
(-)a/Koha/Prosentient/Auth/OpenIDConnect.pm (+378 lines)
Line 0 Link Here
1
package Koha::Prosentient::Auth::OpenIDConnect;
2
3
# This file is part of Koha.
4
#
5
# Copyright Prosentient Systems 2014
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
use Modern::Perl;
21
use LWP::UserAgent;
22
use HTTP::Request::Common; #Used for HTTP Basic Authorization
23
use MIME::Base64 qw( encode_base64url decode_base64url encode_base64 );
24
use JSON qw( decode_json );
25
use Data::Dumper;
26
27
use base qw(Class::Accessor);
28
__PACKAGE__->mk_accessors(qw( debugging auth_url token_url userinfo_url response_type scope client_id client_secret client_authentication_method redirect_uri issuer http_username http_password bearer_access_token_method));
29
30
=head1 NAME
31
32
Koha::Prosentient::Auth::OpenIDConnect - OpenID Connect 1.0 Client
33
34
=head1 SYNOPSIS
35
36
    use Koha::Prosentient::Auth::OpenIDConnect;
37
    my $OpenIDC = Koha::Prosentient::Auth::OpenIDConnect->new(({
38
        auth_url => $auth_url,
39
        token_url => $token_url,
40
        userinfo_url => $userinfo_url,
41
        response_type => $response_type,
42
        scope => $scope,
43
        client_id => $client_id,
44
        redirect_uri => $redirect_uri,
45
    });
46
47
=head1 DESCRIPTION
48
49
This class creates OpenID Connect 1.0 authentication requests,
50
token requests, and fetches user info.
51
52
=cut
53
54
sub new {
55
     my ($class, $args) = @_;
56
     $args = {} unless defined $args;
57
     return bless ($args, $class);
58
}
59
60
=head2 ValidateToken
61
62
    my $token_response_valid = $OpenIDC->ValidateToken({
63
        token => $token,
64
        token_type => $token_type,
65
    });
66
67
    Check the validity of an id_token in token response according to OpenID Connect
68
    protocol criteria.
69
70
=cut
71
72
sub ValidateToken {
73
    my ($self, $args) = @_;
74
    my $token = $args->{token};
75
    my $token_type = $args->{token_type};
76
    my $valid = 0;
77
78
    if ($token){
79
        $valid = 1;
80
81
        #Validate: Check token type (3.1.3.3.  Successful Token Response)
82
        #NOTE: The OAuth 2.0 token_type response parameter value MUST be Bearer, as specified in OAuth 2.0 Bearer Token Usage [RFC6750], unless another Token Type has been negotiated with the Client.
83
        if ($token_type){
84
            #According to RFC 6749 Section 5.1, the "token_type" value is case insensitive.
85
            if ($token_type !~ /^Bearer$/i){
86
                warn "Token type doesn't equal 'Bearer'";
87
                $valid = 0;
88
            }
89
        } else {
90
            warn "No token type provided";
91
            $valid = 0;
92
        }
93
94
        #Validate: Token issuer (3.1.3.7.  ID Token Validation)
95
        #NOTE: The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) MUST exactly match the value of the iss (issuer) Claim.
96
        if ($token->{iss}){
97
            if ($self->issuer ne $token->{iss}){
98
                $valid = 0 ;
99
                warn "OIDC issuer in configuration doesn't match token.";
100
		warn "Configuration = " . $self->issuer;
101
		warn "Token = " . $token->{iss};
102
            }
103
        } else {
104
            warn "No OIDC issuer provided in token.";
105
            $valid = 0;
106
        }
107
108
        #Validate: Token audience (3.1.3.7.  ID Token Validation)
109
        #NOTE: The Client MUST validate that the aud (audience) Claim contains its client_id value registered at the Issuer identified by the iss (issuer) Claim as an audience.
110
        #NOTE: The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, or if it contains additional audiences not trusted by the Client. http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
111
        if ($token->{aud}){
112
            if (ref $token->{aud} && ref $token->{aud} eq 'ARRAY'){
113
114
                #Token invalid if the client_id isn't in the array of possible audiences
115
                $valid = 0 if ! grep { $_ eq  $self->client_id } @$token->{aud};
116
                #NOTE: The token should also be considered invalid if the array "contains additional audiences not trusted by the Client." (http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation)
117
                #However, we don't know which audiences are not trusted by the Client...
118
119
                if ($token->{azp}){
120
                    $valid = 0 if $token->{azp} ne $self->client_id;
121
                }
122
            } else {
123
                $valid = 0 if $self->client_id ne $token->{aud};
124
            }
125
        } else {
126
            warn "No OIDC aud provided in token.";
127
            $valid = 0;
128
        }
129
130
        #Validate: Token expiry time (3.1.3.7.  ID Token Validation)
131
        #NOTE: The current time MUST be before the time represented by the exp Claim.
132
        if ($token->{exp}){
133
            if (time() > $token->{exp}){
134
                $valid = 0;
135
                warn "OIDC token expired.";
136
            }
137
        } else {
138
            warn "No OIDC expiry provided in token.";
139
            $valid = 0;
140
        }
141
142
        #TODO: The iat Claim can be used to reject tokens that were issued too far away from the current time, limiting the amount of time that nonces need to be stored to prevent attacks. The acceptable range is Client specific.
143
        #NOTE: This isn't a requirement...this would probably just be an improvement.
144
145
        #NOTE: If a nonce claim was made in the authentication request, then it must be checked here. However, we haven't implemented that, so no reason to do it yet.
146
147
    } else {
148
        warn "No token available to validate.";
149
    }
150
151
    if ($self->debugging){
152
        warn "DEBUGGING -> Token Validity = $valid";
153
    }
154
    return $valid;
155
}
156
157
=head2 ParseIdToken
158
159
    my ($token,$header) = $OpenIDC->ParseIdToken({id_token => $id_token,});
160
161
162
=cut
163
164
sub ParseIdToken {
165
    my ($self, $args) = @_;
166
    my $id_token = $args->{id_token};
167
168
    my ($token,$header);
169
170
    if ($id_token){
171
        my ($jose_header, $json_web_token) = split('\.',$id_token);
172
        if ($jose_header){
173
            my $decoded_jose_header = decode_json(decode_base64url($jose_header));
174
            $header = $decoded_jose_header if $decoded_jose_header; #e.g. {"alg":"none","typ":"JWT"}
175
        }
176
        if ($json_web_token){
177
            my $decoded_json_web_token = decode_json(decode_base64url($json_web_token));
178
            $token = $decoded_json_web_token if $decoded_json_web_token; #e.g. {"iss":"http://www.URL.com.au","sub":"cccccccccccccccccc1111111111111111111","aud":null,"exp":1405348350,"iat":1405345050,"session_state":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9999999999"}
179
        }
180
    }
181
    if ($self->debugging){
182
        warn "DEBUGGING Token = ".Dumper($token);
183
        warn "DEBUGGING Token Header = ".Dumper($header);
184
    }
185
    return ($token,$header);
186
}
187
188
=head2 GetTokenResponse
189
190
    my ($token_response) = $OpenIDC->GetTokenResponse({code => $code,});
191
192
    Send a Token Request using the code retrieved via the Authentication Request
193
194
=cut
195
196
sub GetTokenResponse {
197
    my ($self, $args) = @_;
198
    my $token_response = {};
199
    if ( (my $code = $args->{code}) && ($self->token_url) && ($self->client_id) && ($self->redirect_uri) ){
200
        my $client_authentication_method = $self->client_authentication_method || "client_secret_basic";
201
202
        my $ua = LWP::UserAgent->new();
203
        #By default, LWP::UserAgent uses a string like 'libwww-perl/#.###'.
204
        #NOTE: Cloudflare doesn't like this, but it accepts an empty User-Agent string.
205
        $ua->agent("");
206
        my %headers = ();
207
        #NOTE: #When sending a request using a "grant_type" of "authorization_code" to the token endpoint, an unauthenticated client MUST send its "client_id" to prevent itself from inadvertently accepting a code intended for a client with a different "client_id".
208
        #See RFC 6749 (OAuth 2.0) http://tools.ietf.org/html/rfc6749#section-3.2.1
209
        my $form = {
210
            'grant_type' => 'authorization_code',
211
            'code' => $code,
212
            'redirect_uri' => $self->redirect_uri,
213
        };
214
215
        if ($client_authentication_method && $client_authentication_method eq "client_secret_post"){
216
            $form->{client_id} = $self->client_id;
217
            $form->{client_secret} = $self->client_secret;
218
        }
219
        else {
220
            if ($self->http_username && $self->http_password){
221
                my $credentials = encode_base64url($self->http_username.":".$self->http_password, '');
222
                if ($credentials){
223
                    $headers{'Authorization'} = "Basic $credentials";
224
                    #TODO: Not sure whether or not to delete the "client_id" hash key from $form at this point when doing HTTP Basic Authorization...
225
                    #This would probably just be an improvement rather than a fix...
226
227
                    if ($self->debugging){
228
                        warn "DEBUGGING Decoded Credentials  = ".decode_base64url($credentials);
229
                    }
230
                }
231
            }
232
        }
233
234
        my $request = POST $self->token_url, $form, %headers; #Uses HTTP::Request::Common
235
        if ($self->debugging){
236
            warn "DEBUGGING Token Request String = ".Dumper($request->as_string);
237
        }
238
        #Send Request
239
        my $response = $ua->request($request);
240
241
        #Get Successful Response Content
242
        if ($response->is_success){
243
            my $content = $response->decoded_content();
244
            if ($content){
245
                my $decoded_json = decode_json($content);
246
                if ($decoded_json){
247
                    $token_response = $decoded_json;
248
                }
249
            }
250
        }
251
        else {
252
            if ($self->debugging){
253
                warn "DEBUGGING token request was not successful";
254
                warn "DEBUGGING " . $response->status_line;
255
                warn "DEBUGGING " . $response->decoded_content();
256
            }
257
        }
258
    }
259
    if ($self->debugging){
260
        warn "DEBUGGING -> token_response = ".Dumper($token_response);
261
    }
262
    return $token_response;
263
}
264
265
sub ValidateUserInfoResponse {
266
    my ($self, $args) = @_;
267
    my $userinfo_response = $args->{userinfo_response};
268
    my $token_subject = $args->{token_subject};
269
    my $valid = 0;
270
271
    if ($userinfo_response && $token_subject){
272
        $valid = 1;
273
        #VALIDATION: 5.3.2.  Successful UserInfo Response
274
        #NOTE: Due to the possibility of token substitution attacks (see Section 16.11), the UserInfo Response is not guaranteed to be about the End-User identified by the sub (subject) element of the ID Token. The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; if they do not match, the UserInfo Response values MUST NOT be used.
275
        if ( $userinfo_response->{sub} ){
276
            if ( $userinfo_response->{sub} ne $token_subject ){
277
                warn "'sub' in userinfo does not match 'sub' in id_token";
278
                $valid = 0;
279
            }
280
        } else {
281
            warn "No sub in userinfo_response";
282
            $valid = 0;
283
        }
284
    } else {
285
        warn "No userinfo_response" if !$userinfo_response;
286
        warn "No token subject" if !$token_subject;
287
    }
288
289
    return $valid;
290
}
291
292
293
=head2 SendUserInfoRequest
294
295
    my $userinfo = $OpenIDC->SendUserInfoRequest({access_token => $access_token,});
296
297
    Retrieve userinfo from an OpenID Connect 1.0 server using an Access Token
298
299
=cut
300
301
sub SendUserInfoRequest {
302
    my ($self, $args) = @_;
303
304
    #https://tools.ietf.org/html/rfc6750#section-2
305
    my $bearer_access_token_method = $self->bearer_access_token_method || "header";
306
307
    my $userinfo;
308
    if ( (my $access_token = $args->{access_token}) && $self->userinfo_url && $bearer_access_token_method ){
309
        my $ua = LWP::UserAgent->new();
310
        #By default, LWP::UserAgent uses a string like 'libwww-perl/#.###'.
311
        #NOTE: Cloudflare doesn't like this, but it accepts an empty User-Agent string.
312
        $ua->agent("");
313
        #"5.3.4.  UserInfo Response Validation" requires a TLS service certificate, which should be handled automatically by LWP::UserAgent.
314
        #The following verification should be on by default, but it doesn't hurt to make this explicit here.
315
        $ua->ssl_opts("verify_hostname" => 1);
316
317
        my $userinfo_response;
318
        if ($bearer_access_token_method eq "header"){
319
            $userinfo_response = $ua->get( $self->userinfo_url, Authorization => "Bearer $access_token");
320
        }
321
        elsif ($bearer_access_token_method eq "form"){
322
            my $form = {
323
                access_token => $access_token,
324
            };
325
            my $request = POST($self->userinfo_url, $form);
326
            $userinfo_response = $ua->request($request);
327
        }
328
        elsif ($bearer_access_token_method eq "querystring"){
329
            $userinfo_response = $ua->get( $self->userinfo_url . "?access_token=$access_token");
330
        }
331
332
        if ($userinfo_response->is_success){
333
            my $userinfo_content = $userinfo_response->decoded_content();
334
            if ($userinfo_content){
335
                $userinfo = decode_json($userinfo_content);
336
                if ($self->debugging){
337
                    warn "DEBUGGING Decoded content as JSON = ".Dumper($userinfo_content);
338
                    warn "DEBUGGING Decoded JSON as Perl = ".Dumper($userinfo);
339
                }
340
            }
341
        }
342
        else {
343
            if ($self->debugging){
344
                warn "DEBUGGING " . $userinfo_response->status_line;
345
                warn "DEBUGGING " . $userinfo_response->decoded_content();
346
            }
347
        }
348
    }
349
    return $userinfo;
350
}
351
352
=head2 GenerateAuthenticationRequest
353
354
    my $authentication_request = $OpenIDC->GenerateAuthenticationRequest();
355
356
    Build Authentication Request URL as per http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
357
358
=cut
359
360
sub GenerateAuthenticationRequest {
361
    my ($self) = @_;
362
    my $authentication_request;
363
    if ( $self->{'auth_url'} && $self->{'response_type'} && $self->{'scope'} && $self->{'client_id'} && $self->{'redirect_uri'} ){
364
        $authentication_request =
365
            $self->auth_url . "?" .
366
            "response_type=" . $self->response_type .
367
            "&scope=" . $self->scope . #N.B. Scope needs to be URI escaped at some stage. It should've already been escaped in this case.
368
            "&client_id=" . $self->client_id .
369
            "&redirect_uri=" . $self->redirect_uri #redirect_uri should also be URI escaped prior to this point
370
            #. "&state=af0ifjsldkj" #NOTE: Currently, clients' OIDC servers don't support state. NOTE: the current value here is only an example - not a real state value
371
        ;
372
    }
373
    warn "DEBUGGING -> Authentication Request = $authentication_request" if $self->debugging;
374
    return $authentication_request;
375
}
376
377
378
1;
(-)a/Koha/Prosentient/Auth/OpenIDConnect/UserInfo.pm (+97 lines)
Line 0 Link Here
1
package Koha::Prosentient::Auth::OpenIDConnect::UserInfo;
2
3
# This file is part of Koha.
4
#
5
# Copyright Prosentient Systems 2015
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
use Modern::Perl;
21
use base qw(Class::Accessor);
22
use Data::Dumper;
23
use Hash::Flatten qw(:all); #We use the flatten() function on the nested hash returned by a UserInfo endpoint
24
25
my @canonical_fields = qw(firstname surname email phone dateofbirth address city state zipcode country verified_email nickname);
26
#FIXME: you should rename canonical fields to something more appropriate
27
push(@canonical_fields,"koha_sort1");
28
__PACKAGE__->mk_ro_accessors(@canonical_fields);
29
30
=head1 NAME
31
32
Koha::Prosentient::Auth::OpenIDConnect::UserInfo - A helper class for handling UserInfo responses
33
34
=head1 SYNOPSIS
35
36
    use Koha::Prosentient::Auth::OpenIDConnect;
37
    Koha::Prosentient::Auth::OpenIDConnect::UserInfo
38
    my $userinfo = Koha::Prosentient::Auth::OpenIDConnect::UserInfo->new({
39
        userinfo => $userinfo_response,
40
        mapping => $mapping,
41
        debugging => 1,
42
    });
43
=head1 DESCRIPTION
44
45
This class takes a nested hash (returned from a decoded UserInfo JSON response) and
46
translates it so that it can be turned into data which is useful for Koha.
47
48
=cut
49
50
sub new {
51
     my ($class, $args) = @_;
52
     $args = {} unless defined $args;
53
     return bless ($args, $class);
54
}
55
56
sub map_userinfo {
57
    my ($self, $args) = @_;
58
    my $flattened_userinfo = {};
59
    if ( ( $args->{userinfo} ) && ( ref $args->{userinfo} eq 'HASH' ) ){
60
        #Flatten the hash so that you can always do 1 to 1 mappings
61
        $flattened_userinfo = Hash::Flatten::flatten($args->{userinfo});
62
        if ($self->{debugging}){
63
            warn "DEBUGGING Flattened userinfo = ".Dumper($flattened_userinfo);
64
        }
65
    }
66
    if ( ( $args->{mapping} ) && ( ref $args->{mapping} eq 'HASH' ) ){
67
        if ($self->{debugging}){
68
            warn "DEBUGGING Mapping = ".Dumper($args->{mapping});
69
        }
70
    }
71
72
    #NOTE: Canonical fields are the same as accessor methods... so they're somewhat arbitrary
73
    foreach my $canonical_field (@canonical_fields){
74
        my $real_userinfo_value = undef;
75
        #This initial value will be used if:
76
        #1) No mapping exists for the $canonical_field; or
77
        #2) A mapping exists, but it's empty (i.e. blank) and thus not mapped to an existing hash key in $flattened_userinfo
78
        #NOTE: The matching method used below is CASE SENSITIVE because hash lookups are CASE SENSITIVE. If you wanted it to be CASE INSENSITIVE, you'd have to iterate through the hash keys.
79
        if ( ( $args->{mapping} ) && ( ref $args->{mapping} eq 'HASH' ) ){
80
            #First, check if there is a mapping for this $canonical_field
81
            if ( exists $args->{mapping}->{$canonical_field} ){
82
                #Second, check if the mapping exists in the actual $flattened_userinfo
83
                if ( exists $flattened_userinfo->{ $args->{mapping}->{$canonical_field} } ){
84
                    #Third, store the real value obtained via the mapping
85
                    $real_userinfo_value = $flattened_userinfo->{ $args->{mapping}->{$canonical_field} };
86
                }
87
            }
88
        }
89
        $self->{$canonical_field} = $real_userinfo_value;
90
    }
91
    if ($self->{debugging}){
92
        warn "DEBUGGING Userinfo object = ".Dumper($self);
93
    }
94
    return $self; #Returning the $self allows for method chaining
95
}
96
97
1;
(-)a/Koha/Prosentient/Borrowers.pm (+76 lines)
Line 0 Link Here
1
package Koha::Prosentient::Borrowers;
2
3
# This file is part of Koha.
4
#
5
# Copyright Prosentient Systems 2014
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
use Modern::Perl;
21
use C4::Context;
22
23
use base qw(Class::Accessor);
24
__PACKAGE__->mk_accessors(qw( X ));
25
26
=head1 NAME
27
28
Koha::Prosentient::Borrowers
29
30
=head1 SYNOPSIS
31
32
    use Koha::Prosentient::Borrowers::OpenIDConnect;
33
    my $ProsentientBorrowers = Koha::Prosentient::Borrowers->new();
34
35
=head1 DESCRIPTION
36
37
This class adds third-party (ie Prosentient) borrower management
38
functionality
39
40
=cut
41
42
sub new {
43
     my ($class, $args) = @_;
44
     $args = {} unless defined $args;
45
     return bless ($args, $class);
46
}
47
48
=head2 FindBorrowerByEmails
49
50
    my $borrowers = $ProsentientBorrowers->FindBorrowerByEmails({
51
        email = $email,
52
    });
53
54
    Fetch an arrayref of borrowers based on an email address, which searches
55
    every email field in the borrower table.
56
57
=cut
58
59
sub FindBorrowerByEmails {
60
    my ($self, $args) = @_;
61
    my @borrowers;
62
    my $email = $args->{email};
63
    my $dbh = C4::Context->dbh;
64
    my $query = "SELECT userid, cardnumber, borrowernumber FROM borrowers WHERE email = ? OR emailpro = ? OR B_email = ?";
65
    my $sth=$dbh->prepare($query);
66
    $sth->execute($email,$email,$email);
67
    while (my $row = $sth->fetchrow_hashref){
68
        my $userid = $row->{userid};
69
        my $cardnumber = $row->{cardnumber};
70
        my $borrowernumber = $row->{borrowernumber};
71
        push(@borrowers,{userid => $userid, cardnumber => $cardnumber, borrowernumber => $borrowernumber});
72
    }
73
    return (\@borrowers);
74
}
75
76
1;
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt (+37 lines)
Lines 42-47 Link Here
42
                            <!-- login prompt time-->
42
                            <!-- login prompt time-->
43
                            <h3>Log in to your account</h3>
43
                            <h3>Log in to your account</h3>
44
44
45
                            [%# Add OpenID Connect 1.0 external authentication %]
46
                            [% IF ( OpenIDConnect1 ) %]
47
                                <div class="alert alert-info">
48
                                    <p>Sorry! OpenID Connect authentication was not successful!</p>
49
                                    [% IF ( OpenIDConnect1.server_error ) %]
50
                                        <p>(Server error)</p>
51
                                    [% END %]
52
                                    [% IF ( OpenIDConnect1.config_fail ) %]
53
                                        <p>(Configuration error)</p>
54
                                    [% END %]
55
                                    [% IF ( OpenIDConnect1.auth_fail ) %]
56
                                        <p>(Authentication request failure)</p>
57
                                    [% END %]
58
                                    [% IF ( OpenIDConnect1.no_koha_identifier ) %]
59
                                        <p>(Unable to retrieve Koha details)</p>
60
                                    [% END %]
61
                                    [% IF ( OpenIDConnect1.token_invalid || OpenIDConnect1.token_fail || OpenIDConnect1.no_subject ) %]
62
                                        <p>(Token error)</p>
63
                                    [% END %]
64
                                    [% IF ( OpenIDConnect1.no_user ) %]
65
                                        <p>(Unable to retrieve your information from remote server)</p>
66
                                    [% END %]
67
                                    [% IF ( OpenIDConnect1.no_email ) %]
68
                                        <p>(No email address provided by remote server)</p>
69
                                    [% END %]
70
                                    [% IF ( OpenIDConnect1.email_not_unique ) %]
71
                                        <p>(Your email address is not unique on this system)</p>
72
                                    [% END %]
73
                                    [% IF ( OpenIDConnect1.not_verified ) %]
74
                                        <p>(Your email address has not been verified on the remote server)</p>
75
                                    [% END %]
76
                                </div>
77
                            [% END #/OpenID Connect 1.0 %]
78
                            [% IF ( Koha.Preference('OpenIDConnectLoginPrompt') ) %]
79
                                [% Koha.Preference('OpenIDConnectLoginPrompt') %]
80
                            [% END #OpenID Connect 1.0 %]
81
45
                            [% IF ( timed_out ) %]
82
                            [% IF ( timed_out ) %]
46
                                <!-- This is what is displayed if login has timed out -->
83
                                <!-- This is what is displayed if login has timed out -->
47
                                <div class="alert alert-info">
84
                                <div class="alert alert-info">
(-)a/opac/svc/login_openidc (-1 / +368 lines)
Line 0 Link Here
0
- 
1
#!/usr/bin/perl
2
3
# Copyright Prosentient Systems 2014
4
#
5
# This file is part of Koha.
6
#
7
# Koha is free software; you can redistribute it and/or modify it under the
8
# terms of the GNU General Public License as published by the Free Software
9
# Foundation; either version 3 of the License, or (at your option) any later
10
# version.
11
#
12
# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
13
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14
# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License along
17
# with Koha; if not, write to the Free Software Foundation, Inc.,
18
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19
20
use CGI;
21
use Modern::Perl;
22
use URI::Escape;
23
use Data::Dumper;
24
25
use C4::Auth;
26
use C4::Context;
27
use C4::Members;
28
use Koha::Prosentient::Auth::OpenIDConnect;
29
use Koha::Prosentient::Auth::OpenIDConnect::UserInfo;
30
use Koha::Prosentient::Borrowers;
31
32
=head1 DESCRIPTION
33
34
This script adds an authentication option using OpenID Connect 1.0.
35
36
It relies on having the following elements set in koha-conf.xml:
37
38
<prosentient>
39
 <OpenIdConnect>
40
  <provider id="foo">
41
      <hack_username></hack_username>
42
      <issuer>https://xxxxxxxxxxxxxxx/</issuer>
43
      <client_id>xxxxxxxxxxxxxxxxxxxxxxx</client_id>
44
      <client_secret></client_secret>
45
      <auth_url>https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</auth_url>
46
      <token_url>https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</token_url>
47
      <userinfo_url>https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</userinfo_url>
48
      <response_type>code</response_type>
49
      <scope>openid profile email</scope>
50
      <redirect_url>http://xxxxxxxxxxxxxxxxx/cgi-bin/koha/svc/login_openidc</redirect_url>
51
      <!-- Supply default categorycode and branchcode for new patrons from server -->
52
      <categorycode>XXXXX</categorycode>
53
      <branchcode>XXXXX</branchcode>
54
      <mapping>
55
        <!-- mapping keys match Koha::Prosentient::Auth::OpenIDConnect::UserInfo accessors -->
56
        <firstname>given_name</firstname>
57
        <surname>family_name</surname>
58
        <email>email</email>
59
        <phone>phone_number</phone>
60
        <dateofbirth>birthdate</dateofbirth>
61
        <address>address.street_address</address>
62
        <city>address.locality</city>
63
        <state>address.region</state>
64
        <zipcode>address.postal_code</zipcode>
65
        <country>address.country</country>
66
        <verified_email>email_verified</verified_email>
67
        <nickname></nickname>
68
      </mapping>
69
  </provider>
70
 </OpenIdConnect>
71
</prosentient>
72
73
=cut
74
75
##################################################################################################
76
#DECLARE VARIABLES
77
78
my ($scope, $client_id, $response_type, $issuer, $redirect_uri, $state, $auth_url, $token_url, $userinfo_url); #Send to OpenID Connect server
79
my ($http_username, $http_password, $client_secret); #Send to OpenID Connect server
80
my ($categorycode, $branchcode, $mapping); #For processing UserInfo && passing data to Koha APIs
81
my ($errors, $koha_user, $koha_identifier, $return); #Send to Auth.pm
82
my ($openid);
83
my $client_authentication_method = "client_secret_basic";
84
my $bearer_access_token_method = "header";
85
86
my $query = CGI->new();
87
88
#Restore or create new Koha session...
89
my $koha_sessionID = $query->cookie('CGISESSID') // '';
90
my $koha_session = C4::Auth::get_session($koha_sessionID);
91
92
#We need a session cookie so that we know which endpoints to use...
93
my $session_cookie = $query->cookie(
94
                -name     => 'CGISESSID',
95
                -value    => $koha_session->id,
96
                -HttpOnly => 1
97
            );
98
99
my $cgi_pid = $query->param("pid");
100
my $oidc_login_pid = $koha_session->param("oidc_login_pid");
101
my $provider_id = $cgi_pid ? $cgi_pid : $oidc_login_pid;
102
103
my $code = $query->param("code"); #Authorization code from OpenID Connect server
104
my $server_error = $query->param("error");
105
my $server_error_description = $query->param("error_description");
106
107
if ($provider_id){
108
    my $context = new C4::Context;
109
    my $providers = $context->{prosentient}->{OpenIdConnect}->{provider};
110
    if ( ($providers->{id}) && ($providers->{id} eq $provider_id) ){
111
        #NOTE: This scenario happens when there is only one provider in koha-conf.xml
112
        $openid = $providers;
113
    } elsif ( $context->{prosentient}->{OpenIdConnect}->{provider}->{$provider_id} ) {
114
        #NOTE: This scenario happens when there is more than one provider in koha-conf.xml
115
        $openid = $context->{prosentient}->{OpenIdConnect}->{provider}->{$provider_id};
116
    }
117
}
118
119
if ($openid){
120
    if ($provider_id){
121
        #Store the provider id in the user's Koha session, so that we know which provider to use to process the Authentication Response!
122
        #NOTE: Alternatively, we could include the provider_id in the redirect_url that we issue to the Authentication endpoint...
123
        $koha_session->param("oidc_login_pid",$provider_id);
124
    }
125
126
    #FIXME: This $hack_username shouldn't be necessary in a standard OpenID Connect implementation... it's an unfortunate requirement for one server's non-standard implementation.
127
    $http_username = $openid->{hack_username} ? $openid->{hack_username} : $openid->{client_id};
128
    $http_password = $openid->{client_secret};
129
130
    $client_id = $openid->{client_id};
131
    $client_secret = $openid->{client_secret};
132
    $client_authentication_method = $openid->{client_authentication_method} if $openid->{client_authentication_method};
133
    $bearer_access_token_method = $openid->{bearer_access_token_method} if $openid->{bearer_access_token_method};
134
135
    $issuer = $openid->{issuer};
136
137
    $auth_url = $openid->{auth_url};
138
    $token_url = $openid->{token_url};
139
    $userinfo_url = $openid->{userinfo_url};
140
    $scope = uri_escape($openid->{scope}); #escape scope
141
    $response_type = uri_escape($openid->{response_type}); #escape response_type
142
    $redirect_uri = uri_escape($openid->{redirect_url}); #escape uri (including reserved characters)
143
144
    $categorycode = $openid->{categorycode}; #Categorycode for borrowers added via OpenID Connect...
145
    $branchcode = $openid->{branchcode}; #Branchcode for bororwers added via OpenID Connect...
146
    $mapping = $openid->{mapping}; #Map canonical hash keys to the hash keys returned in a UserInfo response
147
}
148
149
my $insecure = 0; #FIXME: Change this to 1 if you want to authenticate using unverified email addresses
150
my $debugging = 0; #NOTE: This should be 0 in production
151
152
##################################################################################################
153
#SCRIPT ACTION
154
155
unless ( $cgi_pid || $code || $server_error ){
156
    print $query->redirect(-uri => "/cgi-bin/koha/opac-user.pl");
157
    exit;
158
}
159
160
161
162
#Create an OpenIDC object using values we set in koha-conf.xml
163
my $OpenIDC = Koha::Prosentient::Auth::OpenIDConnect->new({
164
    debugging => $debugging,
165
    auth_url => $auth_url,
166
    token_url => $token_url,
167
    userinfo_url => $userinfo_url,
168
    response_type => $response_type,
169
    scope => $scope,
170
    client_id => $client_id,
171
    client_secret => $client_secret,
172
    client_authentication_method => $client_authentication_method,
173
    bearer_access_token_method => $bearer_access_token_method,
174
    redirect_uri => $redirect_uri,
175
    issuer => $issuer,
176
    http_username => $http_username,
177
    http_password => $http_password,
178
    state => $state,
179
});
180
181
if ($OpenIDC->auth_url && $OpenIDC->response_type && $OpenIDC->scope && $OpenIDC->client_id && $OpenIDC->redirect_uri && $categorycode && $branchcode && $OpenIDC->issuer){
182
    if (!$code && $cgi_pid){
183
        #Step One: Send an Authentication Request
184
        my $authentication_request = $OpenIDC->GenerateAuthenticationRequest();
185
        if ($authentication_request){
186
            print $query->redirect(-uri => $authentication_request, -cookie => $session_cookie);
187
            #NOTE: At this stage, the authorization server "MUST" ask the user if they're OK sharing their details with Koha http://openid.net/specs/openid-connect-core-1_0.html#Consent
188
        } else {
189
            $errors->{auth_fail} = 1;
190
        }
191
    } elsif ($code){
192
193
        #Step Two: Send a Token Request
194
        my $token_response = $OpenIDC->GetTokenResponse({code => $code,});
195
        if ($token_response){
196
            my ($token,$header) = $OpenIDC->ParseIdToken({ id_token => $token_response->{id_token}, });
197
            my $token_type = $token_response->{token_type} if $token_response->{token_type};
198
199
            my $token_response_valid = $OpenIDC->ValidateToken({
200
                token => $token,
201
                token_type => $token_type,
202
            });
203
204
            my $access_token = $token_response->{access_token} if $token_response->{access_token};
205
            my $subject = $token->{sub} if $token && $token->{sub};
206
207
            if ($token_response_valid && $access_token && $subject){
208
                #Step Three: Send a UserInfo Request
209
                my $userinfo_response = $OpenIDC->SendUserInfoRequest({
210
                    access_token => $access_token,
211
                });
212
213
                my $userinfo_response_valid = $OpenIDC->ValidateUserInfoResponse({
214
                    userinfo_response => $userinfo_response,
215
                    token_subject => $subject,
216
                });
217
218
                #Step Four: Process the UserInfo and find/create borrower in Koha
219
                if ($userinfo_response && $userinfo_response_valid){
220
221
                    my $userinfo = Koha::Prosentient::Auth::OpenIDConnect::UserInfo->new({
222
                        debugging => $debugging,
223
                    });
224
225
                    #Process the UserInfo into a canonical data structure using a 1 to 1 hash mapping
226
                    $userinfo->map_userinfo({
227
                        userinfo => $userinfo_response,
228
                        mapping => $mapping,
229
                    });
230
231
                    #If we have at least a userinfo email, then we try to find them in Koha or we add them to Koha
232
                    if ( $userinfo->email && ( ($userinfo->verified_email) || ($insecure) ) ){
233
234
                            ($koha_user,$errors) = _process_user({
235
                                userinfo => $userinfo,
236
                                categorycode => $categorycode,
237
                                branchcode => $branchcode,
238
                                errors => $errors,
239
                            });
240
                            if ($koha_user->{koha_identifier}){
241
                                #NOTE: This section essentially says that it's OK to log this user into Koha
242
                                $koha_identifier = $koha_user->{koha_identifier};
243
                                $return = 1;
244
                            } else {
245
                                $errors->{no_koha_identifier} = 1;
246
                            }
247
248
                    } else {
249
                        $errors->{no_email} = 1 if !$userinfo->email; #Tell user
250
                        $errors->{not_verified} = 1 if !$userinfo->verified_email && !$insecure; #Tell user
251
                    }
252
253
                } else {
254
                    $errors->{no_user} = 1; #Tell user
255
                }
256
257
            } else {
258
                $errors->{token_fail} = 1 if !$access_token;
259
                $errors->{token_invalid} = 1 if !$token_response_valid;
260
                $errors->{no_subject} = 1 if !$subject;
261
            }
262
263
        } else {
264
            $errors->{token_fail} = 1;
265
        }
266
267
    } elsif ($server_error){
268
        $errors->{server_error} = 1; #Tell user
269
        warn "OpenID Connect Server Error = $server_error";
270
        warn "OpenID Connect Server Error Description = $server_error_description" if $server_error_description;
271
        #NOTE: For more information, consult http://openid.net/specs/openid-connect-core-1_0.html#AuthError
272
    }
273
} else {
274
    $errors->{config_fail} = 1; #Tell user
275
    if ($OpenIDC->debugging){
276
        warn "DEBUGGING OpenID Connect object = ".Dumper($OpenIDC);
277
    }
278
}
279
280
if ($OpenIDC->debugging){
281
    warn "DEBUGGING OpenIDConnect1.0 errors = ".Dumper($errors);
282
}
283
284
my $external_authen = {
285
    "OpenIDConnect1.0" => {
286
        errors => $errors,
287
        return => $return,
288
        koha_identifier => $koha_identifier,
289
    },
290
};
291
292
my ( $userid, $cookie, $sessionID ) = checkauth( $query, 0,  {}, 'opac', '', $external_authen );
293
294
if ($provider_id){
295
    my $post_auth_session = C4::Auth::get_session($sessionID);
296
    #Set the oidc provider id in the user's active logged-in session
297
    $post_auth_session->param("oidc_pid",$provider_id);
298
}
299
300
301
#NOTE: If we are unable to authenticate into Koha, we'll be shown the opac-auth.tt page but have the service URL in the address bar
302
print $query->redirect(-uri => "/cgi-bin/koha/opac-user.pl", -cookie => $cookie);
303
exit;
304
305
##################################################################################################
306
#FUNCTIONS
307
308
sub _process_user {
309
    my ($args) = @_;
310
    my ( $koha_identifier );
311
    my $userinfo = $args->{userinfo}; #This should be a Koha::Prosentient::Auth::OpenIDConnect::UserInfo object
312
    my $categorycode = $args->{categorycode}; #This should be set in koha-conf.xml
313
    my $branchcode = $args->{branchcode}; #This should be set in koha-conf.xml
314
    my $errors = $args->{errors};
315
316
    #Get borrowers
317
    my $ProsentientBorrowers = Koha::Prosentient::Borrowers->new();
318
    my $borrowers = $ProsentientBorrowers->FindBorrowerByEmails({
319
        email => $userinfo->{email},
320
    });
321
322
    if (scalar @$borrowers == 1){
323
        $koha_identifier = $borrowers->[0]->{cardnumber} if $borrowers->[0]->{cardnumber};
324
        $koha_identifier = $borrowers->[0]->{userid} if $borrowers->[0]->{userid};
325
    } elsif (scalar @$borrowers > 1) {
326
        $errors->{email_not_unique} = 1;
327
    } else {
328
        if ($categorycode && $branchcode && $userinfo){
329
            my %memberdata = (
330
                firstname => $userinfo->{firstname},
331
                surname => $userinfo->{surname},
332
                email => $userinfo->{email},
333
                phone => $userinfo->{phone},
334
                dateofbirth => $userinfo->{dateofbirth},
335
                categorycode => $categorycode,
336
                branchcode => $branchcode,
337
                #dateexpiry
338
                #dateenrolled
339
                address => $userinfo->{address},
340
                city => $userinfo->{city} // '',
341
                state => $userinfo->{state},
342
                zipcode => $userinfo->{zipcode},
343
                country => $userinfo->{country},
344
                #TODO: I wonder if we should be using Nickname/nickname for the cardnumber...
345
                #Nickname appears to be the same as their memberID...
346
                #cardnumber => $userinfo->{nickname},
347
            );
348
            #<koha_sort1>company_name</koha_sort1>
349
            $memberdata{sort1} = $userinfo->{koha_sort1} if $userinfo->{koha_sort1};
350
351
352
            my $borrowernumber = C4::Members::AddMember(%memberdata);
353
            if ($borrowernumber){
354
                my $new_borrowers = $ProsentientBorrowers->FindBorrowerByEmails({
355
                    email => $userinfo->{email},
356
                });
357
                if (scalar @$new_borrowers == 1){
358
                    $koha_identifier = $new_borrowers->[0]->{cardnumber} if $new_borrowers->[0]->{cardnumber};
359
                    $koha_identifier = $new_borrowers->[0]->{userid} if $new_borrowers->[0]->{userid};
360
                } else {
361
                    $errors->{email_not_unique} = 1;
362
                }
363
            }
364
        }
365
    }
366
367
    return ( {koha_identifier => $koha_identifier}, $errors );
368
}

Return to bug 21586