diff options
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rw-r--r-- | perllib/Catalyst/Authentication/Credential/AccessToken.pm | 144 | ||||
-rw-r--r-- | perllib/FixMyStreet/App.pm | 13 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Auth.pm | 2 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Auth/Profile.pm | 22 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Dashboard.pm | 4 | ||||
-rw-r--r-- | perllib/Open311/PopulateServiceList.pm | 8 | ||||
-rw-r--r-- | t/app/controller/auth.t | 25 | ||||
-rw-r--r-- | t/app/controller/auth_profile.t | 93 | ||||
-rw-r--r-- | t/app/controller/dashboard.t | 15 | ||||
-rw-r--r-- | templates/web/base/auth/generate_token.html | 44 | ||||
-rw-r--r-- | templates/web/base/my/my.html | 3 |
12 files changed, 374 insertions, 0 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 68b07ca0d..67f2da48d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ - Admins can now unban users #1881 - Council dashboard has date range for report generation #1885 - More JavaScript-enhanced `<select multiple>` elements #1589 + - Council dashboard CSV export now has token based authentication #1911 - Consolidate various admin summary statistics page. #1919. - UK: - Use SVG logo, inlined on front page. #1887 diff --git a/perllib/Catalyst/Authentication/Credential/AccessToken.pm b/perllib/Catalyst/Authentication/Credential/AccessToken.pm new file mode 100644 index 000000000..7827c936d --- /dev/null +++ b/perllib/Catalyst/Authentication/Credential/AccessToken.pm @@ -0,0 +1,144 @@ +package Catalyst::Authentication::Credential::AccessToken; + +use strict; +use warnings; +use base 'Class::Accessor::Fast'; + +__PACKAGE__->mk_accessors(qw(token_field token_lookup)); + +our $VERSION = "0.01"; + +sub new { + my ($class, $config, $c, $realm) = @_; + my $self = { %$config }; + bless $self, $class; + return $self; +} + +sub authenticate { + my ( $self, $c, $realm, $authinfo_ignored ) = @_; + + my $auth_header = $c->req->header('Authorization') || ''; + my ($token) = $auth_header =~ /^Bearer (.*)/i; + $token ||= $c->get_param('access_token'); + return unless $token; + + my $field = $self->token_field || 'access_token'; + + my $value = $token; + if (my $lookup = $self->token_lookup) { + $value = {}; + foreach (keys %$lookup) { + my $v = $lookup->{$_}; + $v =~ s/TOKEN/$token/; + $value->{$_} = $v; + } + } + my $user_obj = $realm->find_user({ $field => $value }, $c); + if (ref $user_obj) { + return $user_obj; + } +} + +__PACKAGE__; + +__END__ + +=pod + +=head1 NAME + +Catalyst::Authentication::Credential::AccessToken - Authenticate a user +with an access token. + +=head1 SYNOPSIS + + use Catalyst qw/ + Authentication + /; + + package MyApp::Controller::Auth; + + sub login : Local { + my ( $self, $c ) = @_; + $c->authenticate(undef, "access_token"); + } + +=head1 DESCRIPTION + +This authentication credential checker takes authentication information +(most often a username) and a password, and attempts to validate the password +provided against the user retrieved from the store. + +=head1 CONFIGURATION + + # example + __PACKAGE__->config('Plugin::Authentication' => + { + default_realm => 'members', + realms => { + access_token => { + credential => { + class => 'AccessToken', + token_field => 'access_token', + }, + ... + + +=over 4 + +=item class + +The classname used for Credential. This is part of +L<Catalyst::Plugin::Authentication> and is the method by which +Catalyst::Authentication::Credential::AccessToken is loaded as the +credential validator. For this module to be used, this must be set to +'AccessToken'. + +=item token_field + +The field in the user object that contains the access token. This will vary +depending on the storage class used, but is most likely something like +'access_token'. In fact, this is so common that if this is left out of the +config, it defaults to 'access_token'. + +=item token_lookup + +If the token isn't a field on its own, but contained within another field, you +can provide a custom lookup here, where the string TOKEN in a value will be +replaced by the access token. + +=back + +=head1 USAGE + +The AccessToken credential module is very simple to use. Once configured as +indicated above, authenticating using this module is simply a matter of calling +$c->authenticate(). + + if ($c->authenticate(undef, "access_token")) { + # authentication successful + } else { + # authentication failed + } + +=head1 METHODS + +There are no publicly exported routines in the AccessToken module (or indeed in +most credential modules.) However, below is a description of the routines +required by L<Catalyst::Plugin::Authentication> for all credential modules. + +=head2 new( $config, $app, $realm ) + +Instantiate a new AccessToken object using the configuration hash provided in +$config. A reference to the application is provided as the second argument. +Note to credential module authors: new() is called during the application's +plugin setup phase, which is before the application specific controllers are +loaded. The practical upshot of this is that things like $c->model(...) will +not function as expected. + +=head2 authenticate + +Tries to log a user in. + +=cut diff --git a/perllib/FixMyStreet/App.pm b/perllib/FixMyStreet/App.pm index 1eb197e38..e47336b7c 100644 --- a/perllib/FixMyStreet/App.pm +++ b/perllib/FixMyStreet/App.pm @@ -81,6 +81,19 @@ __PACKAGE__->config( user_model => 'DB::User', }, }, + access_token => { + use_session => 0, + credential => { + class => 'AccessToken', + token_field => 'extra', + # This means the token has to be 18 characters long (as generated by AuthToken) + token_lookup => { like => "%access_token,T18:TOKEN,%" }, + }, + store => { + class => 'DBIx::Class', + user_model => 'DB::User', + }, + }, }, ); diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm index 80e407147..455022e03 100644 --- a/perllib/FixMyStreet/App/Controller/Auth.pm +++ b/perllib/FixMyStreet/App/Controller/Auth.pm @@ -419,6 +419,8 @@ Mainly intended for testing but might also be useful for ajax calls. sub check_auth : Local { my ( $self, $c ) = @_; + $c->authenticate(undef, 'access_token') unless $c->user; + # choose the response my ( $body, $code ) # = $c->user diff --git a/perllib/FixMyStreet/App/Controller/Auth/Profile.pm b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm index acffd3019..5e6fe6266 100644 --- a/perllib/FixMyStreet/App/Controller/Auth/Profile.pm +++ b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm @@ -4,6 +4,8 @@ use namespace::autoclean; BEGIN { extends 'Catalyst::Controller'; } +use mySociety::AuthToken; + =head1 NAME FixMyStreet::App::Controller::Auth::Profile - Catalyst Controller @@ -146,6 +148,26 @@ sub change_phone_success : Path('/auth/change_phone/success') { $c->res->redirect('/my'); } +sub generate_token : Path('/auth/generate_token') { + my ($self, $c) = @_; + + $c->detach( '/page_error_403_access_denied', [] ) + unless $c->user and ( $c->user->is_superuser or $c->user->from_body ); + + $c->stash->{template} = 'auth/generate_token.html'; + $c->forward('/auth/get_csrf_token'); + + if ($c->req->method eq 'POST') { + $c->forward('/auth/check_csrf_token'); + my $token = mySociety::AuthToken::random_token(); + $c->user->set_extra_metadata('access_token', $token); + $c->user->update(); + $c->stash->{token_generated} = 1; + } + + $c->stash->{existing_token} = $c->user->get_extra_metadata('access_token'); +} + __PACKAGE__->meta->make_immutable; 1; diff --git a/perllib/FixMyStreet/App/Controller/Dashboard.pm b/perllib/FixMyStreet/App/Controller/Dashboard.pm index bc9e78333..834d9c8d6 100644 --- a/perllib/FixMyStreet/App/Controller/Dashboard.pm +++ b/perllib/FixMyStreet/App/Controller/Dashboard.pm @@ -76,6 +76,10 @@ Show the summary statistics table. sub index : Path : Args(0) { my ( $self, $c ) = @_; + if ($c->get_param('export')) { + $c->authenticate(undef, "access_token"); + } + my $body = $c->stash->{body} = $c->forward('check_page_allowed'); if ($body) { diff --git a/perllib/Open311/PopulateServiceList.pm b/perllib/Open311/PopulateServiceList.pm index 764207626..dbd0d1c68 100644 --- a/perllib/Open311/PopulateServiceList.pm +++ b/perllib/Open311/PopulateServiceList.pm @@ -292,6 +292,14 @@ sub _delete_contacts_not_in_service_list { email => { -not_like => '%@%' } } ); + } elsif ($self->_current_body->name eq 'East Hertfordshire District Council') { + # For EHDC we need to leave the 'Other' category alone or reports made + # in this category will be sent only to Hertfordshire County Council. + $found_contacts = $found_contacts->search( + { + category => { '!=' => 'Other' } + } + ); } $found_contacts->update( diff --git a/t/app/controller/auth.t b/t/app/controller/auth.t index 661f99412..8d60137a2 100644 --- a/t/app/controller/auth.t +++ b/t/app/controller/auth.t @@ -251,3 +251,28 @@ FixMyStreet::override_config { is $mech->uri->path, '/my', "redirected to correct page"; }; }; + +subtest "check logging in with token" => sub { + $mech->log_out_ok; + $mech->not_logged_in_ok; + + my $user = FixMyStreet::App->model('DB::User')->find( { email => $test_email } ); + # token needs to be 18 characters + $user->set_extra_metadata('access_token', '1234567890abcdefgh'); + $user->update(); + + $mech->add_header('Authorization', 'Bearer 1234567890abcdefgh'); + $mech->logged_in_ok; + + $mech->delete_header('Authorization'); + $mech->not_logged_in_ok; + + $mech->get_ok('/auth/check_auth?access_token=1234567890abcdefgh'); + + $mech->add_header('Authorization', 'Bearer 1234567890abcdefgh'); + $user->set_extra_metadata('access_token', 'XXXXXXXXXXXXXXXXXX'); + $user->update(); + $mech->not_logged_in_ok; + + $mech->delete_header('Authorization'); +}; diff --git a/t/app/controller/auth_profile.t b/t/app/controller/auth_profile.t index 519086ff5..74edccfe6 100644 --- a/t/app/controller/auth_profile.t +++ b/t/app/controller/auth_profile.t @@ -260,3 +260,96 @@ subtest "Test change phone to existing account" => sub { is $_->user->email, $test_email; } }; + +subtest "Test superuser can access generate token page" => sub { + my $user = FixMyStreet::App->model('DB::User')->find( { email => $test_email } ); + ok $user->update({ is_superuser => 0 }), 'user not superuser'; + + $mech->log_out_ok; + $mech->get_ok('/auth'); + $mech->submit_form_ok({ + with_fields => { + username => $test_email, + password_sign_in => $test_password, + }, + }); + + $mech->content_lacks('Generate token'); + + $mech->get('/auth/generate_token'); + is $mech->res->code, 403, "access denied"; + + ok $user->update({ is_superuser => 1 }), 'user is superuser'; + + $mech->get_ok('/my'); + $mech->content_contains('Generate token'); + $mech->get_ok('/auth/generate_token'); +}; + +subtest "Test staff user can access generate token page" => sub { + my $user = FixMyStreet::App->model('DB::User')->find( { email => $test_email } ); + ok $user->update({ is_superuser => 0 }), 'user not superuser'; + + $mech->log_out_ok; + $mech->get_ok('/auth'); + $mech->submit_form_ok({ + with_fields => { + username => $test_email, + password_sign_in => $test_password, + }, + }); + + $mech->content_lacks('Generate token'); + + my $body = $mech->create_body_ok(2237, 'Oxfordshire'); + + $mech->get('/auth/generate_token'); + is $mech->res->code, 403, "access denied"; + + ok $user->update({ from_body => $body }), 'user is staff user'; + + $mech->get_ok('/my'); + $mech->content_contains('Generate token'); + $mech->get_ok('/auth/generate_token'); +}; + +subtest "Test generate token page" => sub { + my $user = FixMyStreet::App->model('DB::User')->find( { email => $test_email } ); + ok $user->update({ is_superuser => 1 }), 'user set to superuser'; + + $mech->log_out_ok; + + $mech->get_ok('/auth'); + $mech->submit_form_ok({ + with_fields => { + username => $test_email, + password_sign_in => $test_password, + }, + }); + + ok !$user->get_extra_metadata('access_token'); + + $mech->get_ok('/my'); + $mech->follow_link_ok({url => '/auth/generate_token'}); + $mech->content_lacks('Token:'); + $mech->submit_form_ok( + { with_fields => { generate_token => 'Generate token' } }, + "submit generate token form" + ); + $mech->content_contains( 'Your token has been generated', "token generated" ); + + $user->discard_changes(); + my $token = $user->get_extra_metadata('access_token'); + ok $token, 'access token set'; + + $mech->content_contains($token, 'access token displayed'); + + $mech->get_ok('/auth/generate_token'); + $mech->content_contains('Current token:'); + $mech->content_contains($token, 'access token displayed'); + $mech->content_contains('If you generate a new token'); + + $mech->log_out_ok; + $mech->add_header('Authorization', "Bearer $token"); + $mech->logged_in_ok; +} diff --git a/t/app/controller/dashboard.t b/t/app/controller/dashboard.t index 7d0b0d217..b53056968 100644 --- a/t/app/controller/dashboard.t +++ b/t/app/controller/dashboard.t @@ -184,6 +184,21 @@ FixMyStreet::override_config { is $rows[5]->[16], '179716', 'Correct Northing conversion'; }; + subtest 'export as csv using token' => sub { + $mech->log_out_ok; + + $counciluser->set_extra_metadata('access_token', '1234567890abcdefgh'); + $counciluser->update(); + + $mech->get_ok('/dashboard?export=1'); + like $mech->res->header('Content-type'), qr'text/html'; + $mech->content_lacks('Report ID'); + + $mech->add_header('Authorization', 'Bearer 1234567890abcdefgh'); + $mech->get_ok('/dashboard?export=1'); + like $mech->res->header('Content-type'), qr'text/csv'; + $mech->content_contains('Report ID'); + }; }; sub test_table { diff --git a/templates/web/base/auth/generate_token.html b/templates/web/base/auth/generate_token.html new file mode 100644 index 000000000..157335047 --- /dev/null +++ b/templates/web/base/auth/generate_token.html @@ -0,0 +1,44 @@ +[% +INCLUDE 'header.html', title = loc('Generate token'), bodyclass = 'fullwidthpage' +%] + +[% IF token_generated %] + + <div class="confirmation-header"> + <h1>[% loc('Your token has been generated') %]</h1> + + <p> + <strong>[% loc('Token:') %]</strong> + <span>[% existing_token | html %]</span> + </p> + + <p><a href="/my">[% loc('Your account') %]</a></p> + </div> + +[% ELSE %] + +<h1>[% loc('Generate token') %]</h1> + +<form action="[% c.uri_for_action('/auth/profile/generate_token') %]" method="post" name="generate_token"> + <input type="hidden" name="token" value="[% csrf_token %]"> + + [% IF existing_token %] + <p> + <strong>[% loc('Current token:') %]</strong> + <span>[% existing_token | html %]</span> + </p> + [% END %] + + <p> + <input name="generate_token" type="submit" class="btn" value="[% existing_token ? loc('Replace token') : loc('Generate token') %]"> + </p> +</form> + +[% IF existing_token %] + <p> + [% loc('If you generate a new token the existing token will no longer work.') %] + </p> +[% END %] +[% END %] + +[% INCLUDE 'footer.html' %] diff --git a/templates/web/base/my/my.html b/templates/web/base/my/my.html index 68fbc2ee8..e10dd96c8 100644 --- a/templates/web/base/my/my.html +++ b/templates/web/base/my/my.html @@ -61,6 +61,9 @@ li .my-account-buttons a { <p class="my-account-buttons"> <a href="/auth/change_password">[% loc('Change password') %]</a> + [% IF c.user AND (c.user.from_body OR c.user.is_superuser) %] + <a href="/auth/generate_token">[% loc('Generate token') %]</a> + [% END %] <a href="/auth/sign_out">[% loc('Sign out') %]</a> </p> |