diff --git a/app/openapi/api.yaml b/app/openapi/api.yaml
index 8c82b93..d7c2919 100644
--- a/app/openapi/api.yaml
+++ b/app/openapi/api.yaml
@@ -723,7 +723,7 @@ paths:
tags:
- DangerZone
summary: Danger Zone パスワード照合
- description: 入力されたパスワードが設定済みパスワードと一致するかを判定する。不一致でも200を返し、validフラグで判定する
+ description: 入力されたパスワードが設定済みパスワードと一致するかを判定する。不一致でも200を返し、validフラグで判定する。接続元ごとに連続失敗を数え、上限に達している間は429を返す
operationId: verifyDangerZonePassword
requestBody:
required: true
@@ -744,6 +744,18 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
+ '429':
+ description: 連続失敗が上限に達したため一時的に照合を受け付けない
+ headers:
+ Retry-After:
+ description: 再試行できるまでの秒数
+ schema:
+ type: integer
+ example: 60
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DangerZoneVerifyResponse'
# APK
/api/apk/version/latest:
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/DangerZoneRateLimitedException.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/DangerZoneRateLimitedException.kt
new file mode 100644
index 0000000..b988fe6
--- /dev/null
+++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/DangerZoneRateLimitedException.kt
@@ -0,0 +1,5 @@
+package info.nukoneko.cuc.android.kidspos.api
+
+class DangerZoneRateLimitedException(
+ val retryAfterSeconds: Long?
+) : Exception("Danger zone verification is rate limited")
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiDangerZoneService.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiDangerZoneService.kt
index 3c50a1d..a82d45f 100644
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiDangerZoneService.kt
+++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiDangerZoneService.kt
@@ -20,6 +20,11 @@ class OpenApiDangerZoneService(
val response = dangerZoneApi.verifyDangerZonePassword(
VerifyDangerZonePasswordRequest(password = password)
)
+ if (response.code() == HTTP_TOO_MANY_REQUESTS) {
+ throw DangerZoneRateLimitedException(
+ response.headers()[RETRY_AFTER_HEADER]?.trim()?.toLongOrNull()
+ )
+ }
if (!response.isSuccessful) {
throw Exception("Failed to verify danger zone password: ${response.code()}")
}
@@ -30,4 +35,9 @@ class OpenApiDangerZoneService(
message = body.message
)
}
+
+ private companion object {
+ const val HTTP_TOO_MANY_REQUESTS = 429
+ const val RETRY_AFTER_HEADER = "Retry-After"
+ }
}
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreen.kt
index ce7160b..a0e1485 100644
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreen.kt
@@ -186,6 +186,18 @@ private fun DangerZoneSection(
Text(error.message)
}
+ is DangerZoneError.RateLimited -> {
+ Spacer(modifier = Modifier.padding(4.dp))
+ val seconds = error.retryAfterSeconds
+ Text(
+ if (seconds != null) {
+ stringResource(R.string.danger_zone_rate_limited, seconds)
+ } else {
+ stringResource(R.string.danger_zone_rate_limited_unknown)
+ }
+ )
+ }
+
is DangerZoneError.Unreachable -> {
Spacer(modifier = Modifier.padding(4.dp))
Text(stringResource(R.string.danger_zone_verify_failed))
diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModel.kt
index ef662de..37f5dbc 100644
--- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModel.kt
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import info.nukoneko.cuc.android.kidspos.BuildConfig
+import info.nukoneko.cuc.android.kidspos.api.DangerZoneRateLimitedException
import info.nukoneko.cuc.android.kidspos.data.repository.AppUpdateRepository
import info.nukoneko.cuc.android.kidspos.data.repository.DangerZoneRepository
import info.nukoneko.cuc.android.kidspos.data.settings.SettingsRepository
@@ -51,6 +52,7 @@ sealed interface DangerZoneStatus {
sealed interface DangerZoneError {
data class Rejected(val message: String) : DangerZoneError
+ data class RateLimited(val retryAfterSeconds: Long?) : DangerZoneError
data object Unreachable : DangerZoneError
}
@@ -138,6 +140,9 @@ class SettingsViewModel @Inject constructor(
!result.configured -> DangerZoneStatus.Unlocked(DangerZoneReason.NOT_CONFIGURED)
else -> DangerZoneStatus.Locked(DangerZoneError.Rejected(result.message))
}
+ } catch (e: DangerZoneRateLimitedException) {
+ Timber.w(e, "Danger zone verification is rate limited")
+ DangerZoneStatus.Locked(DangerZoneError.RateLimited(e.retryAfterSeconds))
} catch (e: Exception) {
Timber.w(e, "Failed to verify danger zone password")
DangerZoneStatus.Locked(DangerZoneError.Unreachable)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4021dee..e2500dc 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -79,4 +79,6 @@
サーバーにパスワードが設定されていないため、そのまま操作できます
サーバーに接続できないため、そのまま操作できます
サーバーに接続できませんでした
+ 試行回数が多すぎます。%1$d秒後にもう一度お試しください
+ 試行回数が多すぎます。しばらく待ってからもう一度お試しください
diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiDangerZoneServiceTest.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiDangerZoneServiceTest.kt
new file mode 100644
index 0000000..55ee5c5
--- /dev/null
+++ b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/api/OpenApiDangerZoneServiceTest.kt
@@ -0,0 +1,106 @@
+package info.nukoneko.cuc.android.kidspos.api
+
+import info.nukoneko.cuc.android.kidspos.api.generated.DangerZoneApi
+import info.nukoneko.cuc.android.kidspos.api.generated.model.DangerZoneStatusResponse
+import info.nukoneko.cuc.android.kidspos.api.generated.model.DangerZoneVerifyResponse
+import info.nukoneko.cuc.android.kidspos.api.generated.model.VerifyDangerZonePasswordRequest
+import kotlinx.coroutines.test.runTest
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import retrofit2.Response
+
+class OpenApiDangerZoneServiceTest {
+
+ private class FakeDangerZoneApi(
+ private val verifyResponse: Response
+ ) : DangerZoneApi {
+ override suspend fun getDangerZoneStatus(): Response =
+ Response.success(DangerZoneStatusResponse(configured = true))
+
+ override suspend fun verifyDangerZonePassword(
+ verifyDangerZonePasswordRequest: VerifyDangerZonePasswordRequest
+ ): Response = verifyResponse
+ }
+
+ private fun tooManyRequests(retryAfter: String?): Response {
+ val raw = okhttp3.Response.Builder()
+ .request(Request.Builder().url("http://localhost/api/setting/danger-zone/verify").build())
+ .protocol(Protocol.HTTP_1_1)
+ .code(429)
+ .message("Too Many Requests")
+ .apply { retryAfter?.let { header("Retry-After", it) } }
+ .build()
+ return Response.error("{}".toResponseBody("application/json".toMediaType()), raw)
+ }
+
+ private fun serviceWith(response: Response) =
+ OpenApiDangerZoneService(FakeDangerZoneApi(response))
+
+ @Test
+ fun successfulVerifyIsMappedToVerification() = runTest {
+ val service = serviceWith(
+ Response.success(
+ DangerZoneVerifyResponse(valid = true, configured = true, message = "認証しました")
+ )
+ )
+
+ val result = service.verifyPassword("secret")
+
+ assertEquals(true, result.valid)
+ assertEquals(true, result.configured)
+ assertEquals("認証しました", result.message)
+ }
+
+ @Test
+ fun tooManyRequestsRaisesRateLimitedWithRetryAfter() = runTest {
+ val service = serviceWith(tooManyRequests("45"))
+
+ val error = runCatching { service.verifyPassword("wrong") }.exceptionOrNull()
+
+ assertTrue(error is DangerZoneRateLimitedException)
+ assertEquals(45L, (error as DangerZoneRateLimitedException).retryAfterSeconds)
+ }
+
+ @Test
+ fun tooManyRequestsWithoutRetryAfterRaisesRateLimitedWithoutSeconds() = runTest {
+ val service = serviceWith(tooManyRequests(null))
+
+ val error = runCatching { service.verifyPassword("wrong") }.exceptionOrNull()
+
+ assertTrue(error is DangerZoneRateLimitedException)
+ assertNull((error as DangerZoneRateLimitedException).retryAfterSeconds)
+ }
+
+ @Test
+ fun tooManyRequestsWithHttpDateRetryAfterRaisesRateLimitedWithoutSeconds() = runTest {
+ val service = serviceWith(tooManyRequests("Wed, 21 Oct 2015 07:28:00 GMT"))
+
+ val error = runCatching { service.verifyPassword("wrong") }.exceptionOrNull()
+
+ assertTrue(error is DangerZoneRateLimitedException)
+ assertNull((error as DangerZoneRateLimitedException).retryAfterSeconds)
+ }
+
+ @Test
+ fun otherErrorStatusRaisesGenericFailure() = runTest {
+ val raw = okhttp3.Response.Builder()
+ .request(Request.Builder().url("http://localhost/api/setting/danger-zone/verify").build())
+ .protocol(Protocol.HTTP_1_1)
+ .code(500)
+ .message("Internal Server Error")
+ .build()
+ val service = serviceWith(
+ Response.error("{}".toResponseBody("application/json".toMediaType()), raw)
+ )
+
+ val error = runCatching { service.verifyPassword("wrong") }.exceptionOrNull()
+
+ assertTrue(error != null && error !is DangerZoneRateLimitedException)
+ }
+}
diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreenTest.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreenTest.kt
index d2ab8af..6b554fe 100644
--- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreenTest.kt
+++ b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsScreenTest.kt
@@ -13,6 +13,7 @@ import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import info.nukoneko.cuc.android.kidspos.R
+import info.nukoneko.cuc.android.kidspos.api.DangerZoneRateLimitedException
import info.nukoneko.cuc.android.kidspos.entity.AppUpdate
import info.nukoneko.cuc.android.kidspos.entity.DangerZoneVerification
import info.nukoneko.cuc.android.kidspos.testutil.FakeAppUpdateService
@@ -189,6 +190,59 @@ class SettingsScreenTest {
composeRule.onNodeWithText(context.getString(R.string.load_setting)).assertDoesNotExist()
}
+ @Test
+ fun rateLimitedVerifyShowsRetryAfterMessage() {
+ val dangerZoneService = FakeDangerZoneService()
+ dangerZoneService.isPasswordConfiguredHandler = { true }
+ dangerZoneService.verifyPasswordHandler = { throw DangerZoneRateLimitedException(45) }
+ composeRule.setContent {
+ SettingsScreen(
+ onNavigateBack = {},
+ viewModel = createSettingsViewModel(
+ settingsRepository,
+ dangerZoneService = dangerZoneService
+ )
+ )
+ }
+
+ composeRule.onNode(hasSetTextAction()).performTextInput("wrong")
+ composeRule.onNodeWithText(context.getString(R.string.danger_zone_unlock))
+ .performScrollTo()
+ .performClick()
+ composeRule.waitForIdle()
+
+ composeRule.onNodeWithText(
+ context.getString(R.string.danger_zone_rate_limited, 45L)
+ ).assertExists()
+ composeRule.onNodeWithText(context.getString(R.string.load_setting)).assertDoesNotExist()
+ }
+
+ @Test
+ fun rateLimitedVerifyWithoutRetryAfterShowsGenericMessage() {
+ val dangerZoneService = FakeDangerZoneService()
+ dangerZoneService.isPasswordConfiguredHandler = { true }
+ dangerZoneService.verifyPasswordHandler = { throw DangerZoneRateLimitedException(null) }
+ composeRule.setContent {
+ SettingsScreen(
+ onNavigateBack = {},
+ viewModel = createSettingsViewModel(
+ settingsRepository,
+ dangerZoneService = dangerZoneService
+ )
+ )
+ }
+
+ composeRule.onNode(hasSetTextAction()).performTextInput("wrong")
+ composeRule.onNodeWithText(context.getString(R.string.danger_zone_unlock))
+ .performScrollTo()
+ .performClick()
+ composeRule.waitForIdle()
+
+ composeRule.onNodeWithText(
+ context.getString(R.string.danger_zone_rate_limited_unknown)
+ ).assertExists()
+ }
+
@Test
fun availableUpdateShowsConfirmDialog() {
val updateService = FakeAppUpdateService()
diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModelTest.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModelTest.kt
index 5210c34..2b7773b 100644
--- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModelTest.kt
+++ b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/settings/SettingsViewModelTest.kt
@@ -1,5 +1,6 @@
package info.nukoneko.cuc.android.kidspos.ui.settings
+import info.nukoneko.cuc.android.kidspos.api.DangerZoneRateLimitedException
import info.nukoneko.cuc.android.kidspos.entity.AppUpdate
import info.nukoneko.cuc.android.kidspos.entity.DangerZoneVerification
import info.nukoneko.cuc.android.kidspos.testutil.FakeApkDownloader
@@ -325,6 +326,45 @@ class SettingsViewModelTest {
)
}
+ @Test
+ fun rateLimitedVerifyKeepsDangerZoneLockedWithRetryAfter() = runTest {
+ val dangerZoneService = FakeDangerZoneService()
+ dangerZoneService.isPasswordConfiguredHandler = { true }
+ dangerZoneService.verifyPasswordHandler = { throw DangerZoneRateLimitedException(45) }
+ val viewModel = createSettingsViewModel(
+ settingsRepository,
+ dangerZoneService = dangerZoneService
+ )
+
+ viewModel.onDangerZonePasswordChange("wrong")
+ viewModel.onUnlockDangerZone()
+
+ assertEquals(
+ DangerZoneStatus.Locked(DangerZoneError.RateLimited(45)),
+ viewModel.uiState.value.dangerZoneStatus
+ )
+ assertEquals("wrong", viewModel.uiState.value.dangerZonePassword)
+ }
+
+ @Test
+ fun rateLimitedVerifyWithoutRetryAfterKeepsDangerZoneLocked() = runTest {
+ val dangerZoneService = FakeDangerZoneService()
+ dangerZoneService.isPasswordConfiguredHandler = { true }
+ dangerZoneService.verifyPasswordHandler = { throw DangerZoneRateLimitedException(null) }
+ val viewModel = createSettingsViewModel(
+ settingsRepository,
+ dangerZoneService = dangerZoneService
+ )
+
+ viewModel.onDangerZonePasswordChange("wrong")
+ viewModel.onUnlockDangerZone()
+
+ assertEquals(
+ DangerZoneStatus.Locked(DangerZoneError.RateLimited(null)),
+ viewModel.uiState.value.dangerZoneStatus
+ )
+ }
+
@Test
fun passwordClearedOnServerUnlocksDangerZone() = runTest {
val dangerZoneService = FakeDangerZoneService()