TwitterのOAuth認証を通過してTLを取得

OAuth認証を通過してTwitterの自分のアカウントのTLを取得するプログラムをPerlで書いてみた。
色々間違っていそうだけど、とりあえずTLを取得できたので、その流れをメモ。


Net::OAuthとか使うんだろwwwwとか思ってた人、ごめんなさい。
ほぼ標準で入っているだろうと思われるモジュールでLWPとDigestしか使いません。
つまり、OAuth認証を自前で実装します。


まだ欠点はあって、認証コードの入力は手動です。(自動化するとなると、アカウントとパスワード入力が必要になる)


参考

OAuthプロトコルの中身をざっくり解説してみるよ - ( ꒪⌓꒪) ゆるよろ日記
OAuth Core 1.0a
http://tzmtk.pbworks.com/w/page/7618697/OAuthCore10JP

OAuth認証

OAuth Core 1.0 Revision A
http://oauth.net/core/1.0a/


簡単な流れ。

(アプリケーションを登録)
リクエストークンを要求・受信
認証コードを取得
アクセストークンを要求・受信

()で括って無い部分がOAuth認証の処理です

(アプリケーションを登録)

Twitter / アプリケーション登録
http://twitter.com/apps/new


適当に登録を済ませる。登録すると、5つの情報が得られます。

  • Consumer key : キーです
  • Consumer secret : 外部に見せてはまずい情報です。
  • フォローをリクエストしました。:なぜ訳したし。リクエストークン(oauth_token)を得るためのURL
  • Access token URL : リクエストークンを使ってアクセストークンを得るためのURL
  • Authorize URL : アクセストークンを得るときに認証をする際に使うURL


ConsumerkeyとConsumerSecretはOAuth認証で使います。
後の3つは後の工程でリクエストを発行する時に必要です。

リクエストークンを要求・受信

oauth/request_tokenにリクエストを発行します。
その時にAuthorizationヘッダーに以下のパラメータ情報をセットします。

'oauth_consumer_key' => アプリケーション固有のキー
'oauth_nonce' => リクエスト毎に一意な文字列
'oauth_signature' => 署名
'oauth_signature_method' => 署名の方式
'oauth_timestamp' => タイムスタンプ(UTC)
'oauth_version' => バージョン(今は'1.0')

'oauth_nonce'はリクエスト毎に一意な文字列であればなんでもいいようです。ただのタイムスタンプにすることも多いようです。
'oauth_signature'は、'oauth_signature_method'に沿って生成する必要があります。
TwitterOAuth認証ではHMAC-SHA1という方式を使うので、これを使って'oauth_signature'を生成します。

oauth_signatureの作り方

HMAC-SHA1(key, msg)のダイジェストをBase64に変換する事で生成できます。
keyは"oauth_consumer_secret&oauth_token_secret"で生成できます。
'oauth_consumer_secret'はアプリケーション固有の隠し情報ですが、
'oauth_token_secret'はリクエストークン生成時にはまだ知らない状態なので、ここでのkeyは"oauth_consumer_secret&"となります。
msgは、HTTPでリクエストを発行するときのメソッド(method)と、URL,パラメータ情報を&で連結したもの、をそれぞれパラメータエンコードし、さらに&で繋げたものです。
( 参考: http://oauth.net/core/1.0a/#encoding_parameters )
パラメータエンコード関数をPEとしたとき、msgは以下となります。
PE(method) '&' PE(URL) '&' PE(クエリ情報)
このkeyとmsgをHMAC-SHA1関数に渡してダイジェストを生成し、Base64に変換します。

Authorizationヘッダー

"OAuth a={a}, b={b}..."という形式で生成します。({a}は変数aを示します)
これをHTTPリクエストを発行する時にAuthorizationに付加して送信します。

リクエスト発行

Content-Typeにapplication/x-www-form-urlencodedを指定して、リクエストを発行します。

oauth_token={oauth_token}&oauth_token_secret={oauth_token_secret}

が得られます。
oauth_tokenとoauth_token_secretがリクエストークンになります。

認証コード(oauth_verifier)の取得

oauth/authorizeにoauth_token={oauth_token}というパラメータを付加してGETメソッドでリクエストするだけです。
ttp://twitter.com/oauth/authorize?oauth_token={oauth_token}
という形になります。
しかし、実際にはユーザの認証を行うので、IDとパスワードを入力する必要があります。
アプリケーションにパスワードを入力するのはちょっと・・・と思う人はいるはずなので、
ここはユーザーさんにURLだけを表示してブラウザでアクセスしてもらって、認証コードを入手してきてもらいます。
ちなみにこのURLはワンタイム方式なので、1度認証が終わると使えなくなります。

アクセストークンを要求・受信

基本的にリクエストークンと全く同じ手順を踏みます。
違う点はoauth/access_tokenに'POST'でリクエストを発行する事と、oauth_tokenにリクエストークンで取得したoauth_tokenを、oauth_verifierに認証コードをセットしたパラメータに追加する点と、oauth_token_secretを使う点だけです。
oauth_signatureを作るときは、リクエストークンで得たoauth_token_secretを与えて追加したパラメータ含めてsignatureを生成します。
リクエストに成功すると、やはりリクエストークンと同じように、oauth_tokenとoauth_token_secretが帰ってきます。
これがアクセストークンになり、OAuth認証に通過した証明になるものになります。



アクセストークンの使い方

OAuth認証でやってきたのと同じようにAuthorizationヘッダーにアクセストークンを含めたパラメータを付加してAPIへリクエストを発行します。
結果はAPIの実装に依存した結果が返ります。

実装

PerlTwitterのタイムラインを取得するコードを実装してみた。
エラーチェックが甘かったり、デバッグ用のprintfが沢山書かれていたり、コーディングが汚いけど、気にしない。
リクエストの発行はLWP::UserAgentに任せて、signatureの算出はSHA1の計算だけDigest::SHA1で得る。
アクセストークンを取得したら、oauth.datに記録して、2回目以降は認証作業を飛ばす。
標準出力をtl.xmlで受け取っている。
ファイルの構成としては、アプリケーションをNimonoとして、こんな感じ。

Nimono.pm
Nimono
└OAuth.pm
UseNimono.pl
(oauth.dat) OAuth認証データ
(tl.xml) タイムラインのデータ


Nimono/OAuth.pm

package Nimono::OAuth;
use strict;
use warnings;

use Digest::SHA1;
use LWP::UserAgent;

my $RequestTokenPath = 'oauth/request_token';
my $AccessTokenPath = 'oauth/access_token';
my $AuthorizePath = 'oauth/authorize';

sub new{
	my $class = shift;
	my $oauth_url = shift;
	my $consumer_key = shift;
	my $consumer_secret = shift;
	my $param = shift;
	$oauth_url =~ s/\/$//;
	
	my $self = {
		useragent => new LWP::UserAgent(agent=>'OAuth Activater', timeout=>60),
		oauth_url => $oauth_url,
		
		oauth_consumer_key    => $consumer_key, # コンシューマーキー
		oauth_consumer_secret => $consumer_secret, # シークレット
		
		# request_token
		# oauth_consumer_key   => $consumer_key,
		oauth_signature        => '', # 署名
		oauth_signature_method => 'HMAC-SHA1', # 署名方式 HMAC-SHA1, RSA-SHA1, and PLAINTEXT
		oauth_timestamp        => '', # タイムスタンプ
		oauth_nonce            => '', # ユニークな値
		oauth_version          => '1.0', # OAuthのバージョン
		oauth_signature        => '', # 署名(取得するのでここでは定義しない)
		
		# request_token - response
		request_token => {
			oauth_token => '',
			oauth_token_secret => '',
			oauth_callback_confirmed => '',
		},
		
		oauth_token => '', # アクセス/リクエストトークン
		oauth_token_secret => '', # アクセストークン・シークレット
		oauth_verifier => '', # PINコード
	};
	
	if(!$self->{oauth_url} || !$self->{oauth_consumer_key} || !$self->{oauth_consumer_secret}){
		die('need consumer_key and consumer_secret');
	}
	
	$self = bless($self,$class);
	
	return $self;
}
#acceser
sub oauth_token{
	my $self = shift; return $self->{oauth_token};
}
sub oauth_token_secret{
	my $self = shift; return $self->{oauth_token_secret};
}
sub oauth_consumer_secret{
	my $self = shift; return $self->{oauth_consumer_secret};
}
sub oauth_consumer_key{
	my $self = shift; return $self->{oauth_consumer_key};
}

# method
sub init_param{
	my $self = shift;
	$self->{oauth_timestamp}        = time(); # タイムスタンプ
	$self->{oauth_nonce}            = randomNonce(); # ユニークな値
}

# ランダムなNonceの値を24〜32文字で返す
sub randomNonce{
	my @ch = ('a'..'z',  '0'..'9', 'A'..'Z');
	my $loop = 24+rand(8);
	my $nonce = '';
	for(my $i=0; $i<$loop; ++$i){
		$nonce .= $ch[rand(@ch)];
	}
	return $nonce;
}

# リクエストトークンを得る
# ret not:失敗 not以外:成功($self->{request_token}->{oauth_token}/{oauth_token_secret}に情報が入る)
sub getRequestToken{
	my $self = shift;
	my $request_token_url = "$self->{oauth_url}/$RequestTokenPath";
	$self->init_param();
	$self->{request_token}->{oauth_token} = $self->{request_token}->{oauth_token_secret} = '';
	# generate oauth_signature
	$self->{oauth_signature} = $self->makeSignature('', 'GET', $request_token_url,
		['oauth_consumer_key', 'oauth_nonce', 'oauth_signature_method', 'oauth_timestamp', 'oauth_version']
	);
	
	my $header = $self->makeAuthorizationHeader(
		['oauth_consumer_key', 'oauth_nonce', 'oauth_signature', 'oauth_signature_method', 'oauth_timestamp', 'oauth_version']
	);
	
	my $re = $self->{useragent}->get($request_token_url, Authorization => "$header", "Content-Type" => "application/x-www-form-urlencoded");
	
	if($re->code() != 200){ # error
		print STDERR "getRequestToken error\n";
		return 0;
	}
	
	foreach my $pair(split(/&/,$re->content)){
		my($k,$v) = split(/=/,$pair,2);
		$self->{request_token}->{$k} = $v if exists $self->{request_token}->{$k};
	}
	
	return ($self->{request_token}->{oauth_token} && $self->{request_token}->{oauth_token_secret});
}

# アクセストークンを得る
# ret not:失敗 not以外:成功($self->{oauth_token}/{oauth_token_secret}に情報が入る)
sub getAccessToken{
	my $self = shift;
	my $verifier = shift; # 暗証番号
	
	if(not $self->{request_token}->{oauth_token} || not $self->{request_token}->{oauth_token_secret}){
		print STDERR "getting request token...\n";
		if($self->getRequestToken() == 0){
			return 0;
		}
	}
	my $access_token_url = "$self->{oauth_url}/$AccessTokenPath";
	
	$self->init_param();
	$self->{oauth_verifier}         = $verifier;
	$self->{oauth_token}            = $self->{request_token}->{oauth_token};
	
	$self->{oauth_token_secret} = '';
	# generate oauth_signature
	$self->{oauth_signature} = $self->makeSignature($self->{request_token}->{oauth_token_secret}, 'POST', $access_token_url,
		['oauth_consumer_key', 'oauth_nonce', 'oauth_signature_method', 'oauth_timestamp', 'oauth_version' ,'oauth_token', 'oauth_verifier']
	);
	
	# HTTP Header (Authorization)
	my $header = $self->makeAuthorizationHeader(
		['oauth_consumer_key', 'oauth_nonce', 'oauth_signature', 'oauth_signature_method', 'oauth_timestamp', 'oauth_version' ,'oauth_token', 'oauth_verifier']
	);
	
	# request
	my $re = $self->{useragent}->post($access_token_url, Authorization => "$header", "Content-Type" => "application/x-www-form-urlencoded");
	
	if($re->code() != 200){ # error
		print STDERR "getAccessToken error\n";
		return 0;
	}
	
	# parse
	foreach my $pair(split(/&/,$re->content)){
		my($k,$v) = split(/=/,$pair,2);
		$self->{$k} = $v if exists $self->{$k};
	}
	
#	print $re->as_string;
	return ($self->{oauth_token} && $self->{oauth_token_secret});
}

sub makeOAuthHeader{
	my $self = shift;
	my $url = shift;
	$self->init_param();
	my @param = ('oauth_consumer_key', 'oauth_nonce', 'oauth_signature_method', 'oauth_timestamp', 'oauth_version' ,'oauth_token');
	$self->{oauth_signature} = $self->makeSignature($self->{oauth_token_secret}, 'GET', $url, \@param);
	push @param, 'oauth_signature';
	return $self->makeAuthorizationHeader(\@param);
}

# 情報から署名を生成
# 1. {oauth_consumer_key}&{oauth_token_secret}, method, URL, Query(param)列を渡す
# 2. method, URL, Queryをurlエスケープしたものを&で結合
# 3. ハッシュ関数にHMACを適用したハッシュを生成(key, msg)
# 4. urlエスケープして返す
# SecretKey, Method, URL, Parameter	
sub makeSignature{
	my $self = shift;
	my($key, $method, $url, $param) = @_; # 1
	$key = $self->{oauth_consumer_secret}.'&'. $key; # {oauth_consumer_key}&{oauth_token_secret}
	
	my $msg = "$method". "&" . paramEncode($url) . "&" . paramEncode(join('&', map{"$_=$self->{$_}"} sort @$param)); # 2
	
	if($self->{oauth_signature_method} eq 'HMAC-SHA1'){ # 3
		return paramEncode( hmac_sha1_b64digest($key, $msg) );
	}else{
		die("$self->{oauth_consumer_secret} is not support.");
	}
	return paramEncode($msg); # 4
}

# 認証ヘッダのAuthorization部分を生成
sub makeAuthorizationHeader{
	my $self = shift;
	my $param = shift;
	return "OAuth realm=\"\"," . join ",", map {"$_=\"$self->{$_}\""} sort @$param;
}

# OAuth認証済みか否か {oauth_token} && {oauth_token_secret} があれば良い
sub authorized{
	my $self = shift;
	return ($self->{oauth_token} && $self->{oauth_token_secret});
}

# 認証するためにURLを取得
sub authorizeUrl{
	my $self = shift;
	# リクエストトークンを持っていない
	if(not $self->{request_token}->{oauth_token}){
		print STDERR "getting request token...\n";
		if( not $self->getRequestToken()){
			return '';
		}
	}
	my $authorize_url = "$self->{oauth_url}/$AuthorizePath";
	$authorize_url .= "?oauth_token=$self->{request_token}->{oauth_token}";
	
	return $authorize_url;
}

sub consoleAuthorize{
	my $self = shift;
	# リクエストトークンを持っていない
	my $authorize_url = $self->authorizeUrl();
	if(not $authorize_url){
		print STDERR "consoleAuthorize Error\n";
		return '';
	}
	print STDERR "Access and get code.\nURL: $authorize_url\n";
	print STDERR "input PIN code: ";
	my ($input) = <STDIN> =~ /(\d+)/;
	
#	my $re = $self->{useragent}->get($authorize_url);
#	my $re = $self->{useragent}->post($authorize_url);
#	print STDERR "=== authorize response ===\n" . $re->as_string() . "\n";
#	print $re->content;
	return $input;
}

# return Base64 digest
sub hmac_sha1_b64digest{
	my ($key, $msg) = @_;
	# init
	my $object = new Digest::SHA1();
	if(length($key) > 64){
		 $key = $object->add($key)->digest;
		 $object->reset();
	}
	# calc
	$msg = $object->add($key ^ "\x36" x 64, $msg)->digest;
	$object->reset();
	# return
	return $object->add($key ^ "\x5C" x 64, $msg)->b64digest . "=";
}

sub paramEncode{
	my $str = shift;
	$str =~ s{[^A-Za-z0-9\-\_\.\~]}{sprintf("%%%02X",unpack("C", $&))}eg;
	return $str;
}

sub outputOAuthFile{
	my $self = shift;
	my $file = shift || return 0;
	open(my $FH, '>', $file) || return 0;
	print $FH "oauth_token=$self->{oauth_token}\n";
	print $FH "oauth_token_secret=$self->{oauth_token_secret}\n";
	return close($FH);
}

sub inputOAuthFile{
	my $self = shift;
	my $file = shift || return 0;
	open(my $FH, '<', $file) || return 0;
	while(defined(my $line = <$FH>)){
		chomp $line;
		my($k,$v) = split(/=/,$line,2);
		$self->{$k} = $v if exists $self->{$k};
	}
	return close($FH);
}

1;
Nimono.pm

CONSUMER_KEYとCONSUMER_SECRETはアプリケーション毎に自分で設定します。

package Nimono;
use strict;
use warnings;

use Nimono::OAuth;

sub new{
	my $class = shift;
	
	my $CONSUMER_KEY = 'XXXXXXXXXXXXXXXXXXX';
	my $CONSUMER_SECRET = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
	my $HOST = 'http://twitter.com/';
	
	$HOST =~ s{/+$}{};
	
	my $self = {
		host => $HOST,
		oauth => new Nimono::OAuth($HOST, $CONSUMER_KEY, $CONSUMER_SECRET),
		oauthfile => 'oauth.dat',
		timeline => [],
	};
	
	$self->{oauth}->inputOAuthFile($self->{oauthfile});
	
	
	return bless($self,$class);
}

sub authorize{
	my $self = shift;
	if((not $self->{oauth}->authorized()) && (not $self->{oauth}->getAccessToken( $self->{oauth}->consoleAuthorize() ))){
		return 0;
	}
	return 1;
}

sub request{
	my $self = shift;
	my $method = (lc(shift) eq 'post') ? 'post' : 'get';
	my $url = shift;
	my $header = $self->{oauth}->makeOAuthHeader($url);
	return $self->{oauth}->{useragent}->$method($url, Authorization => "$header", "Content-Type" => "application/x-www-form-urlencoded");
}

sub getFriendTimeLine{
	my $self = shift;
	my $path = 'statuses/friends_timeline.xml';
	my $url = "$self->{host}/$path";
	
	my $header = $self->{oauth}->makeOAuthHeader($url);
	my $re = $self->{oauth}->{useragent}->get($url, Authorization => "$header", "Content-Type" => "application/x-www-form-urlencoded");
	
	return $re->content;
}

sub getShow{
	my $self = shift;
	my %param = @_;
	my $path = "statuses/show/$param{id}.xml";
	my $url = "$self->{host}/$path";
	my $re = $self->request('GET', $url);
	
	return $re->content;
	
}

sub DESTROY{
	my $self = shift;
	printf STDERR "----- \$self->{oauth} DUMP -----\n";
	printf STDERR ("%-15s => %s\n", $_, $self->{oauth}->{$_}) foreach(sort keys %{$self->{oauth}});
	$self->{oauth}->outputOAuthFile($self->{oauthfile});
}

1;
UseNimono.pl
use strict;
use warnings;

use Nimono;

my $o = new Nimono();
if(not $o->authorize()){ die('認証失敗'); }
print $o->getFriendTimeLine();


実行するとこんな感じ。

初回

>UseTwitter.pl > tl.xml
getting request token...
Access and get code.
URL: http://twitter.com/oauth/authorize?oauth_token=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
input PIN code: [上のURLで得た認証コードを入力]
----- $self->{oauth} DUMP -----
# dump data #

2回目以降(oauth.datの情報が有効な間)

UseTwitter.pl > tl.xml
----- $self->{oauth} DUMP -----
# dump data #