Twitter API v2.0について – ユーザ認証編 –

Monacaチームの小田川です。

前回、アプリ認証でのTwitter API v2.0の利用方法を紹介しましたが、今回は、ユーザ認証での利用方法を紹介したいと思います。

ユーザ認証について調べてみると、PHP等からユーザ認証でアクセスする方法は見つかるのですが、Android(Kotlin)から利用する方法がなかったので、今回は、Android(Kotlin)からユーザ認証でTwitter API v2.0を利用する方法を紹介してみたいと思います。

今回使用する、

  • APIキー
  • APIシークレット
  • アクセストークン
  • アクセストークン シークレット

は、あらかじめ取得できているものとします。

Twitter API v2.0の利用(ユーザ認証)

アプリ認証の場合は、Bearer Tokenを取得することで、簡単に利用することが出来ましたが、ユーザ認証の場合は、OAuth 1.0を利用するため、署名に必要なデータを作成する必要があります。

今回は、前回のアプリ認証で使用したTwitter API v2.0のtweetsを例にしています。全体のソースコードを掲載しています。コード量が多いので、コードを確認しながら読み進めてください。

HTMLの解析には、Jsoupを使用しています。

署名用データ

tweetsオプション

リクエストにtweetsオプションを設定する場合は、このtweetsオプション情報も署名用データに含める必要があります。ソースコードのtweetsオプションに設定されているtweetsオプション情報を使用して署名用データを作成する場合は、Mapでコレクションを作成し、

  • キー: オプション名
  • 値: オプションの値

という形式でデータを作成します。

OAuthパラメータ

OAuthパラメータのMapには、以下の情報を設定します。

  • oauth_token: アクセストークン
  • oauth_consumer_key: APIキー
  • oauth_signature_method: 署名メソッド
  • oauth_timestamp: UNIXタイムスタンプ
  • oauth_nonce: ユニークな値
  • oauth_version: OAuthバージョン

署名用データの作成

tweetsオプションとOAuthパラメータを署名用データとして一つのMapにまとめます。署名用データを作成する場合は、以下の設定が必要になります。

  • キーでデータをソートする。
  • キーと値をエンコードする。
  • エンコードしたキーと値を=でつなぐ。
  • キーと値が複数ある場合は&でつなぐ。

次に、エンコードされた+~は、署名で使用できるように文字列を変換する必要があります。変換内容は、replaceParams()を参照していください。

署名ベース文字列の作成

署名ベース文字列を作成する場合は、以下の情報が必要になります。

  • リクエストメソッド名
  • リクエストURL
  • 署名用データ

エンコードしたリクエストメソッド名、リクエストURL、署名用データを&でつなげることで、署名ベース文字列を作成することが出来ます。

署名の作成

作成する署名はHMAC-SHA1で作成する必要があります。処理の流れは、以下になります。

  • 秘密鍵の作成。
  • 秘密鍵から署名を作成。
  • 作成した署名を取得。
  • 取得した署名をbase64エンコード。

署名の作成については、ソースコードのcreateSignature()を参照してください。

リクエストヘッダー用データ

リクエストヘッダー用のデータも作成する必要があります。リクエストヘッダー用のデータを作成する場合は、tweetsオプションとOAuthパラメータを一つにまとめたMapで、以下の設定が必要になります。

  • キーでデータをソートする
  • キーと値をエンコードする
  • エンコードしたキーと値を=でつなぐ
  • キーと値が複数ある場合は,でつなぐ

次に、エンコードされた+~は、署名用データと同じく文字列を変換する必要があります。変換内容は、replaceParams()を参照していください。

リクエストヘッダーに設定するOAuthデータの作成

リクエストヘッダーに設定するOAuthデータは、リクエストヘッダー用データの後ろに署名を追加する形で作成します。以下は、ソースコードの抜粋です。

"OAuth $headerParams,oauth_signature=$signature"

リクエストを送信する

リクエストの送信は、HttpURLConnectionで行なっています。署名の設定とリクエストヘッダーの設定が正しく行われていれば、ユーザ認証でツイートを取得することができます。

今回のソースコードを使用してアプリ認証を行いたい場合は、httpURLConnection.setRequestProperty()で、Bearer Tokenの設定を行うことで対応できます。

ソースコード

fun getTweets() {
    val requestUrl = "https://api.twitter.com/2/tweets/1312028311011438592" // エンドポイント
    val requestMethod = "GET" // リクエストメソッド

    val apiKey = "APIキー" // APIキー
    val apiSecret = "APIシークレット" // APIシークレット
    val accessToken = "アクセストークン" // アクセストークン
    val accessTokenSecret = "アクセストークンシークレット" // アクセストークンシークレット

    // tweetsオプション
    val expansions = "expansions=attachments.poll_ids,attachments.media_keys,author_id,entities.mentions.username,geo.place_id,in_reply_to_user_id,referenced_tweets.id,referenced_tweets.id.author_id"
    val mediaFields = "media.fields=duration_ms,height,media_key,preview_image_url,type,url,width,public_metrics"
    val placeFields = "place.fields=contained_within,country,country_code,full_name,geo,id,name,place_type"
    val pollFields = "poll.fields=duration_minutes,end_datetime,id,options,voting_status"
    val tweetFields = "tweet.fields=attachments,author_id,context_annotations,conversation_id,created_at,entities,geo,id,in_reply_to_user_id,lang,public_metrics,possibly_sensitive,referenced_tweets,source,text,withheld"
    val userFields = "user.fields=created_at,description,entities,id,location,name,pinned_tweet_id,profile_image_url,protected,public_metrics,url,username,verified,withheld"

    val param: MutableMap<String, String> = mutableMapOf() // 各種データ作成用コレクション

    // tweetsオプション用データの作成
    val expansionsSplit = expansions.split("=")
    val mediaFieldsSplit = mediaFields.split("=")
    val placeFieldsSplit = placeFields.split("=")
    val pollFieldsSplit = pollFields.split("=")
    val tweetFieldsSplit = tweetFields.split("=")
    val userFieldsSplit = userFields.split("=")
    val param1: Map<String, String> = mapOf(
            expansionsSplit[0] to expansionsSplit[1],
            mediaFieldsSplit[0] to mediaFieldsSplit[1],
            placeFieldsSplit[0] to placeFieldsSplit[1],
            pollFieldsSplit[0] to pollFieldsSplit[1],
            tweetFieldsSplit[0] to tweetFieldsSplit[1],
            userFieldsSplit[0] to userFieldsSplit[1],
    )

    // OAuthパラメータの作成
    val timestamp = System.currentTimeMillis() / 1000 // UNIXタイムスタンプ
    val randomInt = Random.nextInt() // oauth_nonce用乱数
    val param2: Map<String, String> = mapOf(
            "oauth_token" to accessToken,
            "oauth_consumer_key" to apiKey,
            "oauth_signature_method" to "HMAC-SHA1",
            "oauth_timestamp" to "$timestamp",
            "oauth_nonce" to "$randomInt+$timestamp",
            "oauth_version" to "1.0"
    )

    // tweetsオプション用データと署名用データをマージ
    param.putAll(param1)
    param.putAll(param2)


    // 署名データ用パラメータ情報の作成(キーでソートする必要があります)
    var requestParams = ""
    for (item in param.toSortedMap()) {
        if (requestParams.isEmpty()) {
            requestParams = URLEncoder.encode(item.key, "UTF-8") + "=" + URLEncoder.encode(item.value, "UTF-8")
        } else {
            requestParams += "&" + URLEncoder.encode(item.key, "UTF-8") + "=" + URLEncoder.encode(item.value, "UTF-8")
        }
    }
    requestParams = replaceParams(requestParams) // 署名データ用に文字列を変換

    // 署名の作成
    val encodedRequestMethod = URLEncoder.encode(requestMethod, "UTF-8")
    val encodedRequestURL= URLEncoder.encode(requestUrl, "UTF-8")
    val encodedRequestParams = URLEncoder.encode(requestParams, "UTF-8")
    val signatureBase = "$encodedRequestMethod&$encodedRequestURL&$encodedRequestParams" // 署名ベース文字列
    val signatureKey = URLEncoder.encode(apiSecret, "UTF-8") + "&" + URLEncoder.encode(accessTokenSecret, "UTF-8")
    val signature = createSignature(signatureBase, signatureKey) // 署名


    // リクエストヘッダー用のデータ作成(キーでソートする必要があります)
    var headerParams = ""
    for (item in param.toSortedMap()) {
        if (headerParams.isEmpty()) {
            headerParams = URLEncoder.encode(item.key, "UTF-8") + "=" + URLEncoder.encode(item.value, "UTF-8")
        } else {
            headerParams += "," + URLEncoder.encode(item.key, "UTF-8") + "=" + URLEncoder.encode(item.value, "UTF-8")
        }
    }
    headerParams = replaceParams(headerParams) // リクエストヘッダー用のデータ文字列を変換。

    // HttpURLConnectionのheaderに設定するOAuthデータを作成
    val oAuth = "OAuth $headerParams,oauth_signature=$signature"


    // tweetsを取得
    val url = URL("$requestUrl?$expansions&$mediaFields&$placeFields&$pollFields&$tweetFields&$userFields")
    // val bearerToken = "Bearer {Bearer Token}"
    val httpURLConnection: HttpURLConnection
    httpURLConnection = url.openConnection() as HttpURLConnection
    httpURLConnection.requestMethod = requestMethod
    // httpURLConnection.setRequestProperty("Authorization", bearerToken)
    httpURLConnection.setRequestProperty("Authorization", oAuth)
    httpURLConnection.connect()
    if (httpURLConnection.responseCode == HttpURLConnection.HTTP_OK) {
        val readStream = httpURLConnection.inputStream.use { readInputStream(it) }
        val document = Jsoup.parse(readStream)
        val body = JSONObject(document.body().text())

        if (BuildConfig.DEBUG) {
            Log.d("tweets id: ", JSONObject(body.getString("data")).getLong("id").toString())
            Log.d("tweets text: ", JSONObject(body.getString("data")).getString("text"))
        }
    }

    httpURLConnection.disconnect()
}

fun createSignature(signatureBase: String, signatureKey: String): String {
    // 署名アルゴリズム
    val algorithm = "HmacSHA1"

    // 秘密鍵の作成
    val secretKey: SecretKey = SecretKeySpec(signatureKey.toByteArray(), algorithm)

    // 秘密鍵から署名を作成
    val mac = Mac.getInstance(algorithm)
    mac.init(secretKey)
    mac.update(signatureBase.toByteArray())

    // 署名を取得
    val signature = mac.doFinal()

    // 署名をbase64エンコード
    return URLEncoder.encode(Base64.encodeToString(signature, Base64.NO_WRAP), "UTF-8")
}

fun replaceParams(params: String): String {
    return params.replace("%2B", "+").replace("+", "%20").replace("%7E", "~")
}

fun readInputStream(inputStream: InputStream): String {
    val stringBuilder = StringBuilder()

    BufferedReader(InputStreamReader(inputStream, StandardCharsets.UTF_8)).use {
        var line : String?

        do {
            line = it.readLine()
            line ?: break

            stringBuilder.append(line)
        } while (true)
    }

    return stringBuilder.toString()
}

おわりに

ユーザ認証を行う場合は、認証用データを作成する必要があるため敷居が高いですが、作成方法が分かれば他にも応用が効くと思います。ユーザ認証に興味があれば、一度、トライしてみてください。