JavaでTwitterのOAuthを書いてみました

OAuthについて前々から気になっていたので、やる夫と Python で学ぶ Twitter の OAuth - YoshioriのBlogを真似する感じでJavaで書いてみました。なお、あまり意味はないのですがJDKのライブラリのみで作成しました。

※ 2013-08-03 に動作確認済みです。なお、その際に statuses/update API の Resource URL を 1.1 に更新しました。

OAuthの概要

要はユーザーIDとパスワードではなくトークンを利用して認証を行う仕組みです。OAuthプロトコルの中身をざっくり解説してみるよ - ゆろよろ日記がとても分かりやすいです。
実際に実装する際はAuthenticating Requests with OAuth | dev.twitter.comがマニュアルになります。あとは、用語や定義について確認したいことがあればRFC 5849 - The OAuth 1.0 Protocolを参照する感じです。

今回の内容

アクセストークンをTwitterから取得して、サンプルアプリからTwitterにつぶやきます。手順は以下のような流れになります。立場としてはUser兼Consumerです。

  1. Consumerとして、Consumer登録
  2. (Userから依頼があった前提で)Consumerとして、リクエストークンの取得
  3. Userとして、サンプルアプリの認証
  4. Consumerとして、アクセストークンの取得
  5. Consumerとして、Twitterにつぶやく

5.に関してはUserから依頼されてConsumer経由でつぶやくみたいなイメージでも良いです。Twitterクライアントとかはそういう流れだと思います。

OAuthのポイント

アクセストークンが欲しい

Consumerとしてはアクセストークン(oauth_tokenとoauth_token_secret)が欲しいです。そのUserのアクセストークンさえ手に入れば、後はそのUserとしてTwitterAPIを呼び出し放題ですwで、アクセストークンを手に入れるには、Userに認証してもらう必要があるわけですが、その認証を行うためにリクエストークンがいるので、まず最初にリクエストークンの取得を行っています。

リクエストークンとアクセストークンとキー名が同じ

トークンと言えば、リクエストークンでもアクセストークンとキー名(oauth_tokenとoauth_token_secret)が同じなのは分かりづらかったです。別の名前にすればよかったのにと思います。

署名用のテキストとHTTPヘッダがかなり似ている

あと、個人的にはまったのは、署名用のテキストとHTTPヘッダの関係です。実際にソースを見てもらうと分かりますが署名用のテキストにはOAuth関連のパラメーターだけでなくHTTPメソッドやURLも入るので、最初はHTTPヘッダと混同してしまいました。要は本人確認+改ざんチェックを目的としているのでAPIに送信するHTTPヘッダと同じものを入れるのは当然のことなのですが、若干フォーマットが違っているだけなので混乱しました。その辺りを注意してみてもらうと良いかと思います。例えば、リクエストークン取得時の署名用テキストのフォーマットとHTTPヘッダのフォーマットは以下のような形になります。

署名用テキストのフォーマットと値

○フォーマット
{$HTTPメソッド}&{$url}&{$OAuth関連のパラメーター}

○値
POST&https%3A%2F%2Fapi.twitter.com%2Foauth%2Frequest_token&oauth_consumer_key%3DGDdmIQH6jhtmLUypg82g%26oauth_nonce%3DQP70eNmVz8jvdPevU3oJD2AfF7R7odC2XJcn4XlZJqk%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1272323042%26oauth_version%3D1.0

HTTPヘッダのフォーマットと値

○フォーマット
{$HTTPメソッド} {$url} {$HTTPバージョン} 
Host: {$hostname}
Authorization: OAuth {$OAuth関連のパラメーター}

○値
POST https://api.twitter.com/oauth/request_token HTTP/1.1
Host: api.twitter.com
Authorization: OAuth oauth_nonce="QP70eNmVz8jvdPevU3oJD2AfF7R7odC2XJcn4XlZJqk", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1272323042", oauth_consumer_key="GDdmIQH6jhtmLUypg82g", oauth_signature="8wUi7m5HFQy76nowoCThusfgB%2BQ%3D", oauth_version="1.0"
HTTPボディは利用しない

HTTPという意味では、TwitterAPIはPOSTを推奨しているのですが、今回登場するAPIはHTTPヘッダしか利用しないので、その点についても混乱しました。POSTだからHTTPボディを使ってパラメーターを送ると思い込んだ私が悪いのですが、HTTPボディは一切利用しませんでした。

1.Consumerとして、Consumer登録

OAuthで認証を行うにはConsumer KeyとConsumer Secretが必要になるため、Twitterにサンプルアプリを登録します。登録はhttp://twitter.com/oauth_clientsから行ないます。登録後に表示される画面についてはhttp://twitter.com/oauth_clientsから再度アクセス可能なのでConsumer KeyとConsumer Secretを紙に控えたりする必要はありません。
アプリの登録に関してはTwitterのOAuth認証を使う - 強火で進めを参考にさせて頂きました。なお、今回はクライアントアプリケーションとして登録しました。また、アプリケーションのWebサイトは必須だったのでこのブログのURLを入れました。所属会社/団体は未入力でも大丈夫でした。

2.(Userから依頼があった前提で)Consumerとして、リクエストークンの取得

今回はUser兼Consumerなので、依頼があった前提でいきなりリクエストークンの取得を行ないます。

import java.io.BufferedInputStream;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.Map.Entry;
import java.util.SortedMap;
import java.util.TreeMap;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import sun.misc.BASE64Encoder;

public class RequestTokenGetter {

	public static void main(String[] args) throws Exception {
		// OAuthにおいて利用する変数宣言
		String consumerkey = "Twitterから発行されたConsumer key";
		String consumerSecret = "Twitterから発行されたConsumer secret";
		String oauthToken = ""; // リクエストトークン取得時は利用しない
		String oauthTokenSecret = ""; // リクエストトークン取得時は利用しない
		String method = "POST";
		String urlStr = "https://api.twitter.com/oauth/request_token";

		// OAuthにおいて利用する共通パラメーター
		// パラメーターはソートする必要があるためSortedMapを利用
		SortedMap<String, String> params = new TreeMap<String, String>();
		params.put("oauth_consumer_key", consumerkey);
		params.put("oauth_signature_method", "HMAC-SHA1");
		params.put("oauth_timestamp", String.valueOf(getUnixTime()));
		params.put("oauth_nonce", String.valueOf(Math.random()));
		params.put("oauth_version", "1.0");
		// params.put("oauth_token", oauthToken); // リクエストトークン取得時は利用しない

		{
			/*
			 * 署名(oauth_signature)の生成
			 */
			// パラメーターを連結する
			String paramStr = "";
			for (Entry<String, String> param : params.entrySet()) {
				paramStr += "&" + param.getKey() + "=" + param.getValue();
			}
			paramStr = paramStr.substring(1);

			// 署名対象テキスト(signature base string)の作成
			String text = method + "&" + urlEncode(urlStr) + "&"
					+ urlEncode(paramStr);

			// 署名キーの作成
			String key = urlEncode(consumerSecret) + "&"
					+ urlEncode(oauthTokenSecret);

			// HMAC-SHA1で署名を生成
			SecretKeySpec signingKey = new SecretKeySpec(key.getBytes(),
					"HmacSHA1");
			Mac mac = Mac.getInstance(signingKey.getAlgorithm());
			mac.init(signingKey);
			byte[] rawHmac = mac.doFinal(text.getBytes());
			String signature = new BASE64Encoder().encode(rawHmac);

			// 署名をパラメータに追加
			params.put("oauth_signature", signature);
		}

		// Authorizationヘッダの作成
		String paramStr = "";
		for (Entry<String, String> param : params.entrySet()) {
			paramStr += ", " + param.getKey() + "=\""
					+ urlEncode(param.getValue()) + "\"";
		}
		paramStr = paramStr.substring(2);
		String authorizationHeader = "OAuth " + paramStr;

		// APIにアクセス
		URL url = new URL(urlStr);
		HttpURLConnection connection = (HttpURLConnection) url.openConnection();
		connection.setRequestMethod(method);
		connection.setRequestProperty("Authorization", authorizationHeader);
		connection.connect();
		BufferedReader reader = new BufferedReader(new InputStreamReader(
				connection.getInputStream()));
		String response;
		while ((response = reader.readLine()) != null) {
			System.out.println(response);
		}
	}

	private static int getUnixTime() {
		return (int) (System.currentTimeMillis() / 1000L);
	}

	private static String urlEncode(String string) {
		try {
			return URLEncoder.encode(string, "UTF-8");
		} catch (UnsupportedEncodingException e) {
			throw new RuntimeException(e);
		}
	}
}

これでリクエストークン(oauth_tokenとoauth_token_secret)が手に入りました。このリクエストークンはアクセストークン取得時にのみ利用する使い捨てのトークンです。

3.Userとして、サンプルアプリの認証

Userとしてサンプルアプリの認証を行ないます。Webアプリケーションの場合はリダイレクトされると思いますが、今回はクライアントアプリケーションであるため、ブラウザのアドレスバーに以下を入力します。

フォーマット

http://twitter.com/oauth/authorize?oauth_token=${リクエストトークン取得時に取得したoauth_token}

http://twitter.com/oauth/authorize?oauth_token=HKBiR35JHnQsWN1BNqeqsp2dpE0Cc4SwwoghrUYmMQ

アプリケーションを許可すると暗証番号が表示されます。アクセストークンの取得時にこの暗証番号を利用します。

4.Consumerとして、アクセストークンの取得

今までに取得したConsumer key、Consumer secret、oauth_token、oauth_token_secret、暗証番号を利用してアクセストークンを取得します。処理の流れはリクエストークンの取得時とほぼ同じです。違いは以下の3点ぐらいです。

  • oauth_token、oauth_token_secretを利用するようになった。
  • 暗証番号(oauth_verifier)を利用する。
  • urlがアクセストークン取得APIである。
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.Map.Entry;
import java.util.SortedMap;
import java.util.TreeMap;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import sun.misc.BASE64Encoder;

public class AccessTokenGetter {

	public static void main(String[] args) throws Exception {
		// OAuthにおいて利用する変数宣言
		String consumerkey = "Twitterから発行されたConsumer key";
		String consumerSecret = "Twitterから発行されたConsumer secret";
		String oauthToken = "リクエストトークン取得時に取得したoauth_token";
		String oauthTokenSecret = "リクエストトークン取得時に取得したoauth_token_secret";
		String method = "POST";
		String urlStr = "https://api.twitter.com/oauth/access_token";

		// 共通パラメーター
		SortedMap<String, String> params = new TreeMap<String, String>();
		params.put("oauth_consumer_key", consumerkey);
		params.put("oauth_signature_method", "HMAC-SHA1");
		params.put("oauth_timestamp", String.valueOf(getUnixTime()));
		params.put("oauth_nonce", String.valueOf(Math.random()));
		params.put("oauth_version", "1.0");
		params.put("oauth_token", oauthToken);

		// アクセストークン取得時にのみ利用するパラメーター
		// アプリケーションの許可をした場合に表示される暗証番号を設定する
		params.put("oauth_verifier", "暗証番号");

		{
			/*
			 * 署名(oauth_signature)の生成
			 * リクエストトークン取得時と全く同じ処理
			 */
			String paramStr = "";
			for (Entry<String, String> param : params.entrySet()) {
				paramStr += "&" + param.getKey() + "=" + param.getValue();
			}
			paramStr = paramStr.substring(1);

			String text = method + "&" + urlEncode(urlStr) + "&"
					+ urlEncode(paramStr);

			String key = urlEncode(consumerSecret) + "&"
					+ urlEncode(oauthTokenSecret);

			SecretKeySpec signingKey = new SecretKeySpec(key.getBytes(),
					"HmacSHA1");
			Mac mac = Mac.getInstance(signingKey.getAlgorithm());
			mac.init(signingKey);
			byte[] rawHmac = mac.doFinal(text.getBytes());
			String signature = new BASE64Encoder().encode(rawHmac);

			params.put("oauth_signature", signature);
		}

		/*
		 * Authorizationヘッダの作成とAPIの呼び出し
		 * リクエストトークン取得時と全く同じ処理
		 */
		// Authorizationヘッダの作成
		String paramStr = "";
		for (Entry<String, String> param : params.entrySet()) {
			paramStr += ", " + param.getKey() + "=\""
					+ urlEncode(param.getValue()) + "\"";
		}
		paramStr = paramStr.substring(2);
		String authorizationHeader = "OAuth " + paramStr;

		// APIにアクセス
		URL url = new URL(urlStr);
		HttpURLConnection connection = (HttpURLConnection) url.openConnection();
		connection.setRequestMethod(method);
		connection.setRequestProperty("Authorization", authorizationHeader);
		connection.connect();
		BufferedReader reader = new BufferedReader(new InputStreamReader(
				connection.getInputStream()));
		String response;
		while ((response = reader.readLine()) != null) {
			System.out.println(response);
		}
	}

	private static int getUnixTime() {
		return (int) (System.currentTimeMillis() / 1000L);
	}

	private static String urlEncode(String string) {
		try {
			return URLEncoder.encode(string, "UTF-8");
		} catch (UnsupportedEncodingException e) {
			throw new RuntimeException(e);
		}
	}
}

これでやっとアクセストークン(oauth_tokenとoauth_token_secret)が手に入りました。

5.Consumerとして、Twitterにつぶやく

今までに取得したConsumer key、Consumer secret、oauth_token、oauth_token_secretを利用してTwitterにつぶやきます(ステータス更新APIを呼び出します)。処理の流れはアクセストークン取得時とほぼ同じです。違いは以下の3点ぐらいです。

  • oauth_token、oauth_token_secretの値がアクセストークン取得時のものに変わる。リクエストークン取得時に取得したoauth_token、oauth_token_secretはアクセストークン取得時のみしか利用しない。
  • 暗証番号(oauth_verifier)は使わない。暗証番号はアクセストークン取得時のみしか利用しない。
  • urlがステータス更新APIである。
    • ステータス更新APIの必須パラメーターであるstatus変数を扱う。
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.Map.Entry;
import java.util.SortedMap;
import java.util.TreeMap;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import sun.misc.BASE64Encoder;

public class StatusUpdater {

	public static void main(String[] args) throws Exception {

		// OAuthにおいて利用する変数宣言
		String consumerkey = "Twitterから発行されたConsumer key";
		String consumerSecret = "Twitterから発行されたConsumer secret";
		String oauthToken = "アクセストークン取得時に取得したoauth_token";
		String oauthTokenSecret = "アクセストークン取得時に取得したoauth_token_secret";
		String method = "POST";
		String urlStr = "https://api.twitter.com/1.1/statuses/update.json";

		// 共通パラメーター
		SortedMap<String, String> params = new TreeMap<String, String>();
		params.put("oauth_consumer_key", consumerkey);
		params.put("oauth_signature_method", "HMAC-SHA1");
		params.put("oauth_timestamp", String.valueOf(getUnixTime()));
		params.put("oauth_nonce", String.valueOf(Math.random()));
		params.put("oauth_version", "1.0");
		params.put("oauth_token", oauthToken);

		// ステータス更新API利用時にのみ利用するパラメーター
		String status = "てすと";
		params.put("status", urlEncode(status));

		{
			/*
			 * 署名(oauth_signature)の生成
			 * リクエストトークン取得時と全く同じ処理
			 */
			String paramStr = "";
			for (Entry<String, String> param : params.entrySet()) {
				paramStr += "&" + param.getKey() + "=" + param.getValue();
			}
			paramStr = paramStr.substring(1);

			String text = method + "&" + urlEncode(urlStr) + "&"
					+ urlEncode(paramStr);

			String key = urlEncode(consumerSecret) + "&"
					+ urlEncode(oauthTokenSecret);

			SecretKeySpec signingKey = new SecretKeySpec(key.getBytes(),
					"HmacSHA1");
			Mac mac = Mac.getInstance(signingKey.getAlgorithm());
			mac.init(signingKey);
			byte[] rawHmac = mac.doFinal(text.getBytes());
			String signature = new BASE64Encoder().encode(rawHmac);

			params.put("oauth_signature", signature);
		}

		/*
		 * Authorizationヘッダの作成とAPIの呼び出し
		 * リクエストトークン取得、アクセストークン取得と以下の点が異なる。
		 * (1)statusはAuthorizationヘッダーではなくurlに含めるためparamsから削除する
		 * (2)urlにstatusを含める
		 */
		// (1)statusはAuthorizationヘッダーではなくurlに含めるためparamsから削除する
		params.remove("status");

		// Authorizationヘッダの作成
		String paramStr = "";
		for (Entry<String, String> param : params.entrySet()) {
			paramStr += ", " + param.getKey() + "=\""
					+ urlEncode(param.getValue()) + "\"";
		}
		paramStr = paramStr.substring(2);
		String authorizationHeader = "OAuth " + paramStr;

		// APIにアクセス
		// (2)urlにstatusを含める
		URL url = new URL(urlStr + "?status=" + urlEncode(status));
		HttpURLConnection connection = (HttpURLConnection) url.openConnection();
		connection.setRequestMethod(method);
		connection.setRequestProperty("Authorization", authorizationHeader);
		connection.connect();
		BufferedReader reader = new BufferedReader(new InputStreamReader(
				connection.getInputStream()));
		String response;
		while ((response = reader.readLine()) != null) {
			System.out.println(response);
		}
	}

	private static int getUnixTime() {
		return (int) (System.currentTimeMillis() / 1000L);
	}

	private static String urlEncode(String string) {
		try {
			return URLEncoder.encode(string, "UTF-8");
		} catch (UnsupportedEncodingException e) {
			throw new RuntimeException(e);
		}
	}
}

これで「てすと」とつぶやけました。めでたしめでたし。

ソースコードに関する補足

oauth_timestampについて

フォーマットはUnixTimeです。UnixTimeは UTC 1970 年 1 月 1 日午前 0 時 からの秒数ですが、Javaの場合は経過ミリ秒なので1000で割る必要があります。Getting "unixtime" in Java - Stack Overflowを参考にしました。

oauth_nonceについて

RFC 5849 - The OAuth 1.0 Protocolによるとランダムな文字列でよさげだったので、単にMath.random()を使うことにしました。

HMAC-SHA1による署名について

Yahoo!デベロッパーネットワーク - OAuth - リクエストの署名がとても分かりやすいです。なお、Base64エンコーディングが必要なのですが、Javaの標準ライブラリにはBase64エンコーダーが存在しないため、sun.misc.BASE64Encoderを使いました。。JavaでのBase64に関してはjavaにおけるbase64の性能テスト - トラシスラボ 技術ブログがまとまっています。まぁ、commons-codecを使えってことですね。

ちなみに、Eclipseを利用しているとsun.misc.BASE64EncoderでErrorが出ると思いますが、こちらはプロジェクトのコンパイラの設定を変えれば解消出来ます。 java - import sun.misc.BASE64Encoder got error in Eclipse - Stack Overflow 辺りを見て下さい。まぁ、ほんと使うなってことだと思いますが。。

標準ライブラリでのHTTPリクエス

URL#openConnectionの戻り値はURLConnection型ですが、urlがhttpであればHttpURLConnectionが、httpsであればHttpsURLConnectionが取得できるのでダウンキャストして利用します。Java による簡単な HTTP 通信を参考にしました。まぁ、普通はApache HttpComponentsのHttpClient(旧Commons HttpClient)を使うでしょうね。