diff options
Diffstat (limited to 'perllib/FixMyStreet/App/Controller')
-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 |
4 files changed, 291 insertions, 44 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) { |