ツイッター Ktorと共にOAuth 1.0

新しいプロジェクトでプロバイダーに沢山のソーシャルメディアのサインを入れなければいけない。これを行うにはOAuthに関して学び、APIを通してFacebook、Google、Gitub、TwitterなどのOAuthプロバイダーとコミュニケーションを取る事が必要である。最初の3社は簡単に対応できるが、Twitterはそうではない。5時間かけてTwitterの書面を読み、ブログ投稿をし、ライブラリへ行き、コードを書いた。これをしてうまくいった。

ステップ1 - OAuth URLの設定とパラメーター

これ使い、 KtorのOAuthパッケージ, urlProviderconsumerKeyconsumerSecretを変更する必要がある。他のフィールドはそのままで良いです。

oauth("auth-oauth-twitter") {
	urlProvider = { "${config.appConfig.backEndBaseUrl}/auth/callback/twitter" }
	providerLookup = {
	    OAuthServerSettings.OAuth1aServerSettings(
	        name = "twitter",
	        requestTokenUrl = "https://api.twitter.com/oauth/request_token",
	        authorizeUrl = "https://api.twitter.com/oauth/authorize",
	        accessTokenUrl = "https://api.twitter.com/oauth/access_token",
	        consumerKey = "CONSUMER_KEY_FROM_TWITTER_DEV_PORTAL",
	        consumerSecret = "CONSUMER_SECRET_FROM_TWITTER_DEV_PORTAL",
	    )
	}
	client = httpClient
}

ステップ 2 – トリガー、コールバックの最終ポイントを作成する

ハンドラの設定を行い、OAuthフローのトリガーをさせる必要があり、これはKtorのパッケージで行ってくれます。もっと面白いのはコールバックURLです。TwitterコールはコールバックURLで トーケントーケンシークレットを使用します。APIへ依頼を出す際にはこのトーケンが必要となるのです。私の場合ユーザーのプロフィールでサインインする事が出来ますが、あなたの場合はユーザーの代わりにTweetする必要があるかもしれません。

authenticate("auth-oauth-twitter") {
    get("/auth/login/twitter") {
        // Redirects to 'authorizeUrl' automatically
    }
    get("/auth/callback/twitter") {
        val response = twitterTransport.getUser(principal.tokenSecret, principal.token)
            ?: throw BadRequestException(ErrorType.FAILED_TO_FETCH_TWITTER_USER, "Failed to fetch twitter user")
        val (email, name) = response
        logger.info("{} logged in with Twitter.", response.email)
        call.respondRedirect("/somewhere")
    }
}

今の所、コードはシンプルで、Twitterが私のコールバック最終ポイントのコールを行い、私はユーザーのプロフィールを使って得たトーケンシークレットやトーケンを使います。TwitterTransport.getUserの中では何が起きているでしょう?

ステップ 3 – ノンス作成

fun createNonce(): String {
    val charPool : List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
    val randomString = ThreadLocalRandom.current()
        .ints(32, 0, charPool.size)
        .asSequence()
        .map { charPool[it] }
        .joinToString("")
    return randomString
}

これでランダムな32のアルファベットストリングを作成します。

ステップ 4 – 数秒でタイムスタンプを獲得する

val timestamp = Instant.now().epochSecond

ステップ 5 - パラメーターの収集

include_emailの箇所でパラメーターを設定できます。下記のURLでパラメータの設定が可能です。 (https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true) リクエストしています。別のURLをクリックする場合、クエリパラメーターのURLも下記のパラメータへ含めてください。

val params = listOf(
    "include_email" to "true",
    "oauth_consumer_key" to consumerKey,
    "oauth_nonce" to nonce,
    "oauth_signature_method" to "HMAC-SHA1",
    "oauth_timestamp" to timestamp.toString(),
    "oauth_token" to oAuthToken,
    "oauth_version" to "1.0"
)

ステップ 6 – パラメータストリングの作成

私のコードは com.google.common.net.PercentEscaperを使用します。このパラメータのアルゴリズムの作成は下記から閲覧できます。 here パラメータヘッダー収集の下にあります。

private val escaper = PercentEscaper("_-.", false)
fun createParamString(params: List<Pair<String, String>>): String {
    return params.map { (key, value) ->
        escaper.escape(key) to escaper.escape(value)
    }.sortedBy {
        it.first
    }.map { (key, value) ->
        "$key=$value"
    }.joinToString("&")
}

ステップ 7 – 署名ベースストリングの作成

fun createSignatureBaseString(method: String, url: String, parameterString: String): String {
`    `val buffer = StringBuilder()
`    `buffer.append(method.uppercase())
`    `buffer.append("&")
`    `buffer.append(escaper.escape(url))
`    `buffer.append("&")
`    `buffer.append(escaper.escape(parameterString))
`    `return buffer.toString()
}

ステップ 8 – 署名キーの作成

fun createSigningKey(consumerSecret: String, oauthTokenSecret: String): String {
    val buffer = StringBuilder()
    buffer.append(escaper.escape(consumerSecret))
    buffer.append("&")
    buffer.append(escaper.escape(oauthTokenSecret))
    return buffer.toString()
}

ステップ9 – 署名作成

org.apache.commons.codec.digest内のHmacUtilsを使用し、Base64edをし、バイトアレーを出します。

fun hmacSign(signingKey: String, signatureBaseString: String): String {
    val hmacSha1 = HmacUtils.hmacSha1(signingKey, signatureBaseString)
    return Base64.getEncoder().encodeToString(hmacSha1)
}

ステップ 10 – 承認ヘッダーの作成

ここで細かくストリングで分かれていたものを”,”でくっつけて全て収集します。

fun createAuthHeader(consumerKey: String, timestamp: Long, nonce: String, signature: String, token: String): String {
    val pairs = listOf(
        "oauth_consumer_key" to consumerKey,
        "oauth_nonce" to nonce,
        "oauth_signature" to signature,
        "oauth_signature_method" to "HMAC-SHA1",
        "oauth_timestamp" to timestamp.toString(),
        "oauth_token" to token,
        "oauth_version" to "1.0",
    )
    val collected = pairs.map { (key, value) ->
        "${escaper.escape(key)}="${escaper.escape(value)}""
    }.joinToString(", ")
    return "OAuth $collected"
}

これで承認ヘッダーが完成しましたのでTwitterのAPIへHTTPの依頼が出来ます

OAuthEncoder.kt

class OAuthEncoder {
    private val escaper = PercentEscaper("_-.", false)
    fun createNonce(): String {
        val charPool : List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
        val randomString = ThreadLocalRandom.current()
            .ints(32, 0, charPool.size)
            .asSequence()
            .map { charPool[it] }
            .joinToString("")
        return randomString
    }
    fun createAuthHeader(consumerKey: String, timestamp: Long, nonce: String, signature: String, token: String): String {
        val pairs = listOf(
            "oauth_consumer_key" to consumerKey,
            "oauth_nonce" to nonce,
            "oauth_signature" to signature,
            "oauth_signature_method" to "HMAC-SHA1",
            "oauth_timestamp" to timestamp.toString(),
            "oauth_token" to token,
            "oauth_version" to "1.0",
        )
        val collected = pairs.map { (key, value) ->
            "${escaper.escape(key)}="${escaper.escape(value)}""
        }.joinToString(", ")
        return "OAuth $collected"
    }
    fun hmacSign(signingKey: String, signatureBaseString: String): String {
        val hmacSha1 = HmacUtils.hmacSha1(signingKey, signatureBaseString)
        return Base64.getEncoder().encodeToString(hmacSha1)
    }
    fun createSigningKey(consumerSecret: String, oauthTokenSecret: String): String {
        val buffer = StringBuilder()
        buffer.append(escaper.escape(consumerSecret))
        buffer.append("&")
        buffer.append(escaper.escape(oauthTokenSecret))
        return buffer.toString()
    }
    fun createSignatureBaseString(method: String, url: String, parameterString: String): String {
        val buffer = StringBuilder()
        buffer.append(method.uppercase())
        buffer.append("&")
        buffer.append(escaper.escape(url))
        buffer.append("&")
        buffer.append(escaper.escape(parameterString))
        return buffer.toString()
    }
    fun createParamString(params: List<Pair<String, String>>): String {
        return params.map { (key, value) ->
            escaper.escape(key) to escaper.escape(value)
        }.sortedBy {
            it.first
        }.map { (key, value) ->
            "$key=$value"
        }.joinToString("&")
    }
    fun createSignature(
        consumerSecret: String,
        oAuthTokenSecret: String,
        method: String,
        url: String,
        params: List<Pair<String, String>>
    ): String {
        val paramString = createParamString(params)
        val signatureBaseString = createSignatureBaseString(method, url, paramString)
        val signingKey = createSigningKey(consumerSecret, oAuthTokenSecret)
        return hmacSign(signingKey, signatureBaseString)
    }
}

TwitterTransport.kt

interface ITwitterTransport {
    data class User(val email: String, val name: String)
    fun getUser(oAuthTokenSecret: String, oAuthToken: String): User?
}
class TwitterTransport : ITwitterTransport {
    private val logger = LoggerFactory.getLogger(this::class.java)
    private val client = HttpClient.newBuilder().build()
    private val consumerKey = "CONSUMER_KEY"
    private val consumerSecret = "CONSUMER_SECRET"
    private val encoder = OAuthEncoder()
    override fun getUser(oAuthTokenSecret: String, oAuthToken: String): ITwitterTransport.User {
        val url = "https://api.twitter.com/1.1/account/verify_credentials.json"
        val nonce = encoder.createNonce()
        val timestamp = Instant.now().epochSecond
        val params = listOf(
            "include_email" to "true",
            "oauth_consumer_key" to consumerKey,
            "oauth_nonce" to nonce,
            "oauth_signature_method" to "HMAC-SHA1",
            "oauth_timestamp" to timestamp.toString(),
            "oauth_token" to oAuthToken,
            "oauth_version" to "1.0"
        )
        logger.info("oAuthToken = {}", oAuthToken)
        val signature = encoder.createSignature(
            consumerSecret = consumerSecret,
            oAuthTokenSecret = oAuthTokenSecret,
            method = "get",
            url = url,
            params = params
        )
        val authHeader = encoder.createAuthHeader(
            consumerKey = consumerKey,
            timestamp = timestamp,
            nonce = nonce,
            signature = signature,
            token = oAuthToken
        )
        val request = HttpRequest.newBuilder()
            .uri(URI.create("$url?include_email=true"))
            .header("Authorization", authHeader)
            .GET()
            .build()
        val response = client.send(request, HttpResponse.BodyHandlers.ofString())
        val body = parse<JsonNode>(response.body())
        logger.info("${response.statusCode()} ${response.body()}")
        val email = body["email"].asText()
        val name = body["name"].asText()
        return ITwitterTransport.User(email, name)
    }
}

参考

[1] -依頼の承認, https://developer.twitter.com/en/docs/authentication/oauth-1-0a/authorizing-a-request

[2] -署名作成, https://developer.twitter.com/en/docs/authentication/oauth-1-0a/creating-a-signature

Join The Mailing List