SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/api/auth/AuthMastodon.kt

377 lines
16 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package jp.juggler.subwaytooter.api.auth
import android.net.Uri
import jp.juggler.subwaytooter.api.SendException
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.Host
import jp.juggler.subwaytooter.api.entity.InstanceType
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.push.FcmFlavor
import jp.juggler.subwaytooter.table.daoClientInfo
import jp.juggler.subwaytooter.table.daoSavedAccount
import jp.juggler.subwaytooter.util.LinkHelper
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.errorEx
class AuthMastodon(override val client: TootApiClient) : AuthBase() {
companion object {
private val log = LogCategory("MastodonAuth")
@Suppress("MayBeConstant", "RedundantSuppression")
val DEBUG_AUTH = false
const val callbackUrl = "${FcmFlavor.CUSTOM_SCHEME}://oauth/"
fun mastodonScope(ti: TootInstance?) = when {
// 古いサーバ
ti?.versionGE(TootInstance.VERSION_2_4_0_rc1) == false -> "read write follow"
// 新しいサーバか、AUTHORIZED_FETCH(3.0.0以降)によりサーバ情報を取得できなかった
else -> "read write follow push"
// 過去の試行錯誤かな
// ti.versionGE(TootInstance.VERSION_2_7_0_rc1) -> "read+write+follow+push+create"
}
}
val api = ApiAuthMastodon(client)
// クライアントアプリの登録を確認するためのトークンを生成する
// oAuth2 Client Credentials の取得
// https://github.com/doorkeeper-gem/doorkeeper/wiki/Client-Credentials-flow
// このトークンはAPIを呼び出すたびに新しく生成される…
private suspend fun createClientCredentialToken(
apiHost: Host,
savedClientInfo: JsonObject,
): String {
val credentialInfo = api.createClientCredential(
apiHost = apiHost,
clientId = savedClientInfo.string("client_id")
?: error("missing client_id"),
clientSecret = savedClientInfo.string("client_secret")
?: error("missing client_secret"),
callbackUrl = callbackUrl,
)
log.d("credentialInfo: $credentialInfo")
return credentialInfo.string("access_token")
?.notEmpty() ?: error("missing client credential.")
}
private suspend fun prepareClientCredential(
apiHost: Host,
clientInfo: JsonObject,
clientName: String,
): String? {
// 既にcredentialを持っているならそれを返す
clientInfo.string(KEY_CLIENT_CREDENTIAL)
.notEmpty()?.let { return it }
// token in clientCredential
val clientCredential = try {
createClientCredentialToken(apiHost, clientInfo)
} catch (ex: Throwable) {
if ((ex as? SendException)?.response?.code == 422) {
// https://github.com/tateisu/SubwayTooter/issues/156
// some servers not support to get client_credentials.
// just ignore error and skip.
return null
} else {
throw ex
}
}
clientInfo[KEY_CLIENT_CREDENTIAL] = clientCredential
daoClientInfo.save(apiHost, clientName, clientInfo.toString())
return clientCredential
}
// result.JsonObject に credentialつきのclient_info を格納して返す
private suspend fun prepareClientImpl(
apiHost: Host,
clientName: String,
tootInstance: TootInstance?,
forceUpdateClient: Boolean,
): JsonObject {
var clientInfo = daoClientInfo.load(apiHost, clientName)
// スコープ一覧を取得する
val scopeString = mastodonScope(tootInstance)
try {
when {
// 古いクライアント情報は使わない。削除もしない。
AUTH_VERSION != clientInfo?.int(KEY_AUTH_VERSION) -> Unit
// Misskeyにはclient情報をまだ利用できるかどうか調べる手段がないので、再利用しない
clientInfo.boolean(KEY_IS_MISSKEY) == true -> Unit
else -> {
val clientCredential = prepareClientCredential(apiHost, clientInfo, clientName)
// client_credential があるならcredentialがまだ使えるか確認する
if (!clientCredential.isNullOrEmpty()) {
// 存在確認するだけで、結果は使ってない
api.verifyClientCredential(apiHost, clientCredential)
// 過去にはスコープを+で連結したものを保存していた
val oldScope = clientInfo.string(KEY_CLIENT_SCOPE)
?.replace("+", " ")
when {
// クライアント情報を再利用する
!forceUpdateClient && oldScope == scopeString -> return clientInfo
else -> {
// マストドン2.4でスコープが追加された
// 取得時のスコープ指定がマッチしない(もしくは記録されていない)ならクライアント情報を再利用してはいけない
daoClientInfo.delete(apiHost, clientName)
// クライアントアプリ情報そのものはまだサーバに残っているが、明示的に消す方法は現状存在しない
// client credential だけは消せる
api.revokeClientCredential(
apiHost = apiHost,
clientId = clientInfo.string("client_id")
?: error("revokeClientCredential: missing client_id"),
clientSecret = clientInfo.string("client_secret")
?: error("revokeClientCredential: missing client_secret"),
clientCredential = clientCredential,
)
}
}
}
}
}
} catch (ex: Throwable) {
// クライアント再利用チェックやクライアント情報の削除処理はエラーが起きても無視する
log.w(ex, "can't verify/delete client information.")
}
clientInfo = api.registerClient(apiHost, scopeString, clientName, callbackUrl).apply {
// {"id":999,"redirect_uri":"urn:ietf:wg:oauth:2.0:oob","client_id":"******","client_secret":"******"}
put(KEY_AUTH_VERSION, AUTH_VERSION)
put(KEY_CLIENT_SCOPE, scopeString)
}
if (DEBUG_AUTH) log.i("DEBUG_AUTH client_id=${clientInfo.string("client_id")}")
// client credentialを取得して保存する
// この時点ではまだ client credential がないので、必ず更新と保存が行われる
prepareClientCredential(apiHost, clientInfo, clientName)
return clientInfo
}
/**
* アクセストークン手動入力でアカウントを更新する場合、アカウントの情報を取得する
* auth2の後にユーザ情報を知るためにも使われる
*
* 副作用ユーザ情報を取得できたらoutTokenInfoを更新する
*/
override suspend fun verifyAccount(
accessToken: String,
outTokenJson: JsonObject?,
misskeyVersion: Int,
): JsonObject = api.verifyAccount(
apiHost = apiHost ?: error("verifyAccount: missing apiHost."),
accessToken = accessToken,
).also {
// APIレスポンスが成功したら、そのデータとは無関係に
// アクセストークンをtokenInfoに格納する。
outTokenJson?.apply {
put(KEY_AUTH_VERSION, AUTH_VERSION)
put("access_token", accessToken)
}
}
/**
* クライアントを登録してブラウザで開くURLを生成する
* 成功したら TootApiResult.data にURL文字列を格納すること
* @param ti サーバ情報。Mastodonのホワイトリストモードではnullかもしれない
* @param forceUpdateClient (Mastodon)クライアントを強制的に登録しなおす
*/
override suspend fun authStep1(
ti: TootInstance?,
forceUpdateClient: Boolean,
): Uri {
if (ti?.instanceType == InstanceType.Pixelfed) {
error("currently Pixelfed instance is not supported.")
}
val apiHost = apiHost ?: error("authStep1: missing apiHost")
val clientJson = prepareClientImpl(
apiHost = apiHost,
clientName = clientName,
ti,
forceUpdateClient,
)
val accountDbId = account?.db_id?.takeIf { it >= 0L }
val state = listOf(
"random:${System.currentTimeMillis()}",
when (accountDbId) {
null -> "host:${apiHost.ascii}"
else -> "db:$accountDbId"
}
).joinToString(",")
return api.createAuthUrl(
apiHost = apiHost,
scopeString = mastodonScope(ti),
callbackUrl = callbackUrl,
clientId = clientJson.string("client_id")
?: error("missing client_id"),
state = state,
)
}
/**
* 認証コールバックURLを受け取り、サーバにアクセスして認証を終わらせる。
*/
override suspend fun authStep2(uri: Uri): Auth2Result {
// Mastodon 認証コールバック
// エラー時
// subwaytooter://oauth(\d*)/
// ?error=access_denied
// &error_description=%E3%83%AA%E3%82%BD%E3%83%BC%E3%82%B9%E3%81%AE%E6%89%80%E6%9C%89%E8%80%85%E3%81%BE%E3%81%9F%E3%81%AF%E8%AA%8D%E8%A8%BC%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%81%8C%E8%A6%81%E6%B1%82%E3%82%92%E6%8B%92%E5%90%A6%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F%E3%80%82
// &state=db%3A3
arrayOf("error_description", "error")
.mapNotNull { uri.getQueryParameter(it)?.trim()?.notEmpty() }
.notEmpty()
?.let { error(it.joinToString("\n")) }
// subwaytooter://oauth(\d*)/
// ?code=113cc036e078ac500d3d0d3ad345cd8181456ab087abc67270d40f40a4e9e3c2
// &state=host%3Amastodon.juggler.jp
val code = uri.getQueryParameter("code")
?.trim()?.notEmpty() ?: error("missing code in callback url.")
val cols = uri.getQueryParameter("state")
?.trim()?.notEmpty() ?: error("missing state in callback url.")
for (param in cols.split(",")) {
when {
param.startsWith("db:") -> try {
val dataId = param.substring(3).toLong(10)
val sa = daoSavedAccount.loadAccount(dataId)
?: error("missing account db_id=$dataId")
client.account = sa
} catch (ex: Throwable) {
errorEx(ex, "invalide state.db in callback parameter.")
}
param.startsWith("host:") -> {
val host = Host.parse(param.substring(5))
client.apiHost = host
}
// ignore other parameter
}
}
val apiHost = client.apiHost
?: error("can't get apiHost from callback parameter.")
val clientInfo = daoClientInfo.load(apiHost, clientName)
?: error("can't find client info for apiHost=$apiHost, clientName=$clientName")
val tokenInfo = api.authStep2(
apiHost = apiHost,
clientId = clientInfo.string("client_id")
?: error("handleOAuth2Callback: missing client_id"),
clientSecret = clientInfo.string("client_secret")
?.notEmpty() ?: error("handleOAuth2Callback: missing client_secret"),
scopeString = clientInfo.string(KEY_CLIENT_SCOPE)
?.notEmpty() ?: error("handleOAuth2Callback: missing scopeString"),
callbackUrl = callbackUrl,
code = code,
)
// {"access_token":"******","token_type":"bearer","scope":"read","created_at":1492334641}
val accessToken = tokenInfo.string("access_token")
?.notEmpty() ?: error("can't parse access token.")
if (DEBUG_AUTH) log.i("DEBUG_AUTH accessToken=$accessToken")
val accountJson = verifyAccount(
accessToken = accessToken,
outTokenJson = tokenInfo,
misskeyVersion = 0
)
val ti = TootInstance.getExOrThrow(client, forceAccessToken = accessToken)
val parser = TootParser(context, linkHelper = LinkHelper.create(ti))
return Auth2Result(
tootInstance = ti,
tokenJson = tokenInfo,
accountJson = accountJson,
tootAccount = parser.account(accountJson)
?: error("can't parse user information.")
)
}
/**
* サーバにアプリ情報を登録する。
* ユーザ作成の手前で呼ばれる。
*/
suspend fun prepareClient(
tootInstance: TootInstance,
): JsonObject = prepareClientImpl(
apiHost = apiHost ?: error("prepareClient: missing apiHost"),
clientName = clientName,
tootInstance = tootInstance,
forceUpdateClient = false
)
suspend fun createUser(
clientInfo: JsonObject,
params: CreateUserParams,
): Auth2Result {
val apiHost = apiHost ?: error("createUser: missing apiHost")
val tokenJson = api.createUser(
apiHost = apiHost,
clientCredential = clientInfo.string(KEY_CLIENT_CREDENTIAL)
?: error("createUser: missing client credential"),
params = params,
)
val accessToken = tokenJson.string("access_token")
?: error("can't get user access token")
val ti = TootInstance.getExOrThrow(client, forceAccessToken = accessToken)
val parser = TootParser(context, linkHelper = LinkHelper.create(ti))
val accountJson = try {
verifyAccount(
accessToken = accessToken,
outTokenJson = tokenJson,
misskeyVersion = 0, // Mastodon限定
)
// メール確認が不要な場合は成功する
} catch (ex: Throwable) {
// メール確認がまだなら、verifyAccount は失敗する
log.e(ex, "createUser: can't verify account.")
buildJsonObject {
put("id", EntityId.CONFIRMING.toString())
put("username", params.username)
put("acct", params.username)
put("url", "https://$apiHost/@${params.username}")
}
}
return Auth2Result(
tootInstance = ti,
tokenJson = tokenJson,
accountJson = accountJson,
tootAccount = parser.account(accountJson)
?: error("can't verify user information."),
)
}
}