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()