aboutsummaryrefslogtreecommitdiffstats
path: root/perllib/FixMyStreet/App/Controller/Auth
diff options
context:
space:
mode:
authorMarius Halden <marius.h@lden.org>2020-09-29 14:23:52 +0200
committerMarius Halden <marius.h@lden.org>2020-09-29 14:23:52 +0200
commita27ce1524d801d2742a2bdb6ec1da45126d64353 (patch)
tree64123c4e17dc1776aa0a7cd65ee01d49d3e7d978 /perllib/FixMyStreet/App/Controller/Auth
parent377bd96aab7cad3434185c30eb908c9da447fe40 (diff)
parent2773c60226b9370fe8ee00f7b205b571bb87c3b5 (diff)
Merge tag 'v3.0.1' into fiksgatami-dev
Diffstat (limited to 'perllib/FixMyStreet/App/Controller/Auth')
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth/Profile.pm38
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth/Social.pm230
2 files changed, 249 insertions, 19 deletions
diff --git a/perllib/FixMyStreet/App/Controller/Auth/Profile.pm b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm
index 87aff2261..a89c6f539 100644
--- a/perllib/FixMyStreet/App/Controller/Auth/Profile.pm
+++ b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm
@@ -74,7 +74,8 @@ sub change_password : Path('/auth/change_password') {
if ($c->user->password) {
# we should have a usable password - save it to the user
- $c->user->obj->update( { password => $new } );
+ $c->user->obj->password($new);
+ $c->user->obj->update;
$c->stash->{password_changed} = 1;
} else {
# Set up arguments for code sign in
@@ -188,23 +189,38 @@ sub generate_token : Path('/auth/generate_token') {
if ($c->get_param('generate_token')) {
my $token = mySociety::AuthToken::random_token();
$c->user->set_extra_metadata('access_token', $token);
+ $c->user->update;
$c->stash->{token_generated} = 1;
}
- if ($c->get_param('toggle_2fa') && $c->user->is_superuser) {
- if ($has_2fa) {
- $c->user->unset_extra_metadata('2fa_secret');
- $c->stash->{toggle_2fa_off} = 1;
+ my $action = $c->get_param('2fa_action') || '';
+ $action = 'deactivate' if $c->get_param('2fa_deactivate');
+ $action = 'activate' if $c->get_param('2fa_activate');
+ $action = 'activate' if $action eq 'deactivate' && $has_2fa && $c->cobrand->call_hook('must_have_2fa', $c->user);
+
+ my $secret;
+ if ($action eq 'deactivate') {
+ $c->user->unset_extra_metadata('2fa_secret');
+ $c->user->update;
+ $c->stash->{toggle_2fa_off} = 1;
+ } elsif ($action eq 'confirm') {
+ $secret = $c->get_param('secret32');
+ if ($c->check_2fa($secret)) {
+ $c->user->set_extra_metadata('2fa_secret', $secret);
+ $c->user->update;
+ $c->stash->{stage} = 'success';
+ $has_2fa = 1;
} else {
- my $auth = Auth::GoogleAuth->new;
- $c->stash->{qr_code} = $auth->qr_code(undef, $c->user->email, 'FixMyStreet');
- $c->stash->{secret32} = $auth->secret32;
- $c->user->set_extra_metadata('2fa_secret', $auth->secret32);
- $c->stash->{toggle_2fa_on} = 1;
+ $action = 'activate'; # Incorrect code, reshow
}
}
- $c->user->update();
+ if ($action eq 'activate') {
+ my $auth = FixMyStreet::Auth::GoogleAuth->new;
+ $c->stash->{qr_code} = $auth->qr_code($secret, $c->user->email, $c->cobrand->base_url);
+ $c->stash->{secret32} = $auth->secret32;
+ $c->stash->{stage} = 'activate';
+ }
}
$c->stash->{has_2fa} = $has_2fa ? 1 : 0;
diff --git a/perllib/FixMyStreet/App/Controller/Auth/Social.pm b/perllib/FixMyStreet/App/Controller/Auth/Social.pm
index 097cac984..06e67573f 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,166 @@ 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;
+ }
+
+ # The OIDC endpoint may provide a specific URI for changing the user's password.
+ if ( my $password_change_uri = $c->cobrand->feature('oidc_login')->{password_change_uri} ) {
+ $oauth{change_password_uri} = $oidc->uri_to_redirect(
+ uri => $password_change_uri,
+ redirect_uri => $c->uri_for('/auth/OIDC'),
+ scope => 'openid',
+ state => 'password_change',
+ extra => {
+ response_mode => 'form_post',
+ },
+ );
+ }
+
+ $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;
+ } elsif ($c->user_exists && $c->get_param('state') && $c->get_param('state') eq 'password_change') {
+ $c->flash->{flash_message} = _('Password change cancelled.');
+ $c->res->redirect('/my');
+ $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', []);
+ }
+
+ # User may be coming back here after changing their password on the OIDC endpoint
+ if ($c->user_exists && $c->get_param('state') && $c->get_param('state') eq 'password_change') {
+ $c->detach('/auth/profile/change_password_success', []);
+ }
+
+ # 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 +335,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;
}
}