package FixMyStreet::App::Controller::Auth;
use Moose;
use namespace::autoclean;

BEGIN { extends 'Catalyst::Controller'; }

use Email::Valid;
use Data::Password::Common 'found';
use Digest::HMAC_SHA1 qw(hmac_sha1);
use JSON::MaybeXS;
use MIME::Base64;
use FixMyStreet::SMS;

=head1 NAME

FixMyStreet::App::Controller::Auth - Catalyst Controller

=head1 DESCRIPTION

Controller for all the authentication related pages - create account, sign in,
sign out.

=head1 METHODS

=head2 index

Present the user with a sign in / create account page.

=cut

sub general : Path : Args(0) {
    my ( $self, $c ) = @_;

    $c->detach( 'redirect_on_signin', [ $c->get_param('r') ] )
        if $c->req->method eq 'GET' && $c->user && $c->get_param('r');

    # all done unless we have a form posted to us
    return unless $c->req->method eq 'POST';

    my $clicked_sign_in_by_code = $c->get_param('sign_in_by_code');
    my $data_username = $c->get_param('username');
    my $data_password = $c->get_param('password_sign_in');
    my $data_email = $c->get_param('name') || $c->get_param('password_register');

    # 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->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') ] );

}

sub create : Path('create') : Args(0) {
    my ( $self, $c ) = @_;
    return unless $c->req->method eq 'POST';
    $c->detach('code_sign_in');
}

sub forgot : Path('forgot') : Args(0) {
    my ( $self, $c ) = @_;
    $c->stash->{forgotten} = 1;
    $c->stash->{template} = 'auth/create.html';
    return unless $c->req->method eq 'POST';
    $c->detach('code_sign_in');
}

sub expired : Path('expired') : Args(0) {
    my ( $self, $c ) = @_;

    $c->detach('/page_error_403_access_denied', []) unless $c->user_exists;

    my $expiry = $c->cobrand->call_hook('password_expiry');
    $c->detach('/page_error_403_access_denied', []) unless $expiry;

    my $last_change = $c->user->get_extra_metadata('last_password_change') || 0;
    my $midnight = int(time()/86400)*86400;
    my $expired = $last_change + $expiry < $midnight;
    $c->detach('/page_error_403_access_denied', []) unless $expired;

    $c->stash->{expired_password} = 1;
    $c->stash->{template} = 'auth/create.html';
    return unless $c->req->method eq 'POST';
    $c->detach('code_sign_in', [ $c->user->email ]);
}

sub authenticate : Private {
    my ($self, $c, $type, $username, $password) = @_;
    return 1 if $type eq 'email' && $c->authenticate({ email => $username, email_verified => 1, password => $password });
    return 1 if $c->cobrand->sms_authentication && $type eq 'phone' && $c->authenticate({ phone => $username, phone_verified => 1, password => $password });
    return 0;
}

=head2 sign_in

Allow the user to sign in with a username and a password.

=cut

sub sign_in : Private {
    my ( $self, $c, $username ) = @_;

    $username ||= '';
    my $password = $c->get_param('password_sign_in') || '';

    # Sign out just in case
    $c->logout();

    my $parsed = FixMyStreet::SMS->parse_username($username);
    $c->cobrand->call_hook('disable_login_for_email', $parsed->{username}) unless $c->stash->{oauth_need_email};
    $c->forward('throttle_username', [$parsed]);

    if ($parsed->{username} && $password && $c->forward('authenticate', [ $parsed->{type}, $parsed->{username}, $password ])) {
        # Upgrade hash count if necessary
        my $cost = sprintf("%02d", FixMyStreet::DB::Result::User->cost);
        if ($c->user->password !~ /^\$2a\$$cost\$/) {
            $c->user->update({ password => $password });
        }

        # Regenerate CSRF token as session ID changed
        $c->forward('get_csrf_token');

        return 1;
    }

    $c->stash(
        sign_in_error => 1,
        username => $username,
    );
    return;
}

=head 2 throttle_username

Set up memcache to watch for repeated login attempts from the same user name
within a specified time

=cut

sub throttle_username : Private {
    my ( $self, $c, $details ) = @_;
    my $name = $details->{'username'};
    return if !$name;
    my $conf = $c->cobrand->feature('throttle_username');
    return if !$conf;
    $name = "throttle_username_" . $name;
    my $count = Memcached::increment($name, $conf->{time});
    if ($count > $conf->{attempts}) {
        $c->detach('/page_error_403_access_denied', [sprintf (_('Too many login attempts. Please wait %d seconds before trying again'), $conf->{time})]);
    }
}

=head2 code_sign_in

Either email the user a link to sign in, or send an SMS token to do so.

Don't check for an account - if there isn't one we can create it when
they come back with a token (which contains the email/phone).

=cut

sub code_sign_in : Private {
    my ( $self, $c, $override_username ) = @_;

    my $username = $c->stash->{username} = $override_username || $c->get_param('username') || '';

    my $parsed = FixMyStreet::SMS->parse_username($username);

    if ($parsed->{type} eq 'phone' && $c->cobrand->sms_authentication) {
        $c->forward('phone/sign_in', [ $parsed ]);
    } else {
        $c->forward('email_sign_in', [ $parsed->{username} ]);
    }
}

sub email_sign_in : Private {
    my ( $self, $c, $email ) = @_;

    # check that the email is valid - otherwise flag an error
    my $raw_email = lc( $email || '' );

    my $email_checker = Email::Valid->new(
        -mxcheck  => 1,
        -tldcheck => 1,
        -fqdn     => 1,
    );

    my $good_email = $email_checker->address($raw_email);
    if ( !$good_email ) {
        $c->stash->{username_error} = $raw_email ? $email_checker->details : 'missing_email';
        return;
    }

    $c->cobrand->call_hook('disable_login_for_email', $good_email) unless $c->get_param('oauth_need_email');

    my $password = $c->get_param('password_register');
    if ($password) {
        return unless $c->forward('/auth/test_password', [ $password ]);
    }

    # If user registration is disabled then bail out at this point
    # if there's not already a user with this email address.
    # NB this uses the same template as a successful sign in to stop
    # enumeration of valid email addresses.
    if ( FixMyStreet->config('SIGNUPS_DISABLED')
         && !$c->model('DB::User')->find({ email => $good_email })
         && !$c->stash->{current_user} # don't break the change email flow
    ) {
        $c->stash->{template} = 'auth/token.html';
        return;
    }

    my $user_params = {};
    $user_params->{password} = $password if $password;
    my $user = $c->model('DB::User')->new( $user_params );

    my $token_data = {
        email => $good_email,
        r => $c->get_param('r'),
        name => $c->get_param('name'),
        password => $user->password,
    };

    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';
    }

    my $token_obj = $c->model('DB::Token')->create({
        scope => 'email_sign_in',
        data  => $token_data,
    });

    $c->stash->{token} = $token_obj->token;
    my $template = $c->stash->{email_template} || 'login.txt';
    $c->send_email( $template, { to => $good_email } );
    $c->stash->{template} = 'auth/token.html';
}

sub get_token : Private {
    my ( $self, $c, $token, $scope ) = @_;

    $c->stash->{token_not_found} = 1, return unless $token;

    my $token_obj = $c->model('DB::Token')->find({ scope => $scope, token => $token });

    $c->stash->{token_not_found} = 1, return unless $token_obj;
    $c->stash->{token_not_found} = 1, return if $token_obj->created < DateTime->now->subtract( days => 1 );

    my $data = $token_obj->data;
    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 change_password_uri roles/) {
        $token_data->{$_} = $c->session->{oauth}{$_} if $c->session->{oauth}{$_};
    }
}

=head2 token

Handle the 'email_sign_in' tokens. Find the account for the email address
(creating if needed), authenticate the user and delete the token.

=cut

sub token : Path('/M') : Args(1) {
    my ( $self, $c, $url_token ) = @_;

    my $data = $c->forward('get_token', [ $url_token, 'email_sign_in' ]) || return;

    $c->stash->{token_not_found} = 1, return
        if $data->{old_user_id} && $data->{r} && $data->{r} eq 'auth/change_email/success'
            && (!$c->user_exists || $c->user->id ne $data->{old_user_id});

    my $type = $data->{login_type} || 'email';
    $c->detach( '/auth/process_login', [ $data, $type, $url_token ] );
}

sub process_login : Private {
    my ( $self, $c, $data, $type, $url_token ) = @_;

    # sign out in case we are another user
    $c->logout();

    my $user = $c->model('DB::User')->find_or_new({ $type => $data->{$type} });
    my $ver = "${type}_verified";

    # Bail out if this is a new user and SIGNUPS_DISABLED is set
    $c->detach( '/page_error_403_access_denied', [] )
        if FixMyStreet->config('SIGNUPS_DISABLED') && !$user->in_storage && !$data->{old_user_id};

    # People using 2FA need to supply a code
    my $must_have_2fa = $c->cobrand->call_hook('must_have_2fa', $user) || '';
    if ($must_have_2fa ne 'skip') {
        if ($user->has_2fa) {
            $c->forward( 'token_2fa', [ $user, $url_token ] );
        } elsif ($c->cobrand->call_hook('must_have_2fa', $user)) {
            $c->forward( 'signup_2fa', [ $user ] );
        }
    }

    if ($data->{old_user_id}) {
        # Were logged in as old_user_id, want to switch to $user
        if ($user->in_storage) {
            my $old_user = $c->model('DB::User')->find({ id => $data->{old_user_id} });
            if ($old_user) {
                $old_user->adopt($user);
                $user = $old_user;
                $user->$type($data->{$type});
                $user->$ver(1);
            }
        } else {
            # Updating to a new (to the db) email address/phone number, easier!
            $user = $c->model('DB::User')->find({ id => $data->{old_user_id} });
            $user->$type($data->{$type});
            $user->$ver(1);
        }
    }

    $user->name( $data->{name} ) if $data->{name};
    $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};
    $c->cobrand->call_hook(roles_from_oidc => $user, $c->session->{oauth}{roles});
    $user->update_or_insert;
    $c->authenticate( { $type => $data->{$type}, $ver => 1 }, 'no_password' );

    foreach (qw/logout_redirect_uri change_password_uri/) {
        if ($data->{$_}) {
            $c->session->{oauth} ||= ();
            $c->session->{oauth}{$_} = $data->{$_};
        }
    }


    # send the user to their page
    $c->detach( 'redirect_on_signin', [ $data->{r}, $data->{p} ] );
}

=head2 token_2fa

Used after clicking an email token link to request a 2FA code

=cut

sub token_2fa : Private {
    my ($self, $c, $user, $url_token) = @_;

    return if $c->check_2fa($user->has_2fa);

    $c->stash->{form_action} = $c->req->path;
    $c->stash->{token} = $url_token;
    $c->stash->{template} = 'auth/2fa/form.html';
    $c->detach;
}

sub signup_2fa : Private {
    my ($self, $c, $user) = @_;

    $c->stash->{form_action} = $c->req->path;
    $c->stash->{template} = 'auth/2fa/intro.html';
    my $action = $c->get_param('2fa_action') || '';

    my $secret;
    if ($action eq 'confirm') {
        $secret = $c->get_param('secret32');
        if ($c->check_2fa($secret)) {
            $user->set_extra_metadata('2fa_secret' => $secret);
            $user->update;
            $c->stash->{stage} = 'success';
            return;
        } else {
            $action = 'activate'; # Incorrect code, reshow
        }
    }

    if ($action eq 'activate') {
        my $auth = FixMyStreet::Auth::GoogleAuth->new;
        $c->stash->{qr_code} = $auth->qr_code($secret, $user->email, $c->cobrand->base_url);
        $c->stash->{secret32} = $auth->secret32;
        $c->stash->{stage} = 'activate';
    }

    $c->detach;
}

=head2 redirect_on_signin

Used after signing in to take the person back to where they were.

=cut


sub redirect_on_signin : Private {
    my ( $self, $c, $redirect, $params ) = @_;

    if ($c->stash->{detach_to}) {
        $c->detach($c->stash->{detach_to}, $c->stash->{detach_args});
    }

    unless ( $redirect ) {
        my $inspector = $c->user->from_body && (
            scalar @{ $c->user->categories } ||
            scalar @{ $c->user->area_ids || [] }
        );
        $redirect = $inspector ? 'my/inspector_redirect' : 'my';
    }
    $redirect = 'my' if $redirect =~ /^admin/ && !$c->cobrand->admin_allow_user($c->user);
    if ( $c->cobrand->moniker eq 'zurich' ) {
        $redirect = 'admin' if $c->user->from_body;
    }
    if (defined $params) {
        $c->res->redirect( $c->uri_for( "/$redirect", $params ) );
    } else {
        $c->res->redirect( $c->uri_for( "/$redirect" ) );
    }
}

=head2 redirect

Used when trying to view a page that requires sign in when you're not.

=cut

sub redirect : Private {
    my ( $self, $c ) = @_;

    my $uri = $c->uri_for( '/auth', { r => $c->req->path } );
    $c->res->redirect( $uri );
    $c->detach;

}

sub get_csrf_token : Private {
    my ( $self, $c ) = @_;

    my $time = $c->stash->{csrf_time} || time();
    my $hash = hmac_sha1("$time-" . ($c->sessionid || ""), $c->model('DB::Secret')->get);
    $hash = encode_base64($hash, "");
    $hash =~ s/=$//;
    my $token = "$time-$hash";
    $c->stash->{csrf_token} = $token unless $c->stash->{csrf_time};
    return $token;
}

sub check_csrf_token : Private {
    my ( $self, $c ) = @_;

    my $token = $c->get_param('token') || "";
    $token =~ s/ /+/g;
    my ($time) = $token =~ /^(\d+)-[0-9a-zA-Z+\/]+$/;
    $c->stash->{csrf_time} = $time;
    my $gen_token = $c->forward('get_csrf_token');
    delete $c->stash->{csrf_time};
    $c->detach('no_csrf_token')
        unless $time
            && $time > time() - 3600
            && $token eq $gen_token;

    # Also check recaptcha if needed
    $c->cobrand->call_hook('check_recaptcha');
}

sub no_csrf_token : Private {
    my ($self, $c) = @_;
    $c->detach('/page_error_400_bad_request', []);
}

=item common_password

Returns 1/0 depending on if password is common/breached or not.

=cut

sub common_password : Local : Args(0) {
    my ($self, $c) = @_;

    my $password = $c->get_param('password_register');

    my $pass = $c->forward('test_password', [ $password ]);
    my $return = $pass ? JSON->true : $c->stash->{field_errors}->{password_register};

    my $body = JSON->new->utf8->allow_nonref->encode($return);
    $c->res->content_type('application/json; charset=utf-8');
    $c->forward('/set_app_cors_header');
    $c->res->body($body);
}

=item test_password

Checks a password is not too weak; returns true if okay,
false if weak (and sets stash error).

=cut

sub test_password : Private {
    my ($self, $c, $password) = @_;

    return 1 if $c->cobrand->call_hook('bypass_password_checks');

    my $error;
    my $min_length = $c->cobrand->password_minimum_length;
    if (length($password) < $min_length) {
        $error = sprintf(_('Please make sure your password is at least %d characters long'), $min_length);
    } elsif (found($password)) {
        $error = _('Please choose a less commonly-used password');
    } elsif (hibp($password)) {
        $error = _('That password has appeared in a known third-party data breach (<a href="https://haveibeenpwned.com/Passwords" target="_blank">more information</a>); please choose another');
    }

    if ($error) {
        $c->stash->{field_errors}->{password_register} = $error;
        return 0;
    }
    return 1;
}

=item hibp

Returns true if we should check Have I Been Pwned and the check
comes back positive for a password that has been breached.

=cut

use Encode qw(encode);
use Digest::SHA qw(sha1_hex);
use LWP::Simple;
use Unicode::Normalize;

sub hibp : Private {
    my $password = shift;

    return 0 unless FixMyStreet->config('CHECK_HAVEIBEENPWNED');
    my $sha1 = uc sha1_hex(encode('UTF-8', NFD($password)));
    my $url = 'https://api.pwnedpasswords.com/range/' . substr($sha1, 0, 5);
    my $response = LWP::Simple::get($url);
    my $remainder = substr($sha1, 5);
    foreach my $line (split /\r\n/, $response) {
        my ($part, $count) = split /:/, $line;
        return $count if $part eq $remainder;
    }
    return 0;
}

=head2 sign_out

Log the user out. Tell them we've done so.

=cut

sub sign_out : Local {
    my ( $self, $c ) = @_;
    $c->logout();

    if ( $c->sessionid && $c->session->{oauth} && $c->session->{oauth}{logout_redirect_uri} ) {
        my $url = $c->session->{oauth}{logout_redirect_uri};
        $url .= '&id_token_hint=' . $c->session->{oauth}{id_token} if $c->session->{oauth}{id_token};
        $c->response->redirect($url);
        delete $c->session->{oauth}{logout_redirect_uri};
        $c->detach;
    }
}

sub ajax_sign_in : Path('ajax/sign_in') {
    my ( $self, $c ) = @_;

    my $return = {};
    if ( $c->forward( 'sign_in', [ $c->get_param('email') ] ) ) {
        $return->{name} = $c->user->name || '-'; # App currently requires something returned
        $return->{success} = 1;
    } else {
        $return->{error} = 1;
    }

    my $body = encode_json($return);
    $c->res->content_type('application/json; charset=utf-8');
    $c->forward('/set_app_cors_header');
    $c->res->body($body);

    return 1;
}

sub ajax_sign_out : Path('ajax/sign_out') {
    my ( $self, $c ) = @_;

    $c->logout();

    my $body = encode_json( { signed_out => 1 } );
    $c->res->content_type('application/json; charset=utf-8');
    $c->forward('/set_app_cors_header');
    $c->res->body($body);

    return 1;
}

sub ajax_check_auth : Path('ajax/check_auth') {
    my ( $self, $c ) = @_;

    my $code = 401;
    my $data = { not_authorized => 1 };

    # iOS app can't send cookies so instead sends email/password
    # to check login status
    if ( ( my $username = $c->get_param('username') ) && !$c->user ) {
        $c->forward('sign_in', [ $username ]);
    }

    if ( $c->user ) {
        $data = { name => $c->user->name };
        $code = 200;
    }

    my $body = encode_json($data);
    $c->res->content_type('application/json; charset=utf-8');
    $c->forward('/set_app_cors_header');
    $c->res->code($code);
    $c->res->body($body);

    return 1;
}

=head2 check_auth

Utility page - returns a simple message 'OK' and a 200 response if the user is
authenticated and a 'Unauthorized' / 401 reponse if they are not.

Mainly intended for testing but might also be useful for ajax calls.

=cut

sub check_auth : Local {
    my ( $self, $c ) = @_;

    $c->authenticate(undef, 'access_token') unless $c->user;

    # choose the response
    my ( $body, $code )    #
      = $c->user
      ? ( 'OK', 200 )
      : ( 'Unauthorized', 401 );

    # set the response
    $c->res->body($body);
    $c->res->code($code);

    # NOTE - really a 401 response should also contain a 'WWW-Authenticate'
    # header but we ignore that here. The spec is not keeping up with usage.

    return;
}

sub two_factor_setup_success : Private {
    my ($self, $c) = @_;
    # Only here to be detached to after setup success
}

__PACKAGE__->meta->make_immutable;

1;
