#!/usr/bin/perl
#
# DW::Auth::Password
#
# Library for centralizing password storage code, so that this never leaks
# into other systems.
#
# TODO: We are recording a schema in the database in case we ever need to
# change how we store passwords, but for now, this module only ever sets it
# to 1 and never checks it.
#
# Authors:
#     Mark Smith <mark@dreamwidth.org>
#
# Copyright (c) 2020 by Dreamwidth Studios, LLC.
#
# This program is free software; you may redistribute it and/or modify it under
# the same terms as Perl itself. For a copy of the license, please reference
# 'perldoc perlartistic' or 'perldoc perlgpl'.
#

package DW::Auth::Password;

use strict;
use v5.10;
use Log::Log4perl;
my $log = Log::Log4perl->get_logger(__PACKAGE__);

use Authen::Passphrase::Clear;
use Authen::Passphrase::BlowfishCrypt;
use Digest::MD5 qw/ md5_hex /;

use DW::API::Key;
use DW::Auth::Helpers;

################################################################################
#
# public methods
#

sub check {
    my ( $class, $u, $password, %opts ) = @_;

    # Check that the provided password is valid.
    #
    # To support transitional clients, this method allows providing two options which
    # alter the behavior of password validation.
    #
    #   allow_hpassword      If truthy, will perform a secondary check to see if the user has
    #                        provided an MD5'd version of the password. This is used by some
    #                        clients that don't support challenge-response.
    #
    #   allow_api_keys       If truthy, will check the provided 'password' against the user's
    #                        generated API keys and return OK if any of them match.

    # Only people can log in
    return 0 unless $u->is_person;

    if ( $u->dversion <= 9 ) {
        return $class->_check_old_dversion( $u, $password, %opts );
    }

    return $class->_check_modern_dversion( $u, $password, %opts );
}

sub set {
    my ( $class, $u, $password, %opts ) = @_;

    my $dbh = LJ::get_db_writer()
        or $log->logcroak('Failed to get database writer.');

    if ( $u->dversion <= 9 && !$opts{force_bcrypt} ) {

        # Old style: Write raw password to the database and store it in the user
        # object. This is quite dumb, but it was the late 90s when this was written?
        $dbh->do( "REPLACE INTO password (userid, password) VALUES (?, ?)",
            undef, $u->userid, $password )
            or $log->logcroak('Failed to set password.');
    }
    else {
        my $encrypted_password_token =
            DW::Auth::Helpers->encrypt_token( $class->_bcrypt_password($password) );

        # Replace into database.
        $dbh->do( q{REPLACE INTO password2 (userid, version, password) VALUES (?, ?, ?)},
            undef, $u->userid, 1, $encrypted_password_token )
            or $log->logcroak( 'Failed to set password hash: ', $dbh->errstr );
    }

    return 1;
}

################################################################################
#
# internal methods
#
#

sub _check_old_dversion {
    my ( $class, $u, $password, %opts ) = @_;
    my $user_password = $class->_get_password($u);

    # Check bare (no options)
    my $crypt = Authen::Passphrase::Clear->new($user_password);
    return 1 if $crypt->match($password);

    # If allowing hpassword, try that
    if ( $opts{allow_hpassword} ) {
        my $crypt = Authen::Passphrase::Clear->new( md5_hex($user_password) );
        return 1 if $crypt->match($password);
    }

    # If allowing API keys, try each of those
    if ( $opts{allow_api_keys} ) {
        return 1 if $class->_check_api_keys( $u, $password, %opts );
    }

    # Failed all attempts at authentication
    return 0;
}

sub _check_modern_dversion {
    my ( $class, $u, $password, %opts ) = @_;

    # Modern usage, check standard password hash
    my $crypt = Authen::Passphrase::BlowfishCrypt->from_crypt( $class->_get_password_token($u) );
    return 1 if $crypt->match($password);

    # If allowing API keys, try each of those
    if ( $opts{allow_api_keys} ) {
        return 1 if $class->_check_api_keys( $u, $password, %opts );
    }

    return 0;
}

sub _check_api_keys {
    my ( $class, $u, $password, %opts ) = @_;

    my @keys = @{ DW::API::Key->get_keys_for_user($u) || [] };
    foreach my $key (@keys) {
        my $crypt = Authen::Passphrase::Clear->new( $key->hash );
        return 1 if $crypt->match($password);

        if ( $opts{allow_hpassword} ) {
            my $crypt = Authen::Passphrase::Clear->new( md5_hex( $key->hash ) );
            return 1 if $crypt->match($password);
        }
    }

    return 0;
}

sub _get_password {
    my ( $class, $u ) = @_;
    return unless $u->is_person;

    # This is only valid on dversion <= 9. Otherwise, we are using encrypted
    # passwords and this is meaningless.
    $log->logcroak('User password is unavailable.')
        unless $u->dversion <= 9;

    my $dbh = LJ::get_db_writer() or die "Couldn't get db master";
    return $dbh->selectrow_array( q{SELECT password FROM password WHERE userid = ?},
        undef, $u->userid );
}

sub _get_password_token {
    my ( $class, $u ) = @_;
    return unless $u->is_person;

    $log->logcroak('User password hash is unavailable.')
        unless $u->dversion >= 10;

    my $userid = $u->userid;
    my $dbh    = LJ::get_db_writer() or $log->logcroak("Couldn't get db master");

    return DW::Auth::Helpers->decrypt_token(
        $dbh->selectrow_array(
            q{SELECT password FROM password2 WHERE userid = ? AND version = 1},
            undef, $userid
        )
    );
}

sub _bcrypt_password {
    my ( $class, $password ) = @_;

    # Applies bcrypt to a password, with a random salt

    my $crypt = Authen::Passphrase::BlowfishCrypt->new(
        cost        => $LJ::BCRYPT_COST,
        salt_random => 1,
        passphrase  => $password,
    );

    return $crypt->as_crypt;
}

1;
