diff options
Diffstat (limited to 'perllib/FixMyStreet')
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Auth.pm | 46 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Auth/Social.pm | 208 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Report/New.pm | 54 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Report/Update.pm | 27 | ||||
-rw-r--r-- | perllib/FixMyStreet/Cobrand/Default.pm | 21 | ||||
-rw-r--r-- | perllib/FixMyStreet/Cobrand/UKCouncils.pm | 50 | ||||
-rw-r--r-- | perllib/FixMyStreet/Cobrand/Westminster.pm | 148 | ||||
-rw-r--r-- | perllib/FixMyStreet/DB/Result/User.pm | 52 |
8 files changed, 526 insertions, 80 deletions
diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm index c194045b9..964d8f19a 100644 --- a/perllib/FixMyStreet/App/Controller/Auth.pm +++ b/perllib/FixMyStreet/App/Controller/Auth.pm @@ -44,13 +44,12 @@ sub general : Path : Args(0) { # decide which action to take $c->detach('code_sign_in') if $clicked_sign_in_by_code || ($data_email && !$data_password); - if (!$data_username && !$data_password && !$data_email) { - $c->detach('social/facebook_sign_in') if $c->get_param('facebook_sign_in'); - $c->detach('social/twitter_sign_in') if $c->get_param('twitter_sign_in'); + if (!$data_username && !$data_password && !$data_email && $c->get_param('social_sign_in')) { + $c->forward('social/handle_sign_in'); } - $c->forward( 'sign_in', [ $data_username ] ) - && $c->detach( 'redirect_on_signin', [ $c->get_param('r') ] ); + $c->forward( 'sign_in', [ $data_username ] ) + && $c->detach( 'redirect_on_signin', [ $c->get_param('r') ] ); } @@ -180,10 +179,13 @@ sub email_sign_in : Private { name => $c->get_param('name'), password => $user->password, }; - $token_data->{facebook_id} = $c->session->{oauth}{facebook_id} - if $c->get_param('oauth_need_email') && $c->session->{oauth}{facebook_id}; - $token_data->{twitter_id} = $c->session->{oauth}{twitter_id} - if $c->get_param('oauth_need_email') && $c->session->{oauth}{twitter_id}; + + if ($c->get_param('oauth_need_email')) { + $token_data->{name} = $c->session->{oauth}{name} + if $c->session->{oauth}{name} && !$token_data->{name}; + $c->forward('set_oauth_token_data', [ $token_data ]); + } + if ($c->stash->{current_user}) { $token_data->{old_user_id} = $c->stash->{current_user}->id; $token_data->{r} = 'auth/change_email/success'; @@ -214,6 +216,14 @@ sub get_token : Private { return $data; } +sub set_oauth_token_data : Private { + my ( $self, $c, $token_data ) = @_; + + foreach (qw/facebook_id twitter_id oidc_id extra logout_redirect_uri/) { + $token_data->{$_} = $c->session->{oauth}{$_} if $c->session->{oauth}{$_}; + } +} + =head2 token Handle the 'email_sign_in' tokens. Find the account for the email address @@ -272,9 +282,21 @@ sub process_login : Private { $user->password( $data->{password}, 1 ) if $data->{password}; $user->facebook_id( $data->{facebook_id} ) if $data->{facebook_id}; $user->twitter_id( $data->{twitter_id} ) if $data->{twitter_id}; + $user->add_oidc_id( $data->{oidc_id} ) if $data->{oidc_id}; + $user->extra({ + %{ $user->get_extra() }, + %{ $data->{extra} } + }) if $data->{extra}; + $user->update_or_insert; $c->authenticate( { $type => $data->{$type}, $ver => 1 }, 'no_password' ); + if ($data->{logout_redirect_uri}) { + $c->session->{oauth} ||= (); + $c->session->{oauth}{logout_redirect_uri} = $data->{logout_redirect_uri}; + } + + # send the user to their page $c->detach( 'redirect_on_signin', [ $data->{r}, $data->{p} ] ); } @@ -429,6 +451,12 @@ Log the user out. Tell them we've done so. sub sign_out : Local { my ( $self, $c ) = @_; $c->logout(); + + if ( $c->sessionid && $c->session->{oauth} && $c->session->{oauth}{logout_redirect_uri} ) { + $c->response->redirect($c->session->{oauth}{logout_redirect_uri}); + delete $c->session->{oauth}{logout_redirect_uri}; + $c->detach; + } } sub ajax_sign_in : Path('ajax/sign_in') { diff --git a/perllib/FixMyStreet/App/Controller/Auth/Social.pm b/perllib/FixMyStreet/App/Controller/Auth/Social.pm index 097cac984..aa3177163 100644 --- a/perllib/FixMyStreet/App/Controller/Auth/Social.pm +++ b/perllib/FixMyStreet/App/Controller/Auth/Social.pm @@ -6,6 +6,10 @@ BEGIN { extends 'Catalyst::Controller'; } use Net::Facebook::Oauth2; use Net::Twitter::Lite::WithAPIv1_1; +use OIDC::Lite::Client::WebServer::Azure; +use URI::Escape; + +use mySociety::AuthToken; =head1 NAME @@ -13,10 +17,26 @@ FixMyStreet::App::Controller::Auth::Social - Catalyst Controller =head1 DESCRIPTION -Controller for the Facebook/Twitter authentication. +Controller for the Facebook/Twitter/OpenID Connect authentication. =head1 METHODS +=head2 handle_sign_in + +Forwards to the appropriate (facebook|twitter|oidc)_sign_in method +based on the social_sign_in parameter + +=cut + +sub handle_sign_in : Private { + my ($self, $c) = @_; + + $c->detach('facebook_sign_in') if $c->get_param('social_sign_in') eq 'facebook'; + $c->detach('twitter_sign_in') if $c->get_param('social_sign_in') eq 'twitter'; + $c->detach('oidc_sign_in') if $c->get_param('social_sign_in') eq 'oidc'; + +} + =head2 facebook_sign_in Starts the Facebook authentication sequence. @@ -142,6 +162,144 @@ sub twitter_callback: Path('/auth/Twitter') : Args(0) { $c->forward('oauth_success', [ 'twitter', $info->{id}, $info->{name} ]); } +sub oidc : Private { + my ($self, $c) = @_; + + my $config = $c->cobrand->feature('oidc_login'); + + OIDC::Lite::Client::WebServer::Azure->new( + id => $config->{client_id}, + secret => $config->{secret}, + authorize_uri => $config->{auth_uri}, + access_token_uri => $config->{token_uri}, + ); +} + +sub oidc_sign_in : Private { + my ( $self, $c ) = @_; + + $c->detach( '/page_error_403_access_denied', [] ) if FixMyStreet->config('SIGNUPS_DISABLED'); + $c->detach( '/page_error_400_bad_request', [] ) unless $c->cobrand->feature('oidc_login'); + + my $oidc = $c->forward('oidc'); + my $nonce = $self->generate_nonce(); + my $url = $oidc->uri_to_redirect( + redirect_uri => $c->uri_for('/auth/OIDC'), + scope => 'openid', + state => 'login', + extra => { + response_mode => 'form_post', + nonce => $nonce, + }, + ); + + my %oauth; + $oauth{return_url} = $c->get_param('r'); + $oauth{detach_to} = $c->stash->{detach_to}; + $oauth{detach_args} = $c->stash->{detach_args}; + $oauth{nonce} = $nonce; + + # The OIDC endpoint may require a specific URI to be called to log the user + # out when they log out of FMS. + if ( my $redirect_uri = $c->cobrand->feature('oidc_login')->{logout_uri} ) { + $redirect_uri .= "?post_logout_redirect_uri="; + $redirect_uri .= URI::Escape::uri_escape( $c->uri_for('/auth/sign_out') ); + $oauth{logout_redirect_uri} = $redirect_uri; + } + + $c->session->{oauth} = \%oauth; + $c->res->redirect($url); +} + +sub oidc_callback: Path('/auth/OIDC') : Args(0) { + my ( $self, $c ) = @_; + + my $oidc = $c->forward('oidc'); + + if ($c->get_param('error')) { + my $error_desc = $c->get_param('error_description'); + my $password_reset_uri = $c->cobrand->feature('oidc_login')->{password_reset_uri}; + if ($password_reset_uri && $error_desc =~ /^AADB2C90118:/) { + my $url = $oidc->uri_to_redirect( + uri => $password_reset_uri, + redirect_uri => $c->uri_for('/auth/OIDC'), + scope => 'openid', + state => 'password_reset', + extra => { + response_mode => 'form_post', + }, + ); + $c->res->redirect($url); + $c->detach; + } else { + $c->detach('oauth_failure'); + } + } + $c->detach('/page_error_400_bad_request', []) unless $c->get_param('code') && $c->get_param('state'); + + # After a password reset on the OIDC endpoint the user isn't properly logged + # in, so redirect them to the usual OIDC login process. + if ( $c->get_param('state') eq 'password_reset' ) { + # The user may have reset their password as part of the sign-in-during-report + # process, so preserve their report and redirect them to the right place + # if that happened. + if ( $c->session->{oauth} ) { + $c->stash->{detach_to} = $c->session->{oauth}{detach_to}; + $c->stash->{detach_args} = $c->session->{oauth}{detach_args}; + } + $c->detach('oidc_sign_in', []); + } + + # The only other valid state param is 'login' at this point. + $c->detach('/page_error_400_bad_request', []) unless $c->get_param('state') eq 'login'; + + my $id_token; + eval { + $id_token = $oidc->get_access_token( + code => $c->get_param('code'), + ); + }; + if ($@) { + (my $message = $@) =~ s/at [^ ]*Auth.pm.*//; + $c->detach('/page_error_500_internal_error', [ $message ]); + } + + $c->detach('oauth_failure') unless $id_token; + + # sanity check the token audience is us... + $c->detach('/page_error_500_internal_error', ['invalid id_token']) unless $id_token->payload->{aud} eq $c->cobrand->feature('oidc_login')->{client_id}; + + # check that the nonce matches what we set in the user session + $c->detach('/page_error_500_internal_error', ['invalid id_token']) unless $id_token->payload->{nonce} eq $c->session->{oauth}{nonce}; + + # Some claims need parsing into a friendlier format + # XXX check how much of this is Westminster/Azure-specific + my $name = join(" ", $id_token->payload->{given_name}, $id_token->payload->{family_name}); + my $email = $id_token->payload->{email}; + # WCC Azure provides a single email address as an array for some reason + my $emails = $id_token->payload->{emails}; + if ($emails && @$emails) { + $email = $emails->[0]; + } + + # There's a chance that a user may have multiple OIDC logins, so build a namespaced uid to prevent collisions + my $uid = join(":", $c->cobrand->moniker, $c->cobrand->feature('oidc_login')->{client_id}, $id_token->payload->{sub}); + + # The cobrand may want to set values in the user extra field, e.g. a CRM ID + # which is passed to Open311 with reports made by this user. + my $extra = $c->cobrand->call_hook(oidc_user_extra => $id_token); + + $c->forward('oauth_success', [ 'oidc', $uid, $name, $email, $extra ]); +} + +# Just a wrapper around random_token to make mocking easier. +sub generate_nonce : Private { + my ($self, $c) = @_; + + return mySociety::AuthToken::random_token(); +} + + sub oauth_failure : Private { my ( $self, $c ) = @_; @@ -155,30 +313,64 @@ sub oauth_failure : Private { } sub oauth_success : Private { - my ($self, $c, $type, $uid, $name, $email) = @_; + my ($self, $c, $type, $uid, $name, $email, $extra) = @_; my $user; if ($email) { - # Only Facebook gets here + # Only Facebook & OIDC get here # We've got an ID and an email address + # Remove any existing mention of this ID - my $existing = $c->model('DB::User')->find( { facebook_id => $uid } ); - $existing->update( { facebook_id => undef } ) if $existing; - # Get or create a user, give it this Facebook ID + my $existing; + if ($type eq 'facebook') { + $existing = $c->model('DB::User')->find( { $type . '_id' => $uid } ); + $existing->update( { $type . '_id' => undef } ) if $existing; + } elsif ( $type eq 'oidc' ) { + $existing = $c->model('DB::User')->find( { oidc_ids => \[ + '&& ?', [ oidc_ids => [ $uid ] ] + ] } ); + $existing->remove_oidc_id( $uid ) if $existing; + } + + # Get or create a user, give it this Facebook/OIDC ID $user = $c->model('DB::User')->find_or_new( { email => $email } ); - $user->facebook_id($uid); + if ( $type eq 'facebook' ) { + $user->facebook_id($uid); + } elsif ( $type eq 'oidc' ) { + $user->add_oidc_id($uid); + } $user->name($name); + if ($extra) { + $user->extra({ + %{ $user->get_extra() }, + %$extra + }); + } $user->in_storage() ? $user->update : $user->insert; } else { # We've got an ID, but no email - $user = $c->model('DB::User')->find( { $type . '_id' => $uid } ); + if ($type eq 'oidc') { + $user = $c->model('DB::User')->find( { oidc_ids => \[ + '&& ?', [ oidc_ids => [ $uid ] ] + ] } ); + } else { + $user = $c->model('DB::User')->find( { $type . '_id' => $uid } ); + } if ($user) { # Matching ID in our database $user->name($name); + if ($extra) { + $user->extra({ + %{ $user->get_extra() }, + %$extra + }); + } $user->update; } else { # No matching ID, store ID for use later $c->session->{oauth}{$type . '_id'} = $uid; + $c->session->{oauth}{name} = $name; + $c->session->{oauth}{extra} = $extra; $c->stash->{oauth_need_email} = 1; } } diff --git a/perllib/FixMyStreet/App/Controller/Report/New.pm b/perllib/FixMyStreet/App/Controller/Report/New.pm index 120467905..a19c43af8 100644 --- a/perllib/FixMyStreet/App/Controller/Report/New.pm +++ b/perllib/FixMyStreet/App/Controller/Report/New.pm @@ -803,10 +803,17 @@ sub process_user : Private { # Report form includes two username fields: #form_username_register and #form_username_sign_in $params{username} = (first { $_ } $c->get_param_list('username')) || ''; - if ( $c->cobrand->allow_anonymous_reports ) { + my $anon_button = $c->cobrand->allow_anonymous_reports eq 'button' && $c->get_param('report_anonymously'); + my $anon_fallback = $c->cobrand->allow_anonymous_reports eq '1' && !$c->user_exists && !$params{username}; + if ($anon_button || $anon_fallback) { my $anon_details = $c->cobrand->anonymous_account; - $params{username} ||= $anon_details->{email}; - $params{name} ||= $anon_details->{name}; + my $user = $c->model('DB::User')->find_or_new({ email => $anon_details->{email} }); + $user->name($anon_details->{name}); + $report->user($user); + $report->name($user->name); + $c->stash->{no_reporter_alert} = 1; + $c->stash->{contributing_as_anonymous_user} = 1; + return 1; } # The user is already signed in. Extra bare block for 'last'. @@ -938,6 +945,11 @@ sub process_report : Private { $c->stash->{contributing_as_body} = $user->contributing_as('body', $c, $c->stash->{bodies}); $c->stash->{contributing_as_anonymous_user} = $user->contributing_as('anonymous_user', $c, $c->stash->{bodies}); } + # This is also done in process_user, but is needed here for anonymous() just below + my $anon_button = $c->cobrand->allow_anonymous_reports eq 'button' && $c->get_param('report_anonymously'); + if ($anon_button) { + $c->stash->{contributing_as_anonymous_user} = 1; + } # set some simple bool values (note they get inverted) if ($c->stash->{contributing_as_body}) { @@ -1129,12 +1141,13 @@ sub check_for_errors : Private { $c->stash->{field_errors} ||= {}; my %field_errors = $c->cobrand->report_check_for_errors( $c ); + my $report = $c->stash->{report}; + # Zurich, we don't care about title or name # There is no title, and name is optional if ( $c->cobrand->moniker eq 'zurich' ) { delete $field_errors{title}; delete $field_errors{name}; - my $report = $c->stash->{report}; $report->title( Utils::cleanup_text( substr($report->detail, 0, 25) ) ); # We only want to validate the phone number web requests (where the @@ -1154,8 +1167,13 @@ sub check_for_errors : Private { delete $field_errors{name}; } + # If we're making an anonymous report, we do not care about the name field + if ( $c->stash->{contributing_as_anonymous_user} ) { + delete $field_errors{name}; + } + # if using social login then we don't care about other errors - $c->stash->{is_social_user} = $c->get_param('facebook_sign_in') || $c->get_param('twitter_sign_in'); + $c->stash->{is_social_user} = $c->get_param('social_sign_in') ? 1 : 0; if ( $c->stash->{is_social_user} ) { delete $field_errors{name}; delete $field_errors{username}; @@ -1179,7 +1197,6 @@ sub check_for_errors : Private { if ( $c->cobrand->allow_anonymous_reports ) { my $anon_details = $c->cobrand->anonymous_account; - my $report = $c->stash->{report}; $report->user->email(undef) if $report->user->email eq $anon_details->{email}; $report->name(undef) if $report->name eq $anon_details->{name}; } @@ -1197,10 +1214,8 @@ sub tokenize_user : Private { password => $report->user->password, title => $report->user->title, }; - $c->stash->{token_data}{facebook_id} = $c->session->{oauth}{facebook_id} - if $c->get_param('oauth_need_email') && $c->session->{oauth}{facebook_id}; - $c->stash->{token_data}{twitter_id} = $c->session->{oauth}{twitter_id} - if $c->get_param('oauth_need_email') && $c->session->{oauth}{twitter_id}; + $c->forward('/auth/set_oauth_token_data', [ $c->stash->{token_data} ]) + if $c->get_param('oauth_need_email'); } sub send_problem_confirm_email : Private { @@ -1308,7 +1323,19 @@ sub process_confirmation : Private { for (qw(name title facebook_id twitter_id)) { $problem->user->$_( $data->{$_} ) if $data->{$_}; } + $problem->user->add_oidc_id($data->{oidc_id}) if $data->{oidc_id}; + $problem->user->extra({ + %{ $problem->user->get_extra() }, + %{ $data->{extra} } + }) if $data->{extra}; + $problem->user->update; + + # Make sure OIDC logout redirection happens, if applicable + if ($data->{logout_redirect_uri}) { + $c->session->{oauth} ||= (); + $c->session->{oauth}{logout_redirect_uri} = $data->{logout_redirect_uri}; + } } if ($problem->user->email_verified) { $c->authenticate( { email => $problem->user->email, email_verified => 1 }, 'no_password' ); @@ -1368,11 +1395,7 @@ sub save_user_and_report : Private { $c->stash->{detach_to} = '/report/new/oauth_callback'; $c->stash->{detach_args} = [$token->token]; - if ( $c->get_param('facebook_sign_in') ) { - $c->detach('/auth/social/facebook_sign_in'); - } elsif ( $c->get_param('twitter_sign_in') ) { - $c->detach('/auth/social/twitter_sign_in'); - } + $c->forward('/auth/social/handle_sign_in') if $c->get_param('social_sign_in'); } # Save or update the user if appropriate @@ -1531,6 +1554,7 @@ sub create_reporter_alert : Private { my ( $self, $c ) = @_; return if $c->stash->{no_reporter_alert}; + return if $c->cobrand->call_hook('suppress_reporter_alerts'); my $problem = $c->stash->{report}; my $alert = $c->model('DB::Alert')->find_or_create( { diff --git a/perllib/FixMyStreet/App/Controller/Report/Update.pm b/perllib/FixMyStreet/App/Controller/Report/Update.pm index cbedf7a01..1825286ca 100644 --- a/perllib/FixMyStreet/App/Controller/Report/Update.pm +++ b/perllib/FixMyStreet/App/Controller/Report/Update.pm @@ -244,8 +244,7 @@ This makes sure we only proceed to processing if we've had the form submitted sub check_form_submitted : Private { my ( $self, $c ) = @_; - return if $c->stash->{problem}->get_extra_metadata('closed_updates'); - return if $c->cobrand->call_hook(updates_disallowed => $c->stash->{problem}); + return if $c->cobrand->updates_disallowed($c->stash->{problem}); return $c->get_param('submit_update') || ''; } @@ -366,7 +365,7 @@ sub check_for_errors : Private { ); # if using social login then we don't care about name and email errors - $c->stash->{is_social_user} = $c->get_param('facebook_sign_in') || $c->get_param('twitter_sign_in'); + $c->stash->{is_social_user} = $c->get_param('social_sign_in') ? 1 : 0; if ( $c->stash->{is_social_user} ) { delete $field_errors{name}; delete $field_errors{username}; @@ -404,10 +403,8 @@ sub tokenize_user : Private { name => $update->user->name, password => $update->user->password, }; - $c->stash->{token_data}{facebook_id} = $c->session->{oauth}{facebook_id} - if $c->get_param('oauth_need_email') && $c->session->{oauth}{facebook_id}; - $c->stash->{token_data}{twitter_id} = $c->session->{oauth}{twitter_id} - if $c->get_param('oauth_need_email') && $c->session->{oauth}{twitter_id}; + $c->forward('/auth/set_oauth_token_data', [ $c->stash->{token_data} ]) + if $c->get_param('oauth_need_email'); } =head2 save_update @@ -440,11 +437,7 @@ sub save_update : Private { $c->stash->{detach_to} = '/report/update/oauth_callback'; $c->stash->{detach_args} = [$token->token]; - if ( $c->get_param('facebook_sign_in') ) { - $c->detach('/auth/social/facebook_sign_in'); - } elsif ( $c->get_param('twitter_sign_in') ) { - $c->detach('/auth/social/twitter_sign_in'); - } + $c->forward('/auth/social/handle_sign_in') if $c->get_param('social_sign_in'); } if ( $c->cobrand->never_confirm_updates ) { @@ -585,8 +578,18 @@ sub process_confirmation : Private { for (qw(name facebook_id twitter_id)) { $comment->user->$_( $data->{$_} ) if $data->{$_}; } + $comment->user->add_oidc_id($data->{oidc_id}) if $data->{oidc_id}; + $comment->user->extra({ + %{ $comment->user->get_extra() }, + %{ $data->{extra} } + }) if $data->{extra}; $comment->user->password( $data->{password}, 1 ) if $data->{password}; $comment->user->update; + # Make sure OIDC logout redirection happens, if applicable + if ($data->{logout_redirect_uri}) { + $c->session->{oauth} ||= (); + $c->session->{oauth}{logout_redirect_uri} = $data->{logout_redirect_uri}; + } } if ($comment->user->email_verified) { diff --git a/perllib/FixMyStreet/Cobrand/Default.pm b/perllib/FixMyStreet/Cobrand/Default.pm index eaf27e3bc..a6c6f34c4 100644 --- a/perllib/FixMyStreet/Cobrand/Default.pm +++ b/perllib/FixMyStreet/Cobrand/Default.pm @@ -503,6 +503,19 @@ allowing them to report them as offensive. sub allow_update_reporting { return 0; } +=item updates_disallowed + +Returns a boolean indicating whether updates on a particular report are allowed +or not. Default behaviour is disallowed if "closed_updates" metadata is set. + +=cut + +sub updates_disallowed { + my ($self, $problem) = @_; + return 1 if $problem->get_extra_metadata('closed_updates'); + return 0; +} + =item geocode_postcode Given a QUERY, return LAT/LON and/or ERROR. @@ -1058,8 +1071,10 @@ sub never_confirm_reports { 0; } =item allow_anonymous_reports -If true then can have reports that are truely anonymous - i.e with no email or name. You -need to also put details in the anonymous_account function too. +If true then a report submission with no user details will default to the user +given via the anonymous_account function, and create it anonymously. If set to +'button', then this will happen only when a report_anonymously button is +pressed in the front end, rather than whenever a username is not provided. =cut @@ -1264,7 +1279,7 @@ sub allow_report_extra_fields { 0 } sub social_auth_enabled { my $self = shift; - my $key_present = FixMyStreet->config('FACEBOOK_APP_ID') or FixMyStreet->config('TWITTER_KEY'); + my $key_present = FixMyStreet->config('FACEBOOK_APP_ID') || FixMyStreet->config('TWITTER_KEY'); return $key_present && !$self->call_hook("social_auth_disabled"); } diff --git a/perllib/FixMyStreet/Cobrand/UKCouncils.pm b/perllib/FixMyStreet/Cobrand/UKCouncils.pm index 794c3dec6..b59c8990b 100644 --- a/perllib/FixMyStreet/Cobrand/UKCouncils.pm +++ b/perllib/FixMyStreet/Cobrand/UKCouncils.pm @@ -273,10 +273,9 @@ see Buckinghamshire or Lincolnshire for an example. sub lookup_site_code { my $self = shift; my $row = shift; - my $buffer = shift; + my $field = shift; - my $cfg = $self->lookup_site_code_config; - $cfg->{buffer} = $buffer if $buffer; + my $cfg = $self->lookup_site_code_config($field); my ($x, $y) = $row->local_coords; my $features = $self->_fetch_features($cfg, $x, $y); @@ -288,17 +287,7 @@ sub _fetch_features { my $buffer = $cfg->{buffer}; my ($w, $s, $e, $n) = ($x-$buffer, $y-$buffer, $x+$buffer, $y+$buffer); - my $uri = URI->new($cfg->{url}); - $uri->query_form( - REQUEST => "GetFeature", - SERVICE => "WFS", - SRSNAME => $cfg->{srsname}, - TYPENAME => $cfg->{typename}, - VERSION => "1.1.0", - outputformat => "geojson", - BBOX => "$w,$s,$e,$n" - ); - + my $uri = $self->_fetch_features_url($cfg, $w, $s, $e,$n); my $response = get($uri) or return; my $j = JSON->new->utf8->allow_nonref; @@ -313,6 +302,24 @@ sub _fetch_features { return $j->{features}; } +sub _fetch_features_url { + my ($self, $cfg, $w, $s, $e, $n) = @_; + + my $uri = URI->new($cfg->{url}); + $uri->query_form( + REQUEST => "GetFeature", + SERVICE => "WFS", + SRSNAME => $cfg->{srsname}, + TYPENAME => $cfg->{typename}, + VERSION => "1.1.0", + outputformat => "geojson", + BBOX => "$w,$s,$e,$n" + ); + + return $uri; +} + + sub _nearest_feature { my ($self, $cfg, $x, $y, $features) = @_; @@ -321,16 +328,25 @@ sub _nearest_feature { my $site_code = ''; my $nearest; + # We shouldn't receive anything aside from these geometry types, but belt and braces. + my $accept_types = $cfg->{accept_types} || { + LineString => 1, + MultiLineString => 1 + }; + for my $feature ( @{$features || []} ) { next unless $cfg->{accept_feature}($feature); - - # We shouldn't receive anything aside from these two geometry types, but belt and braces. - next unless $feature->{geometry}->{type} eq 'MultiLineString' || $feature->{geometry}->{type} eq 'LineString'; + next unless $accept_types->{$feature->{geometry}->{type}}; my @linestrings = @{ $feature->{geometry}->{coordinates} }; if ( $feature->{geometry}->{type} eq 'LineString') { @linestrings = ([ @linestrings ]); } + # If it is a point, upgrade it to a one-segment zero-length + # MultiLineString so it can be compared by the distance function. + if ( $feature->{geometry}->{type} eq 'Point') { + @linestrings = ([ [ @linestrings ], [ @linestrings ] ]); + } foreach my $coordinates (@linestrings) { for (my $i=0; $i<@$coordinates-1; $i++) { diff --git a/perllib/FixMyStreet/Cobrand/Westminster.pm b/perllib/FixMyStreet/Cobrand/Westminster.pm new file mode 100644 index 000000000..3d99e59c4 --- /dev/null +++ b/perllib/FixMyStreet/Cobrand/Westminster.pm @@ -0,0 +1,148 @@ +package FixMyStreet::Cobrand::Westminster; +use base 'FixMyStreet::Cobrand::Whitelabel'; + +use strict; +use warnings; + +use URI; + +sub council_area_id { return 2504; } +sub council_area { return 'Westminster'; } +sub council_name { return 'Westminster City Council'; } +sub council_url { return 'Westminster'; } + +sub disambiguate_location { + my $self = shift; + return { + %{ $self->SUPER::disambiguate_location() }, + town => 'Westminster', + centre => '51.513444,-0.160467', + bounds => [ 51.483816, -0.216088, 51.539793, -0.111101 ], + }; +} + +sub get_geocoder { + return 'OSM'; # default of Bing gives poor results, let's try overriding. +} + +sub enter_postcode_text { + my ($self) = @_; + return 'Enter a ' . $self->council_area . ' postcode, or street name'; +} + +sub send_questionnaires { 0 } + +sub updates_disallowed { + my $self = shift; + my $c = $self->{c}; + + # Only WCC staff and superusers can leave updates + my $staff = $c->user_exists && $c->user->from_body && $c->user->from_body->name eq $self->council_name; + my $superuser = $c->user_exists && $c->user->is_superuser; + + return ( $staff || $superuser ) ? 0 : 1; + } + +sub suppress_reporter_alerts { 1 } + +sub social_auth_enabled { + my $self = shift; + + return $self->feature('oidc_login') ? 1 : 0; +} + +sub allow_anonymous_reports { 'button' } + +sub admin_user_domain { 'westminster.gov.uk' } + +sub anonymous_account { + my $self = shift; + return { + email => $self->feature('anonymous_account') . '@' . $self->admin_user_domain, + name => 'Anonymous user', + }; +} + +sub oidc_user_extra { + my ($self, $id_token) = @_; + + # Westminster want the CRM ID of the user to be passed in the + # account_id field of Open311 POST Service Requests, so + # extract it from the id token and store in user extra + # if it's available. + my $crm_id = $id_token->payload->{extension_CrmContactId}; + + return { + $crm_id ? (westminster_account_id => $crm_id) : (), + }; +} + +sub open311_config { + my ($self, $row, $h, $params) = @_; + + my $id = $row->user->get_extra_metadata('westminster_account_id'); + # Westminster require 0 as the account ID if there's no MyWestminster ID. + $h->{account_id} = $id || '0'; + + my $extra = $row->get_extra_fields; + + # Reports made via the app probably won't have a USRN because we don't + # display the road layer. Instead we'll look up the closest asset from the + # asset service at the point we're sending the report over Open311. + if (!$row->get_extra_field_value('USRN')) { + if (my $ref = $self->lookup_site_code($row, 'USRN')) { + push @$extra, { name => 'USRN', value => $ref }; + } + } + + # Some categories require a UPRN to be set, so if the field is present + # but empty then look it up. + my $fields = $row->get_extra_fields; + my ($uprn_field) = grep { $_->{name} eq 'UPRN' } @$fields; + if ( $uprn_field && !$uprn_field->{value} ) { + if (my $ref = $self->lookup_site_code($row, 'UPRN')) { + push @$extra, { name => 'UPRN', value => $ref }; + } + } + + $row->set_extra_fields(@$extra); +} + +sub lookup_site_code_config { + my ( $self, $field ) = @_; + # uncoverable subroutine + # uncoverable statement + my $layer = $field eq 'USRN' ? '40' : '25'; # 25 is UPRN + + my %cfg = ( + buffer => 1000, # metres + proxy_url => "https://tilma.staging.mysociety.org/resource-proxy/proxy.php", + url => "https://westminster.assets/$layer/query", + property => $field, + accept_feature => sub { 1 }, + + # UPRNs are Point geometries, so make sure they're allowed by + # _nearest_feature. + ( $field eq 'UPRN' ) ? (accept_types => { Point => 1 }) : (), + ); + return \%cfg; +} + +sub _fetch_features_url { + my ($self, $cfg, $w, $s, $e, $n) = @_; + + # Westminster's asset proxy has a slightly different calling style to + # a standard WFS server. + my $uri = URI->new($cfg->{url}); + $uri->query_form( + inSR => "27700", + outSR => "27700", + f => "geojson", + outFields => $cfg->{property}, + geometry => "$w,$s,$e,$n", + ); + + return $cfg->{proxy_url} . "?" . $uri->as_string; +} + +1; diff --git a/perllib/FixMyStreet/DB/Result/User.pm b/perllib/FixMyStreet/DB/Result/User.pm index fc651b4d1..4ea7524bb 100644 --- a/perllib/FixMyStreet/DB/Result/User.pm +++ b/perllib/FixMyStreet/DB/Result/User.pm @@ -24,22 +24,30 @@ __PACKAGE__->add_columns( }, "email", { data_type => "text", is_nullable => 1 }, - "email_verified", - { data_type => "boolean", default_value => \"false", is_nullable => 0 }, "name", { data_type => "text", is_nullable => 1 }, "phone", { data_type => "text", is_nullable => 1 }, - "phone_verified", - { data_type => "boolean", default_value => \"false", is_nullable => 0 }, "password", { data_type => "text", default_value => "", is_nullable => 0 }, - "from_body", - { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, "flagged", { data_type => "boolean", default_value => \"false", is_nullable => 0 }, + "from_body", + { data_type => "integer", is_foreign_key => 1, is_nullable => 1 }, + "title", + { data_type => "text", is_nullable => 1 }, + "facebook_id", + { data_type => "bigint", is_nullable => 1 }, + "twitter_id", + { data_type => "bigint", is_nullable => 1 }, "is_superuser", { data_type => "boolean", default_value => \"false", is_nullable => 0 }, + "extra", + { data_type => "text", is_nullable => 1 }, + "email_verified", + { data_type => "boolean", default_value => \"false", is_nullable => 0 }, + "phone_verified", + { data_type => "boolean", default_value => \"false", is_nullable => 0 }, "created", { data_type => "timestamp", @@ -54,16 +62,10 @@ __PACKAGE__->add_columns( is_nullable => 0, original => { default_value => \"now()" }, }, - "title", - { data_type => "text", is_nullable => 1 }, - "twitter_id", - { data_type => "bigint", is_nullable => 1 }, - "facebook_id", - { data_type => "bigint", is_nullable => 1 }, - "extra", - { data_type => "text", is_nullable => 1 }, "area_ids", { data_type => "integer[]", is_nullable => 1 }, + "oidc_ids", + { data_type => "text[]", is_nullable => 1 }, ); __PACKAGE__->set_primary_key("id"); __PACKAGE__->add_unique_constraint("users_facebook_id_key", ["facebook_id"]); @@ -129,8 +131,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-05-23 18:03:28 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:qtmzA7ywVkyQpjLh1ienNg +# Created by DBIx::Class::Schema::Loader v0.07035 @ 2019-06-20 16:31:44 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Ryb6giJm/7N7svg/d+2GeA # These are not fully unique constraints (they only are when the *_verified # is true), but this is managed in ResultSet::User's find() wrapper. @@ -142,6 +144,7 @@ __PACKAGE__->rabx_column('extra'); use Moo; use Text::CSV; +use List::MoreUtils 'uniq'; use FixMyStreet::SMS; use mySociety::EmailUtil; use namespace::clean -except => [ 'meta' ]; @@ -534,6 +537,7 @@ sub anonymize_account { title => undef, twitter_id => undef, facebook_id => undef, + oidc_ids => undef, }); } @@ -654,4 +658,20 @@ sub in_role { return $self->roles_hash->{$role}; } +sub add_oidc_id { + my ($self, $oidc_id) = @_; + + my $oidc_ids = $self->oidc_ids || []; + my @oidc_ids = uniq ( $oidc_id, @$oidc_ids ); + $self->oidc_ids(\@oidc_ids); +} + +sub remove_oidc_id { + my ($self, $oidc_id) = @_; + + my $oidc_ids = $self->oidc_ids || []; + my @oidc_ids = grep { $_ ne $oidc_id } @$oidc_ids; + $self->oidc_ids(scalar @oidc_ids ? \@oidc_ids : undef); +} + 1; |