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

283 lines
11 KiB
Kotlin

package jp.juggler.subwaytooter.api.auth
import android.net.Uri
import androidx.core.net.toUri
import jp.juggler.subwaytooter.R
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.TootInstance
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.pref.prefDevice
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.JsonArray
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.buildJsonArray
import jp.juggler.util.data.cast
import jp.juggler.util.data.digestSHA256
import jp.juggler.util.data.encodeHexLower
import jp.juggler.util.data.encodeUTF8
import jp.juggler.util.data.notBlank
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory
class AuthMisskey10(override val client: TootApiClient) : AuthBase() {
companion object {
private val log = LogCategory("MisskeyOldAuth")
private const val callbackUrl = "${FcmFlavor.CUSTOM_SCHEME}://misskey/auth_callback"
fun isCallbackUrl(uriStr: String) =
uriStr.startsWith(callbackUrl) ||
uriStr.startsWith("misskeyclientproto://misskeyclientproto/auth_callback")
fun getScopeArrayMisskey(ti: TootInstance?) =
buildJsonArray {
if (ti != null && !ti.versionGE(TootInstance.MISSKEY_VERSION_11)) {
// https://github.com/syuilo/misskey/issues/2341
// Misskey 10まで
arrayOf(
"account-read",
"account-write",
"account/read",
"account/write",
"drive-read",
"drive-write",
"favorite-read",
"favorite-write",
"favorites-read",
"following-read",
"following-write",
"messaging-read",
"messaging-write",
"note-read",
"note-write",
"notification-read",
"notification-write",
"reaction-read",
"reaction-write",
"vote-read",
"vote-write"
)
} else {
// Misskey 11以降
// https://github.com/syuilo/misskey/blob/master/src/server/api/kinds.ts
arrayOf(
"read:account",
"write:account",
"read:blocks",
"write:blocks",
"read:drive",
"write:drive",
"read:favorites",
"write:favorites",
"read:following",
"write:following",
"read:messaging",
"write:messaging",
"read:mutes",
"write:mutes",
"write:notes",
"read:notifications",
"write:notifications",
"read:reactions",
"write:reactions",
"write:votes"
)
}.toMutableSet().forEach { add(it) }
// APIのエラーを回避するため、重複を排除する
}
fun JsonArray.encodeScopeArray() =
stringArrayList().sorted().joinToString(",")
fun compareScopeArray(a: JsonArray, b: JsonArray?) =
a.encodeScopeArray() == b?.encodeScopeArray()
}
val api = ApiAuthMisskey10(client)
/**
* Misskey v12 までの認証に使うURLを生成する
*
* {"token":"0ba88e2d-4b7d-4599-8d90-dc341a005637","url":"https://misskey.xyz/auth/0ba88e2d-4b7d-4599-8d90-dc341a005637"}
*/
private suspend fun createAuthUri(apiHost: Host, appSecret: String): Uri {
context.prefDevice.saveLastAuth(
host = apiHost.ascii,
secret = appSecret,
dbId = account?.db_id, //nullable
)
return api.authSessionGenerate(apiHost, appSecret)
.string("url").notEmpty()?.toUri()
?: error("missing 'url' in session/generate.")
}
/**
* クライアントを登録してブラウザで開くURLを生成する
* 成功したら TootApiResult.data にURL文字列を格納すること
*
* @param ti サーバ情報。Mastodonのホワイトリストモードではnullかもしれない
* @param forceUpdateClient (Mastodon)クライアントを強制的に登録しなおす
*/
override suspend fun authStep1(
ti: TootInstance?,
forceUpdateClient: Boolean,
): Uri {
if (!PrefB.bpEnableDeprecatedSomething.value) {
error(context.getString(R.string.misskey_support_end))
}
val apiHost = apiHost ?: error("missing apiHost")
val clientInfo = daoClientInfo.load(apiHost, clientName)
// スコープ一覧を取得する
val scopeArray = getScopeArrayMisskey(ti)
if (clientInfo != null &&
AUTH_VERSION == clientInfo.int(KEY_AUTH_VERSION) &&
clientInfo.boolean(KEY_IS_MISSKEY) == true
) {
val appSecret = clientInfo.string(KEY_MISSKEY_APP_SECRET)
val appId = clientInfo.string("id")
?: error("missing app id")
// tmpClientInfo はsecretを含まないので保存してはいけない
val tmpClientInfo = try {
api.appShow(apiHost, appId)
} catch (ex: Throwable) {
// アプリ情報の取得に失敗しても致命的ではない
log.e(ex, "can't get app info, but continue…")
null
}
// - アプリが登録済みで
// - クライアント名が一致してて
// - パーミッションが同じ
// ならクライアント情報を再利用する
if (tmpClientInfo != null &&
clientName == tmpClientInfo.string("name") &&
compareScopeArray(scopeArray, tmpClientInfo["permission"].cast()) &&
appSecret?.isNotEmpty() == true
) return createAuthUri(apiHost, appSecret)
// XXX appSecretを使ってクライアント情報を削除できるようにするべきだが、該当するAPIが存在しない
}
val appJson = api.appCreate(
apiHost = apiHost,
appNameId = appNameId,
appDescription = appDescription,
clientName = clientName,
scopeArray = scopeArray,
callbackUrl = callbackUrl,
).apply {
put(KEY_IS_MISSKEY, true)
put(KEY_AUTH_VERSION, AUTH_VERSION)
}
val appSecret = appJson.string(KEY_MISSKEY_APP_SECRET)
.notBlank() ?: error(context.getString(R.string.cant_get_misskey_app_secret))
daoClientInfo.save(apiHost, clientName, appJson.toString())
return createAuthUri(apiHost, appSecret)
}
/**
* Misskey(v12まで)の認証コールバックUriを処理する
*/
override suspend fun authStep2(uri: Uri): Auth2Result {
val prefDevice = context.prefDevice
val token = uri.getQueryParameter("token")
?.notBlank() ?: error("missing token in callback URL")
val hostStr = prefDevice.lastAuthInstance
?.notBlank() ?: error("missing instance name.")
val apiHost = Host.parse(hostStr)
when (val dbId = prefDevice.lastAuthDbId) {
// new registration
null -> client.apiHost = apiHost
// update access token
else -> daoSavedAccount.loadAccount(dbId)?.also {
client.account = it
} ?: error("missing account db_id=$dbId")
}
val ti = TootInstance.getOrThrow(client)
val parser = TootParser(
context,
linkHelper = LinkHelper.create(ti)
)
val clientInfo = daoClientInfo.load(apiHost, clientName)
?.notEmpty() ?: error("missing client id")
val appSecret = clientInfo.string(KEY_MISSKEY_APP_SECRET)
?.notEmpty() ?: error(context.getString(R.string.cant_get_misskey_app_secret))
val tokenInfo = api.authSessionUserKey(
apiHost,
appSecret,
token,
)
// {"accessToken":"...","user":{…}}
val accessToken = tokenInfo.string("accessToken")
?.notBlank() ?: error("missing accessToken in the userkey response.")
val accountJson = tokenInfo["user"].cast<JsonObject>()
?: error("missing user in the userkey response.")
tokenInfo.remove("user")
return Auth2Result(
tootInstance = ti,
tokenJson = tokenInfo.also {
EntityId.mayNull(accountJson.string("id"))?.putTo(it, KEY_USER_ID)
it[KEY_MISSKEY_VERSION] = ti.misskeyVersionMajor
it[KEY_AUTH_VERSION] = AUTH_VERSION
val apiKey = "$accessToken$appSecret".encodeUTF8().digestSHA256().encodeHexLower()
it[KEY_API_KEY_MISSKEY] = apiKey
},
accountJson = accountJson,
tootAccount = parser.account(accountJson)
?: error("can't parse user information"),
)
}
/**
* アクセストークンを指定してユーザ情報を取得する。
* - アクセストークンの手動入力などで使われる
*
* 副作用:ユーザ情報を取得できたら outTokenInfo にアクセストークンを格納する。
*/
override suspend fun verifyAccount(
accessToken: String,
outTokenJson: JsonObject?,
misskeyVersion: Int,
): JsonObject = api.verifyAccount(
apiHost = apiHost ?: error("missing apiHost"),
accessToken = accessToken
).also {
// ユーザ情報が読めたら outTokenInfo にアクセストークンを保存する
outTokenJson?.apply {
put(KEY_AUTH_VERSION, AUTH_VERSION)
put(KEY_API_KEY_MISSKEY, accessToken)
put(KEY_MISSKEY_VERSION, misskeyVersion)
}
}
}