#!/usr/bin/perl
#
# DW::OAuth
#
# OAuth Helpers for Dreamwidth
#
# Authors:
#      Andrea Nall <anall@andreanall.com>
#
# Copyright (c) 2011 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::OAuth;

use strict;
use warnings;

use LJ::Utils qw(rand_chars);
use DW::Request;
use Net::OAuth;

use DW::OAuth::Consumer;
use DW::OAuth::Request;
use DW::OAuth::Access;

use Digest::SHA qw/ sha1_hex sha1_base64 hmac_sha256 /;
use MIME::Base64::URLSafe;

use Carp qw/ croak /;

use DW::OAuth::LocalProtectedResourceRequest;
use Net::OAuth::V1_0A::AccessTokenRequest;
use Net::OAuth::V1_0A::RequestTokenRequest;

my %TOKEN_LENGTHS = (
    default => 16,
    access  => 20,
);

my %CLASSES = (
    'protected resource' => 'DW::OAuth::LocalProtectedResourceRequest',
    'access token'       => 'Net::OAuth::V1_0A::AccessTokenRequest',
    'request token'      => 'Net::OAuth::V1_0A::RequestTokenRequest',
);

# FIXME: Until I figure our how to make Net::OAuth work with Hash::MultiValue
#  or reimplement it myself, if twisting Net::OAuth to our needs will be too much work
#  we can't accept duplicate keys.
#
# Otherwise we can't fully verify the signature, and even without this, the signature
#  probably would not verify anyway.
sub _process_into {
    my ( $out, $value ) = @_;

    my $data = $value->mixed;
    foreach my $key ( keys %$data ) {
        return 0 if exists $out->{$key};

        my $value = $data->{$key};
        return 0 if ref($value) eq "ARRAY";

        $out->{$key} = $value;
    }
    return 1;
}

sub get_request_raw {
    my ( $class, $method, $params, %opts ) = @_;
    my $args = $opts{args} // {};
    if ( ref $args ne 'HASH' ) {
        die "Invalid arguments: not hash";
    }

    my $authorization_header = $opts{authorizaton_header} || "";

    my $valid_header    = ( $authorization_header && $authorization_header =~ m/^OAuth / );
    my $oauth_attempted = $valid_header
        || ( ( scalar grep { /^oauth_/ } keys %$args ) != 0 );
    return undef unless $oauth_attempted;

    my $consumer_key = $args->{oauth_consumer_key};
    if ($valid_header) {

     # FIXME: Get this another way.
     # This isn't really the best way to do this, but Net::OAuth will fail if the secret is missing.
        my ($key) = $authorization_header =~ m/oauth_consumer_key="(.+?)"/;
        $consumer_key = LJ::durl($key) if $key;
    }

    my $consumer    = DW::OAuth::Consumer->from_token($consumer_key);
    my $oauth_token = $args->{oauth_token};
    if ($valid_header) {

     # FIXME: Get this another way.
     # This isn't really the best way to do this, but Net::OAuth will fail if the secret is missing.
        my ($key) = $authorization_header =~ m/oauth_token="(.+?)"/;
        $oauth_token = LJ::durl($key) if $key;
    }

    if ($consumer) {
        return ( 0, "consumer_unusable" ) unless $consumer->usable;
    }
    else {
        return ( 0, "consumer_notfound" );
    }

    my $token;
    if ( $method eq 'protected resource' ) {
        $token = DW::OAuth::Access->from_token($oauth_token);
        if ($token) {
            return ( 0, "token_unusable" ) unless $token->usable($consumer);
            $params->{token_secret} = $token->secret;
        }
        else {
            return ( 0, "token_notfound" );
        }
    }
    elsif ( $method ne 'request token' ) {
        $token = DW::OAuth::Request->from_token($oauth_token);
        if ($token) {
            return ( 0, "token_unusable" ) unless $token->usable($consumer);
            $params->{token_secret} = $token->secret;
            $params->{verifier}     = $token->verifier;
        }
        else {
            return ( 0, "token_notfound" );
        }
    }

    $params->{consumer_secret} = $consumer->secret;

    $params->{request_method} ||= $opts{method};
    $params->{request_url} ||=
        $opts{url} || LJ::create_url( undef, keep_args => 0 );

    $params->{signature_method} = 'HMAC-SHA1';

    my $oa_class = $CLASSES{$method};
    die "Bad method $method" unless $oa_class;

    my $result = 0;
    eval {
        if ($authorization_header) {
            $params->{callback} ||= $args->{oauth_callback};
            $result = $oa_class->from_authorization_header(
                $authorization_header,
                protocol_version => Net::OAuth::PROTOCOL_VERSION_1_0A,
                %$params
            );
        }
        elsif ( defined $args->{oauth_signature} ) {
            $result = $oa_class->from_hash(
                $args,
                protocol_version => Net::OAuth::PROTOCOL_VERSION_1_0A,
                %$params
            );
        }
    };
    if ($@) {
        warn $@;
        return ( 0, "oauth_failure: $@" );
    }
    return ( 0, "oauth_failure: unknown" ) unless $result;
    return ( 0, "verify_failure" )         unless $result->verify;
    return ( 0, "nonce_failure" ) unless $class->verify_nonce( oauth => $result, token => $token );

    # http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html
    my $inbound_type = $opts{content_type} || '';
    if ( $result->{body_hash} ) {
        return ( 0, "bodyhash_content" )
            if $inbound_type =~ m!^application/x-www-form-urlencoded!i;

        croak "want_content missing" unless $opts{want_content};
        my $real_hash = sha1_base64( $opts{want_content}->() ) . '=';
        return ( 0, "bodyhash_hash" ) unless $real_hash eq $result->{body_hash};
    }
    else {
        return ( 0, "bodyhash_missing" )
            if $inbound_type && $inbound_type !~ m!^application/x-www-form-urlencoded!i;
    }
    return ( $result, $consumer, $token );
}

sub get_request {
    my ( $class, $method, $params, %opts ) = @_;

    $params ||= {};

    my $r = DW::Request->get;

    my $args = $opts{args};
    if ( !$args ) {
        $args = {};
        _process_into( $args, $r->get_args )  or return ( 0, "multi_arg" );
        _process_into( $args, $r->post_args ) or return ( 0, "multi_arg" )
            if $r->did_post;
    }

    return $class->get_request_raw(
        $method, $params,
        args                => $args,
        method              => $r->method,
        authorizaton_header => $r->header_in("Authorization"),
        content_type        => $r->header_in("Content-Type"),
        want_content        => sub { return $r->content; },
        %opts
    );
}

sub _seperate_args {
    my $all_args = $_[0];
    my %out_args = ( extra_params => {} );

    foreach my $argname ( keys %$all_args ) {
        if ( $argname =~ m/^oauth_/ ) {
            $out_args{$argname} = $all_args->{$argname};
        }
        else {
            $out_args{extra_params}->{$argname} = $all_args->{$argname};
        }
    }

    return \%out_args;
}

sub user_for_protected_resource_raw {
    my ( $class, %opts ) = @_;

    my $args = ( delete $opts{args} );

    if ( !defined $args ) {
        my $get_args  = delete $opts{get_args};
        my $post_args = delete $opts{post_args};
        $args = {};
        _process_into( $args, $get_args ) or return ( 0, "multi_arg" )
            if $get_args;
        _process_into( $args, $post_args ) or return ( 0, "multi_arg" )
            if $post_args;
    }
    elsif ( ref $args ne 'HASH' ) {
        die "Invalid arguments: not hash";
    }

    my $params = ( delete $opts{params} ) // {};
    if ( ref $params ne 'HASH' ) {
        die "Invalid params: not hash";
    }

    my %all_args = ( %$params, %$args );
    my $out_args = _seperate_args( \%all_args );

    my ( $result, @rest ) = $class->get_request_raw(
        'protected resource', $out_args,
        args => $args,
        %opts
    );

    return undef unless defined $result;
    return ( 0, @rest ) unless $result;

    my ( $consumer, $token ) = @rest;
    return ( 0, "token_missing" )  unless $token;
    return ( 0, "token_unusable" ) unless $token->usable($consumer);

    unless ( $opts{no_store} ) {
        $class->current_token($token);
        $token->update_accessed;
    }

    return ( 1, $token->user, $token );
}

sub user_for_protected_resource {
    my ( $class, $params, %opts ) = @_;

    my $r = DW::Request->get;

    my %ropts;
    $ropts{no_store}  = $opts{no_store};
    $ropts{get_args}  = $r->get_args;
    $ropts{post_args} = $r->post_args
        if $r->did_post;

    return $class->user_for_protected_resource_raw(
        %ropts,
        method              => $r->method,
        authorizaton_header => $r->header_in("Authorization"),
        content_type        => $r->header_in("Content-Type"),
        want_content        => sub { return $r->content; }
    );
}

sub current_token {
    my $r = DW::Request->get;
    $r->pnote( 'oauth_token', $_[1] )
        if exists $_[1];
    return $r->pnote('oauth_token');
}

sub verify_nonce {
    my ( $class, %opts ) = @_;

    my $timestamp = 0;
    my $nonce     = 0;

    if ( $opts{oauth} ) {
        $timestamp = $opts{oauth}->timestamp;
        $nonce     = $opts{oauth}->nonce;
    }
    else {
        $timestamp = ( $opts{timestamp} || 0 ) + 0;
        $nonce     = $opts{nonce};
    }

    my $timestamp_valid = 30;     # 30 seconds should be plenty
    my $validity        = 120;    # 2 minutes, 4 times the timestamp validity.

    my $now = time();

    return 0 if abs( $now - $timestamp ) > $timestamp_valid;

    # Hash timestamp:nonce, don't want to put user-provided data directly in memcached
    my $key = "oauth_nonce:" . sha1_hex("$timestamp:$nonce");

    # this returns a false value if the key already exists
    return 0 unless LJ::MemCache::add( $key, time(), $validity );
    return 1;
}

sub make_token_pair {
    my ( $class, $type, $data ) = @_;

    my $secret_key = 'oauth_' . $type;

    croak "No secret for type: $secret_key" unless $LJ::SECRETS{$secret_key};

    my $chars = $TOKEN_LENGTHS{$type} || $TOKEN_LENGTHS{default};

    my $token = LJ::rand_chars( $chars, 'urlsafe_b64' );

    # Signing this with a secret, so this is not just the token with things concatenated on.
    my $secret =
        urlsafe_b64encode( hmac_sha256( $token . LJ::rand_chars(32), $LJ::SECRETS{$secret_key} ) );

    return ( $token, $secret );
}

sub validate_token {
    return ( $_[1] =~ m/^[a-zA-Z0-9_\-]+$/ ) ? 1 : 0;
}

# Can this user view other OAuth authorizations/tokens
sub can_view_other {
    my $u = $_[1];
    return $u && $u->has_priv( "siteadmin", "oauth" );
}

# Seperate function, in case we ever want other logic
sub can_edit_other;
*can_edit_other = \&can_view_other;

sub can_create_consumer {
    my $u = $_[1];
    return
           $u
        && !$u->is_inactive
        && !LJ::sysban_check( 'oauth_consumer', $u->user );
}

1;
