Bugzilla – Attachment 136302 Details for
Bug 30988
Add generic OpenIDConnect client implementation
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 30988: Adding a more generic version of googleopenidconnect
Bug-30988-Adding-a-more-generic-version-of-googleo.patch (text/plain), 22.97 KB, created by
Shi Yao Wang
on 2022-06-17 19:42:07 UTC
(
hide
)
Description:
Bug 30988: Adding a more generic version of googleopenidconnect
Filename:
MIME Type:
Creator:
Shi Yao Wang
Created:
2022-06-17 19:42:07 UTC
Size:
22.97 KB
patch
obsolete
>From 7ab24ff634fe3f18f5b323a9b311fa010d93fb92 Mon Sep 17 00:00:00 2001 >From: Shi Yao Wang <shi-yao.wang@inlibro.com> >Date: Thu, 16 Jun 2022 16:28:30 -0400 >Subject: [PATCH] Bug 30988: Adding a more generic version of > googleopenidconnect > >More generic OpenID Connect based off of googleopenidconnect > >Test plan: >1- Set up an OAuth2 provider that has a discovery doc (e.g. for google it's https://accounts.google.com/.well-known/openid-configuration) and note down the client id and client secret. >Docs to help setup: >google: https://developers.google.com/identity/protocols/oauth2/openid-connect > https://koha-community.org/manual/20.11/en/html/administrationpreferences.html >gitlab: https://docs.gitlab.com/ee/integration/oauth_provider.html >2- Apply the patch >3- Run atomicupdate to update database >4- Configure system preferences: > a- Set OpenIDConnect to Yes > b- Enter the url to the discovery doc of your OAuth2 provider > c- Fill OpenIDOAuth2ClientID with noted client id > d- Fill OpenIDOAuth2ClientSecret with noted client secret > > e- Configure the other related system preferences as you wish. They >shoud work the same way as the similarly named GoogleOpenID* ones. >5- If OpenIDConnectAutoRegister is set to Don't allow, have a koha >account with the same email as the one used by your OAuth2 provider. >6- In OPAC, sign in using OpenID. >7- You may have to log into your OpenID provider account and accept conditions. >It should finally log you into the koha account. >--- > C4/Auth.pm | 7 + > .../data/mysql/atomicupdate/openidconnect.sql | 10 + > installer/data/mysql/mandatory/sysprefs.sql | 8 + > .../en/modules/admin/preferences/admin.pref | 39 +++ > .../bootstrap/en/includes/masthead.inc | 2 + > .../bootstrap/en/modules/opac-auth.tt | 9 + > opac/svc/auth/openidconnect | 270 ++++++++++++++++++ > 7 files changed, 345 insertions(+) > create mode 100644 installer/data/mysql/atomicupdate/openidconnect.sql > create mode 100755 opac/svc/auth/openidconnect > >diff --git a/C4/Auth.pm b/C4/Auth.pm >index a44cd09767..7063d4655a 100644 >--- a/C4/Auth.pm >+++ b/C4/Auth.pm >@@ -1478,6 +1478,13 @@ sub checkauth { > } > } > >+ if (C4::Context->preference('OpenIDConnect')) { >+ if ($query->param("OpenIDConnectFailed")) { >+ my $reason = $query->param('OpenIDConnectFailed'); >+ $template->param(invalidOpenIDConnectLogin => $reason); >+ } >+ } >+ > $template->param( > LibraryName => C4::Context->preference("LibraryName"), > ); >diff --git a/installer/data/mysql/atomicupdate/openidconnect.sql b/installer/data/mysql/atomicupdate/openidconnect.sql >new file mode 100644 >index 0000000000..6a32a0f30e >--- /dev/null >+++ b/installer/data/mysql/atomicupdate/openidconnect.sql >@@ -0,0 +1,10 @@ >+INSERT INTO systempreferences (variable,value,explanation,options,type) >+VALUES >+('OpenIDConnect','0',NULL,'if ON, allows the use of OpenID Connect for login','YesNo'), >+('OpenIDConfigURL','',NULL,"URL to identity provider's OpenID config",'Free'), >+('OpenIDOAuth2ClientID','', NULL,'Client ID for the web app','Free'), >+('OpenIDOAuth2ClientSecret','',NULL,'Client Secret for the web app','Free'), >+('OpenIDConnectAutoRegister','0',NULL,'OpenID Connect logins to auto-register patrons','YesNo'), >+('OpenIDConnectDefaultBranch', '','','This branch code will be used to create OpenID Connect patrons.','Textarea'), >+('OpenIDConnectDefaultCategory','','','This category code will be used to create OpenID Connect patrons.','Textarea'), >+('OpenIDConnectDomain', '', NULL, 'Restrict OpenID Connect to this domain (or subdomains of this domain). Leave blank for all domains', 'Free'); >\ No newline at end of file >diff --git a/installer/data/mysql/mandatory/sysprefs.sql b/installer/data/mysql/mandatory/sysprefs.sql >index a15eb6c112..03c1b6a656 100644 >--- a/installer/data/mysql/mandatory/sysprefs.sql >+++ b/installer/data/mysql/mandatory/sysprefs.sql >@@ -497,6 +497,14 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` > ('OPACXSLTDetailsDisplay','default','','Enable XSL stylesheet control over details page display on OPAC','Free'), > ('OPACXSLTListsDisplay','default','','Enable XSLT stylesheet control over lists pages display on OPAC','Free'), > ('OPACXSLTResultsDisplay','default','','Enable XSL stylesheet control over results page display on OPAC','Free'), >+('OpenIDConfigURL','',NULL,"URL to identity provider's OpenID config",'Free'), >+('OpenIDConnect','0',NULL,'if ON, allows the use of OpenID Connect for login','YesNo'), >+('OpenIDConnectAutoRegister','0',NULL,'OpenID Connect logins to auto-register patrons','YesNo'), >+('OpenIDConnectDefaultBranch', '','','This branch code will be used to create OpenID Connect patrons.','Textarea'), >+('OpenIDConnectDefaultCategory','','','This category code will be used to create OpenID Connect patrons.','Textarea'), >+('OpenIDConnectDomain', '', NULL, 'Restrict OpenID Connect to this domain (or subdomains of this domain). Leave blank for all domains', 'Free'), >+('OpenIDOAuth2ClientID', '', NULL, 'Client ID for the web app', 'Free'), >+('OpenIDOAuth2ClientSecret', '', NULL, 'Client Secret for the web app', 'Free'), > ('OpenLibraryCovers','0',NULL,'If ON Openlibrary book covers will be show','YesNo'), > ('OpenLibrarySearch','0',NULL,'If Yes Open Library search results will show in OPAC','YesNo'), > ('OpenURLResolverURL', '', NULL, 'URL of OpenURL Resolver', 'Free'), >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/admin.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/admin.pref >index 593c8c3417..f29a136492 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/admin.pref >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/admin.pref >@@ -171,6 +171,45 @@ Administration: > None: "None" > Common Name: Common Name > emailAddress: emailAddress >+ OpenID Connect: >+ - >+ - "Use OpenID Connect login in the OPAC: " >+ - pref: OpenIDConnect >+ choices: >+ 1: "Yes" >+ 0: "No" >+ - You will need to create an app in your chosen identity provider and activate OAuth2. Requires <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OpenIDConfigURL">OpenIDConfigURL</a>, <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OAuth2ClientID">OAuth2ClientID</a> and <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OAuth2ClientSecret">OAuth2ClientSecret</a> >+ - >+ - "URL to your identity provider's OpenID configuration: " >+ - pref: OpenIDConfigURL >+ - Along with <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OAuth2ClientID">OAuth2ClientID</a> and <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OAuth2ClientSecret">OAuth2ClientSecret</a>. Enables <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OpenIDConnect">OpenIDConnect.</a> >+ - >+ - "OAuth2 Client ID: " >+ - pref: OpenIDOAuth2ClientID >+ - Along with <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OpenIDConfigURL">OpenIDConfigURL</a> and <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OAuth2ClientSecret">OAuth2ClientSecret</a>. Enables <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OpenIDConnect">OpenIDConnect.</a> >+ - >+ - "OAuth2 Client Secret: " >+ - pref: OpenIDOAuth2ClientSecret >+ - Along with <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OpenIDConfigURL">OpenIDConfigURL</a> and <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OAuth2ClientID">OAuth2ClientID</a>. Enables <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OpenIDConnect">OpenIDConnect.</a> >+ - >+ - pref: OpenIDConnectAutoRegister >+ choices: >+ 1: Allow >+ 0: "Don't allow" >+ - patrons logging in with OpenID to automatically register. Requires <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OpenIDConnect">OpenIDConnect</a>. >+ - >+ - "Use this category code when automatically registering a patron using OpenID: " >+ - pref: OpenIDConnectDefaultCategory >+ choices: patron-categories >+ - Requires <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OpenIDConnect">OpenIDConnect</a>. >+ - >+ - "Use this branch code when automatically registering a patron using OpenID: " >+ - pref: OpenIDConnectDefaultBranch >+ - Requires <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OpenIDConnect">OpenIDConnect</a>. >+ - >+ - "Restrict OpenID Connect to this domain (or subdomain of this domain): " >+ - pref: OpenIDConnectDomain >+ - Leave blank for all domains. Requires <a href="/cgi-bin/koha/admin/preferences.pl?op=search&searchfield=OpenIDConnect">OpenIDConnect</a>. > Google OpenID Connect: > - > - "Use Google OpenID Connect login in the OPAC: " >diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc >index b60c696d56..77d87376e8 100644 >--- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc >+++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc >@@ -99,6 +99,8 @@ > [% IF Koha.Preference('casAuthentication') %] > [%# CAS authentication is too complicated for modal window %] > <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> >+ [% ELSIF ( Koha.Preference('OpenIDConnect') == 1 ) %] >+ <a class="nav-link login-link" href="/cgi-bin/koha/svc/auth/openidconnect"><i class="fa fa-user fa-icon-black fa-fw" aria-hidden="true"></i> <span class="userlabel">Log in to your account</span></a> > [% ELSIF ( Koha.Preference('GoogleOpenIDConnect') == 1 ) %] > <a href="/cgi-bin/koha/svc/auth/googleopenidconnect" 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> > [% ELSE %] >diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt >index 0fd0d32bba..698db809b7 100644 >--- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt >+++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt >@@ -165,6 +165,15 @@ > <a href="/cgi-bin/koha/svc/auth/googleopenidconnect" class="btn btn-light" id="openid_connect"><i class="fa fa-google" aria-hidden="true"></i> Log in with Google</a> > <p>If you do not have a Google account, but do have a local account, you can still log in: </p> > [% END # /IF GoogleOpenIDConnect %] >+ [% IF ( Koha.Preference('OpenIDConnect') == 1 ) %] >+ [% IF ( invalidOpenIDConnectLogin ) %] >+ <h2>OpenID login</h2> >+ <p>Sorry, your login using OpenID Connect failed. <span class="error">[% invalidOpenIDConnectLogin | html %]</span></p> >+ <p>Please note that the login will only work if you are using the e-mail address registered with this library.</p> >+ [% END %] >+ <a href="/cgi-bin/koha/svc/auth/openidconnect" class="btn btn-primary" id="openid_connect">Log in with OpenID</a> >+ <p>If you do not have an OpenID account from the provider specified in this library, but do have a local account, you can still log in: </p> >+ [% END # /IF OpenIDConnect %] > [% END # /UNLESS OPACShibOnly %] > > [% IF !(invalid_username_or_password || too_many_login_attempts) and password_has_expired %] >diff --git a/opac/svc/auth/openidconnect b/opac/svc/auth/openidconnect >new file mode 100755 >index 0000000000..3434404b07 >--- /dev/null >+++ b/opac/svc/auth/openidconnect >@@ -0,0 +1,270 @@ >+#!/usr/bin/perl >+# >+# This file is part of Koha. >+# >+# Koha is free software; you can redistribute it and/or modify it >+# under the terms of the GNU General Public License as published by >+# the Free Software Foundation; either version 3 of the License, or >+# (at your option) any later version. >+# >+# Koha is distributed in the hope that it will be useful, but >+# WITHOUT ANY WARRANTY; without even the implied warranty of >+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the >+# GNU General Public License for more details. >+# >+# You should have received a copy of the GNU General Public License >+# along with Koha; if not, see <http://www.gnu.org/licenses>. >+# >+# >+# Basic OAuth2/OpenID Connect authentication goes like this >+# >+# The first thing that happens when this script is called is >+# that one gets redirected to an authentication url >+# >+# If successful, that then redirects back to this script, setting >+# a CODE parameter which we use to look up a json authentication >+# token. This token includes an encrypted json id_token, which we >+# round-trip back to decrypt. Finally, we can extract >+# the email address from this. >+# >+ >+use Modern::Perl; >+use CGI qw ( -utf8 escape ); >+use C4::Auth qw{ checkauth get_session get_template_and_user }; >+use C4::Context; >+use C4::Output; >+use Koha::Patrons; >+ >+use LWP::UserAgent; >+use HTTP::Request::Common qw{ POST }; >+use JSON; >+use MIME::Base64 qw{ decode_base64url }; >+ >+my $discoveryDocURL = C4::Context->preference('OpenIDConfigURL'); >+my $issuer = ''; >+my $authendpoint = ''; >+my $tokenendpoint = ''; >+my $scope = 'openid email profile'; >+my $host = C4::Context->preference('OPACBaseURL') // q{}; >+my $restricttodomain = C4::Context->preference('OpenIDConnectDomain') >+ // q{}; >+ >+# protocol is assumed in OPACBaseURL see bug 5010. >+my $redirecturl = $host . '/cgi-bin/koha/svc/auth/openidconnect'; >+my $clientid = C4::Context->preference('OpenIDOAuth2ClientID'); >+my $clientsecret = C4::Context->preference('OpenIDOAuth2ClientSecret'); >+ >+my $ua = LWP::UserAgent->new(); >+my $response = $ua->get($discoveryDocURL); >+if ( $response->is_success ) { >+ my $json = decode_json( $response->decoded_content ); >+ if ( exists( $json->{'issuer'} ) ) { >+ $issuer = $json->{'issuer'}; >+ } >+ if ( exists( $json->{'authorization_endpoint'} ) ) { >+ $authendpoint = $json->{'authorization_endpoint'}; >+ } >+ if ( exists( $json->{'token_endpoint'} ) ) { >+ $tokenendpoint = $json->{'token_endpoint'}; >+ } >+} >+ >+my $query = CGI->new; >+ >+sub loginfailed { >+ my $cgi_query = shift; >+ my $reason = shift; >+ $cgi_query->delete('code'); >+ $cgi_query->param( 'OpenIDConnectFailed' => $reason ); >+ my ( $template, $borrowernumber, $cookie ) = get_template_and_user( >+ { >+ template_name => 'opac-user.tt', >+ query => $cgi_query, >+ type => 'opac', >+ } >+ ); >+ $template->param( 'invalidOpenIDConnectLogin' => $reason ); >+ $template->param( 'loginprompt' => 1 ); >+ output_html_with_http_headers $cgi_query, $cookie, $template->output, undef, { force_no_caching => 1 }; >+ return; >+} >+ >+if ( defined $query->param('error') ) { >+ loginfailed( $query, >+ 'An authentication error occurred. (Error:' >+ . $query->param('error') >+ . ')' ); >+} >+elsif ( defined $query->param('code') ) { >+ my $stateclaim = $query->param('state'); >+ my $session = get_session( $query->cookie('CGISESSID') ); >+ if ( $session->param('openid-state') ne $stateclaim ) { >+ $session->clear( ["openid-state"] ); >+ $session->flush(); >+ loginfailed( $query, >+ 'Authentication failed. Your session has an unexpected state.' ); >+ } >+ $session->clear( ["openid-state"] ); >+ $session->flush(); >+ >+ my $code = $query->param('code'); >+ my $ua = LWP::UserAgent->new(); >+ if ( $tokenendpoint eq q{} ) { >+ loginfailed( $query, 'Unable to discover token endpoint.' ); >+ } >+ my $request = POST( >+ $tokenendpoint, >+ [ >+ code => $code, >+ client_id => $clientid, >+ client_secret => $clientsecret, >+ redirect_uri => $redirecturl, >+ grant_type => 'authorization_code', >+ $scope => $scope >+ ] >+ ); >+ my $response = $ua->request($request)->decoded_content; >+ my $json = decode_json($response); >+ if ( exists( $json->{'id_token'} ) ) { >+ if ( lc( $json->{'token_type'} ) ne 'bearer' ) { >+ loginfailed( $query, >+ 'Authentication failed. Incorrect token type.' ); >+ } >+ my $idtoken = $json->{'id_token'}; >+ >+ # need to validate the token here >+ >+ my @segments = split( '\.', $idtoken ); >+ unless ( scalar(@segments) == 3 ) { >+ loginfailed( $query, >+ 'Login token broken: either too many or too few segments.' ); >+ } >+ my ( $header, $claims, $validation ) = @segments; >+ $claims = decode_base64url($claims); >+ my $claims_json = decode_json($claims); >+ if ( $issuer eq q{} ) { >+ loginfailed( $query, 'Unable to discover issuer.' ); >+ } >+ if ( ( $claims_json->{'iss'} ne ( 'https://' . $issuer ) ) >+ && ( $claims_json->{'iss'} ne $issuer ) ) >+ { >+ loginfailed( $query, >+ "Authentication failed. Issuer of authentication is different from what we expected." >+ ); >+ } >+ if ( ref( $claims_json->{'aud'} ) eq 'ARRAY' ) { >+ warn "Audience is an array of size: " >+ . scalar( @$claims_json->{'aud'} ); >+ if ( scalar( @$claims_json->{'aud'} ) > 1 ) >+ { # We don't want any other audiences >+ loginfailed( $query, >+ "Authentication failed. Unexpected audience provided." ); >+ } >+ } >+ if ( ( $claims_json->{'aud'} ne $clientid ) ) >+ { >+ loginfailed( $query, >+ "Authentication failed. Unexpected audience." ); >+ } >+ if ( $claims_json->{'exp'} < time() ) { >+ loginfailed( $query, 'Sorry, your authentication has timed out.' ); >+ } >+ >+ if ( exists( $claims_json->{'email'} ) ) { >+ my $email = $claims_json->{'email'}; >+ if ( ( $restricttodomain ne q{} ) >+ && ( index( $email, $restricttodomain ) < 0 ) ) >+ { >+ loginfailed( $query, >+'The email you have used is not valid for this library. Email addresses should conclude with ' >+ . $restricttodomain >+ . ' .' ); >+ } >+ else { >+ my $error_feedback = >+'The email address you are trying to use is not associated with a borrower at this library.'; >+ my $auto_registration = C4::Context->preference('OpenIDConnectAutoRegister') // q{0}; >+ my $borrower = Koha::Patrons->find( { email => $email } ); >+ if (! $borrower && $auto_registration==1) { >+ my $firstname = $claims_json->{'given_name'} // q{}; >+ my $surname = $claims_json->{'family_name'} // q{}; >+ my $delimiter = $firstname ? q{.} : q{}; >+ my $userid = $firstname . $delimiter . $surname; >+ my $categorycode = C4::Context->preference('OpenIDConnectDefaultCategory') // q{}; >+ my $patron_category = Koha::Patron::Categories->find( $categorycode ); >+ my $branchcode = C4::Context->preference('OpenIDConnectDefaultBranch') // q{}; >+ my $library = Koha::Libraries->find( $branchcode ); >+ if (defined $patron_category && defined $library) { >+ my $password = undef; >+ # TODO errors handling! >+ my $borrower = Koha::Patron->new({ >+ firstname => $firstname, >+ surname => $surname, >+ email => $email, >+ categorycode => $categorycode, >+ branchcode => $branchcode, >+ userid => $userid, >+ password => $password >+ })->store; >+ } else { >+ $error_feedback = 'The OpenIDConnectDefaultBranch or OpenIDConnectDefaultCategory system preferences are not configured properly. Please contact the library with this error message.'; >+ } >+ } >+ my ( $userid, $cookie, $session_id ) = >+ checkauth( $query, 1, {}, 'opac', $email ); >+ if ($userid) { # A user with this email is registered in koha >+ >+ #handle redirect to main.pl, for private opac >+ my $uri; >+ if (C4::Context->preference('OpacPublic') ) { >+ $uri = '/cgi-bin/koha/opac-user.pl'; >+ } else { >+ $uri = '/cgi-bin/koha/opac-main.pl'; >+ } >+ print $query->redirect( >+ -uri => $uri, >+ -cookie => $cookie >+ ); >+ } >+ else { >+ loginfailed( $query, $error_feedback ); >+ } >+ } >+ } >+ else { >+ loginfailed( $query, >+'Unexpectedly, no email seems to be associated with that acccount.' >+ ); >+ } >+ } >+ else { >+ loginfailed( $query, 'Failed to get proper credentials.' ); >+ } >+} >+else { >+ my $session = get_session( $query->cookie('CGISESSID') ); >+ my $openidstate = 'auth_'; >+ $openidstate .= sprintf( "%x", rand 16 ) for 1 .. 32; >+ $session->param( 'openid-state', $openidstate ); >+ $session->flush(); >+ >+ my $prompt = $query->param('reauthenticate') // q{}; >+ if ( $authendpoint eq q{} ) { >+ loginfailed( $query, 'Unable to discover authorisation endpoint.' ); >+ } >+ my $authorisationurl = >+ $authendpoint . '?' >+ . 'response_type=code&' >+ . 'redirect_uri=' >+ . escape($redirecturl) . q{&} >+ . 'client_id=' >+ . escape($clientid) . q{&} >+ . 'scope=' >+ . escape($scope) . q{&} >+ . 'state=' >+ . escape($openidstate); >+ if ( $prompt || ( defined $prompt && length $prompt > 0 ) ) { >+ $authorisationurl .= '&prompt=' . escape($prompt); >+ } >+ print $query->redirect($authorisationurl); >+} >-- >2.25.1
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 30988
:
136301
|
136302
|
136382
|
136396
|
136397
|
136403
|
136430
|
136431
|
136450
|
136451
|
136467
|
136468
|
136469
|
137230
|
137231
|
137232