aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md1
-rw-r--r--perllib/Catalyst/Authentication/Credential/AccessToken.pm144
-rw-r--r--perllib/FixMyStreet/App.pm13
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth.pm2
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth/Profile.pm22
-rw-r--r--perllib/FixMyStreet/App/Controller/Dashboard.pm4
-rw-r--r--perllib/Open311/PopulateServiceList.pm8
-rw-r--r--t/app/controller/auth.t25
-rw-r--r--t/app/controller/auth_profile.t93
-rw-r--r--t/app/controller/dashboard.t15
-rw-r--r--templates/web/base/auth/generate_token.html44
-rw-r--r--templates/web/base/my/my.html3
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>