diff options
Diffstat (limited to 'extensions/GitHubAuth/lib')
-rw-r--r-- | extensions/GitHubAuth/lib/Client.pm | 146 | ||||
-rw-r--r-- | extensions/GitHubAuth/lib/Client/Error.pm | 53 | ||||
-rw-r--r-- | extensions/GitHubAuth/lib/Config.pm | 36 | ||||
-rw-r--r-- | extensions/GitHubAuth/lib/Login.pm | 173 | ||||
-rw-r--r-- | extensions/GitHubAuth/lib/Util.pm | 35 | ||||
-rw-r--r-- | extensions/GitHubAuth/lib/Verify.pm | 23 |
6 files changed, 466 insertions, 0 deletions
diff --git a/extensions/GitHubAuth/lib/Client.pm b/extensions/GitHubAuth/lib/Client.pm new file mode 100644 index 000000000..896e82eff --- /dev/null +++ b/extensions/GitHubAuth/lib/Client.pm @@ -0,0 +1,146 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::GitHubAuth::Client; +use strict; +use warnings; + +use JSON qw(decode_json); +use URI; +use URI::QueryParam; +use Digest; + +use Bugzilla::Extension::GitHubAuth::Client::Error qw(ThrowUserError ThrowCodeError); +use Bugzilla::Util qw(remote_ip); + +use constant DIGEST_HASH => 'SHA1'; + +use fields qw(user_agent); + +use constant { + GH_ACCESS_TOKEN_URI => 'https://github.com/login/oauth/access_token', + GH_AUTHORIZE_URI => 'https://github.com/login/oauth/authorize', + GH_USER_EMAILS_URI => 'https://api.github.com/user/emails', +}; + +sub new { + my ($class, %init) = @_; + my $self = $class->fields::new(); + + return $self; +} + +sub login_uri { + my ($self, $target) = @_; + + $target->query_param(GoAheadAndLogIn => 1); + $target->query_param(github_login => 1); + $target->query_param_delete('logout'); + + my $uri = URI->new(GH_AUTHORIZE_URI); + + $uri->query_form( + client_id => Bugzilla->params->{github_client_id}, + scope => 'user:email', + state => $self->get_state($target), + redirect_uri => $target, + ); + + return $uri; +} + +sub get_email_key { + my ($class, $email) = @_; + + my $digest = Digest->new(DIGEST_HASH); + $digest->add($email); + $digest->add(remote_ip()); + $digest->add(Bugzilla->localconfig->{site_wide_secret}); + return $digest->hexdigest; +} + +sub get_state { + my ($class, $target) = @_; + my $sorted_target = $target->clone; + $sorted_target->query_form({}); + + foreach my $key (sort $target->query_param) { + $sorted_target->query_param($key, $target->query_param($key)); + } + + $sorted_target->query_param_delete("code"); + $sorted_target->query_param_delete("state"); + $sorted_target->query_param_delete('github_email_key'); + $sorted_target->query_param_delete('github_email'); + $sorted_target->query_param_delete('GoAheadAndLogIn'); + $sorted_target->query_param_delete('github_login'); + + my $digest = Digest->new(DIGEST_HASH); + $digest->add($sorted_target->as_string); + $digest->add(remote_ip()); + $digest->add(Bugzilla->localconfig->{site_wide_secret}); + return $digest->hexdigest; +} + +sub _handle_response { + my ($self, $response) = @_; + my $data = eval { + decode_json($response->content); + }; + if ($@) { + ThrowCodeError("github_bad_response", { message => "Unable to parse json response" }); + } + + unless ($response->is_success) { + ThrowCodeError("github_error", { response => $response }); + } + return $data; +} + +sub get_access_token { + my ($self, $code) = @_; + + my $response = $self->user_agent->post( + GH_ACCESS_TOKEN_URI, + { client_id => Bugzilla->params->{github_client_id}, + client_secret => Bugzilla->params->{github_client_secret}, + code => $code }, + Accept => 'application/json', + ); + my $data = $self->_handle_response($response); + return $data->{access_token} if exists $data->{access_token}; +} + +sub get_user_emails { + my ($self, $access_token) = @_; + my $uri = URI->new(GH_USER_EMAILS_URI); + $uri->query_form(access_token => $access_token); + + my $response = $self->user_agent->get($uri, Accept => 'application/json'); + + return $self->_handle_response($response); +} + +sub user_agent { + my ($self) = @_; + $self->{user_agent} //= $self->_build_user_agent; + + return $self->{user_agent}; +} + +sub _build_user_agent { + my ($self) = @_; + my $ua = LWP::UserAgent->new( timeout => 10 ); + + if (Bugzilla->params->{proxy_url}) { + $ua->proxy('https', Bugzilla->params->{proxy_url}); + } + + return $ua; +} + +1; diff --git a/extensions/GitHubAuth/lib/Client/Error.pm b/extensions/GitHubAuth/lib/Client/Error.pm new file mode 100644 index 000000000..358df6938 --- /dev/null +++ b/extensions/GitHubAuth/lib/Client/Error.pm @@ -0,0 +1,53 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::GitHubAuth::Client::Error; + +use strict; +use warnings; + +use Bugzilla::Error (); + +use base qw(Exporter); +use fields qw(type error vars); + +our @EXPORT = qw(ThrowUserError ThrowCodeError); +our $USE_EXCEPTION_OBJECTS = 0; + +sub _new { + my ($class, $type, $error, $vars) = @_; + my $self = $class->fields::new(); + $self->{type} = $type; + $self->{error} = $error; + $self->{vars} = $vars // {}; + + return $self; +} + +sub type { $_[0]->{type} } +sub error { $_[0]->{error} } +sub vars { $_[0]->{vars} } + +sub ThrowUserError { + if ($USE_EXCEPTION_OBJECTS) { + die __PACKAGE__->_new('user', @_); + } + else { + Bugzilla::Error::ThrowUserError(@_); + } +} + +sub ThrowCodeError { + if ($USE_EXCEPTION_OBJECTS) { + die __PACKAGE__->_new('code', @_); + } + else { + Bugzilla::Error::ThrowCodeError(@_); + } +} + +1; diff --git a/extensions/GitHubAuth/lib/Config.pm b/extensions/GitHubAuth/lib/Config.pm new file mode 100644 index 000000000..0dc78531b --- /dev/null +++ b/extensions/GitHubAuth/lib/Config.pm @@ -0,0 +1,36 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::GitHubAuth::Config; + +use strict; +use warnings; + +use Bugzilla::Config::Common; + +our $sortkey = 1350; + +sub get_param_list { + my ($class) = @_; + + my @params = ( + { + name => 'github_client_id', + type => 't', + default => '', + }, + { + name => 'github_client_secret', + type => 't', + default => '', + }, + ); + + return @params; +} + +1; diff --git a/extensions/GitHubAuth/lib/Login.pm b/extensions/GitHubAuth/lib/Login.pm new file mode 100644 index 000000000..c83456274 --- /dev/null +++ b/extensions/GitHubAuth/lib/Login.pm @@ -0,0 +1,173 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::GitHubAuth::Login; +use strict; +use warnings; +use base qw(Bugzilla::Auth::Login); +use fields qw(github_failure); + +use Scalar::Util qw(blessed); + +use Bugzilla::Constants qw(AUTH_NODATA AUTH_ERROR USAGE_MODE_BROWSER ); +use Bugzilla::Util qw(trick_taint correct_urlbase); +use Bugzilla::Extension::GitHubAuth::Client; +use Bugzilla::Extension::GitHubAuth::Client::Error (); +use Bugzilla::Extension::GitHubAuth::Util qw(target_uri); +use Bugzilla::Error; + +use constant { requires_verification => 1, + is_automatic => 1, + user_can_create_account => 1 }; + +sub get_login_info { + my ($self) = @_; + my $cgi = Bugzilla->cgi; + my $github_login = $cgi->param('github_login'); + my $github_email = $cgi->param('github_email'); + my $github_email_key = $cgi->param('github_email_key'); + + return { failure => AUTH_NODATA } unless $github_login; + + if ($github_email_key && $github_email) { + trick_taint($github_email); + trick_taint($github_email_key); + return $self->_get_login_info_from_email($github_email, $github_email_key); + } + else { + return $self->_get_login_info_from_github(); + } +} + +sub _get_login_info_from_github { + my ($self) = @_; + my $cgi = Bugzilla->cgi; + my $template = Bugzilla->template; + my $state = $cgi->param('state'); + my $code = $cgi->param('code'); + + return { failure => AUTH_ERROR, error => 'github_missing_code' } unless $code; + return { failure => AUTH_ERROR, error => 'github_invalid_state' } unless $state; + + trick_taint($code); + trick_taint($state); + + my $target = target_uri(); + my $client = Bugzilla::Extension::GitHubAuth::Client->new; + if ($state ne $client->get_state($target)) { + return { failure => AUTH_ERROR, error => 'github_invalid_state' }; + } + + my ($access_token, $emails); + eval { + # The following variable lets us catch and return (rather than throw) errors + # from our github client code, as required by the Auth API. + local $Bugzilla::Extension::GitHubAuth::Client::Error::USE_EXCEPTION_OBJECTS = 1; + $access_token = $client->get_access_token($code); + $emails = $client->get_user_emails($access_token); + }; + my $e = $@; + if (blessed $e && $e->isa('Bugzilla::Extension::GitHubAuth::Client::Error')) { + my $key = $e->type eq 'user' ? 'user_error' : 'error'; + return { failure => AUTH_ERROR, $key => $e->error, details => $e->vars }; + } + elsif ($e) { + die $e; + } + + my @emails = map { $_->{email} } grep { $_->{verified} } @$emails; + + my $choose_email = sub { + my ($email) = @_; + my $uri = $target->clone; + my $key = Bugzilla::Extension::GitHubAuth::Client->get_email_key($email); + $uri->query_param(github_email => $email); + $uri->query_param(github_email_key => $key); + return $uri; + }; + + my @bugzilla_users; + my @github_emails; + foreach my $email (@emails) { + my $user = Bugzilla::User->new({name => $email, cache => 1}); + if ($user) { + push @bugzilla_users, $user; + } + else { + push @github_emails, $email; + } + } + my @allowed_bugzilla_users = grep { not $_->in_group('no-github-auth') } @bugzilla_users; + + if (@allowed_bugzilla_users == 1) { + my ($user) = @allowed_bugzilla_users; + return { username => $user->login, user_id => $user->id, github_auth => 1 }; + } + elsif (@allowed_bugzilla_users > 1) { + $self->{github_failure} = { + template => 'account/auth/github-verify-account.html.tmpl', + vars => { + bugzilla_users => \@allowed_bugzilla_users, + choose_email => $choose_email, + }, + }; + return { failure => AUTH_NODATA }; + } + elsif (@allowed_bugzilla_users == 0 && @bugzilla_users > 0 && @github_emails == 0) { + return { failure => AUTH_ERROR, + user_error => 'github_auth_account_too_powerful' }; + } + elsif (@github_emails) { + $self->{github_failure} = { + template => 'account/auth/github-verify-account.html.tmpl', + vars => { + github_emails => \@github_emails, + choose_email => $choose_email, + }, + }; + return { failure => AUTH_NODATA }; + } + else { + return { failure => AUTH_ERROR, user_error => 'github_no_emails' }; + } +} + +sub _get_login_info_from_email { + my ($self, $github_email, $github_email_key) = @_; + my $cgi = Bugzilla->cgi; + + my $key = Bugzilla::Extension::GitHubAuth::Client->get_email_key($github_email); + unless ($github_email_key eq $key) { + return { failure => AUTH_ERROR, + user_error => 'github_invalid_email', + { email => $github_email }}; + } + + my $user = Bugzilla::User->new({name => $github_email, cache => 1}); + return { failure => AUTH_ERROR, + user_error => 'github_auth_account_too_powerful' } if $user && $user->in_group('no-github-auth'); + + return { username => $github_email, github_auth => 1 }; +} + +sub fail_nodata { + my ($self) = @_; + my $cgi = Bugzilla->cgi; + my $template = Bugzilla->template; + + ThrowUserError('login_required') if Bugzilla->usage_mode != USAGE_MODE_BROWSER; + + my $file = $self->{github_failure}{template} // "account/auth/login.html.tmpl"; + my $vars = $self->{github_failure}{vars} // { target => $cgi->url(-relative=>1) }; + + print $cgi->header(); + $template->process($file, $vars) or ThrowTemplateError($template->error()); + exit; +} + + +1; diff --git a/extensions/GitHubAuth/lib/Util.pm b/extensions/GitHubAuth/lib/Util.pm new file mode 100644 index 000000000..bda76a420 --- /dev/null +++ b/extensions/GitHubAuth/lib/Util.pm @@ -0,0 +1,35 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::GitHubAuth::Util; + +use strict; +use warnings; + +use Bugzilla::Util qw(correct_urlbase); +use URI; + +use base qw(Exporter); +our @EXPORT = qw( target_uri ); + + +# this is like correct_urlbase() except it returns the *requested* uri, before http url rewrites have been applied. +# needed to generate github's redirect_uri. +sub target_uri { + my $cgi = Bugzilla->cgi; + my $base = URI->new(correct_urlbase()); + if (my $request_uri = $cgi->request_uri) { + $base->path(''); + $request_uri =~ s!^/+!!; + return URI->new($base . "/" . $request_uri); + } + else { + return URI->new(correct_urlbase() . $cgi->url(-relative => 1, query => )); + } +} + +1; diff --git a/extensions/GitHubAuth/lib/Verify.pm b/extensions/GitHubAuth/lib/Verify.pm new file mode 100644 index 000000000..ee66c8904 --- /dev/null +++ b/extensions/GitHubAuth/lib/Verify.pm @@ -0,0 +1,23 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::GitHubAuth::Verify; +use strict; +use warnings; +use base qw(Bugzilla::Auth::Verify); + +use Bugzilla::Constants qw( AUTH_NO_SUCH_USER ); + +sub check_credentials { + my ($self, $login_data) = @_; + + return { failure => AUTH_NO_SUCH_USER } unless $login_data->{github_auth}; + + return $login_data; +} + +1; |