diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt index a786015284..6c3580132e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt @@ -57,6 +57,19 @@ interface AuthDiskSource { lastActiveTimeMillis: Long?, ) + /** + * Retrieves the number of consecutive invalid lock attempts for the given [userId]. + */ + fun getInvalidUnlockAttempts(userId: String): Int? + + /** + * Stores the number of consecutive invalid lock attempts for the given [userId]. + */ + fun storeInvalidUnlockAttempts( + userId: String, + invalidUnlockAttempts: Int?, + ) + /** * Retrieves a user key using a [userId]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt index 25a8797c21..23c4e967be 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -20,6 +20,7 @@ private const val UNIQUE_APP_ID_KEY = "$BASE_KEY:appId" private const val REMEMBERED_EMAIL_ADDRESS_KEY = "$BASE_KEY:rememberedEmail" private const val STATE_KEY = "$BASE_KEY:state" private const val LAST_ACTIVE_TIME_KEY = "$BASE_KEY:lastActiveTime" +private const val INVALID_UNLOCK_ATTEMPTS_KEY = "$BASE_KEY:invalidUnlockAttempts" private const val MASTER_KEY_ENCRYPTION_USER_KEY = "$BASE_KEY:masterKeyEncryptedUserKey" private const val MASTER_KEY_ENCRYPTION_PRIVATE_KEY = "$BASE_KEY:encPrivateKey" private const val PIN_PROTECTED_USER_KEY_KEY = "$BASE_KEY:pinKeyEncryptedUserKey" @@ -74,6 +75,7 @@ class AuthDiskSourceImpl( override fun clearData(userId: String) { storeLastActiveTimeMillis(userId = userId, lastActiveTimeMillis = null) + storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null) storeUserKey(userId = userId, userKey = null) storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null) storePinProtectedUserKey(userId = userId, pinProtectedUserKey = null) @@ -96,6 +98,19 @@ class AuthDiskSourceImpl( ) } + override fun getInvalidUnlockAttempts(userId: String): Int? = + getInt(key = "${INVALID_UNLOCK_ATTEMPTS_KEY}_$userId") + + override fun storeInvalidUnlockAttempts( + userId: String, + invalidUnlockAttempts: Int?, + ) { + putInt( + key = "${INVALID_UNLOCK_ATTEMPTS_KEY}_$userId", + value = invalidUnlockAttempts, + ) + } + override fun getUserKey(userId: String): String? = getString(key = "${MASTER_KEY_ENCRYPTION_USER_KEY}_$userId") diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt index 1f33d9970f..51fb08e892 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -127,6 +127,10 @@ class AuthDiskSourceTest { userId = userId, lastActiveTimeMillis = 123456789L, ) + authDiskSource.storeInvalidUnlockAttempts( + userId = userId, + invalidUnlockAttempts = 1, + ) authDiskSource.storeUserKey(userId = userId, userKey = "userKey") authDiskSource.storeUserAutoUnlockKey( userId = userId, @@ -145,6 +149,7 @@ class AuthDiskSourceTest { authDiskSource.clearData(userId = userId) assertNull(authDiskSource.getLastActiveTimeMillis(userId = userId)) + assertNull(authDiskSource.getInvalidUnlockAttempts(userId = userId)) assertNull(authDiskSource.getUserKey(userId = userId)) assertNull(authDiskSource.getUserAutoUnlockKey(userId = userId)) assertNull(authDiskSource.getPrivateKey(userId = userId)) @@ -209,6 +214,63 @@ class AuthDiskSourceTest { assertFalse(fakeSharedPreferences.contains(lastActiveTimeKey)) } + @Test + fun `getInvalidUnlockAttempts should pull from SharedPreferences`() { + val lastActiveTimeBaseKey = "bwPreferencesStorage:invalidUnlockAttempts" + val mockUserId = "mockUserId" + val mockInvalidUnlockAttempts = 4 + fakeSharedPreferences + .edit { + putInt( + "${lastActiveTimeBaseKey}_$mockUserId", + mockInvalidUnlockAttempts, + ) + } + val actual = authDiskSource.getInvalidUnlockAttempts(userId = mockUserId) + assertEquals( + mockInvalidUnlockAttempts, + actual, + ) + } + + @Test + fun `storeInvalidUnlockAttempts for non-null values should update SharedPreferences`() { + val invalidUnlockAttemptsBaseKey = "bwPreferencesStorage:invalidUnlockAttempts" + val mockUserId = "mockUserId" + val mockInvalidUnlockAttempts = 4 + authDiskSource.storeInvalidUnlockAttempts( + userId = mockUserId, + invalidUnlockAttempts = mockInvalidUnlockAttempts, + ) + val actual = fakeSharedPreferences + .getInt( + "${invalidUnlockAttemptsBaseKey}_$mockUserId", + 0, + ) + assertEquals( + mockInvalidUnlockAttempts, + actual, + ) + } + + @Test + fun `storeInvalidUnlockAttempts for null values should clear SharedPreferences`() { + val invalidUnlockAttemptsBaseKey = "bwPreferencesStorage:invalidUnlockAttempts" + val mockUserId = "mockUserId" + val mockInvalidUnlockAttempts = 4 + val invalidUnlockAttemptsKey = "${invalidUnlockAttemptsBaseKey}_$mockUserId" + fakeSharedPreferences + .edit { + putInt(invalidUnlockAttemptsKey, mockInvalidUnlockAttempts) + } + assertTrue(fakeSharedPreferences.contains(invalidUnlockAttemptsKey)) + authDiskSource.storeInvalidUnlockAttempts( + userId = mockUserId, + invalidUnlockAttempts = null, + ) + assertFalse(fakeSharedPreferences.contains(invalidUnlockAttemptsKey)) + } + @Test fun `getUserKey should pull from SharedPreferences`() { val userKeyBaseKey = "bwPreferencesStorage:masterKeyEncryptedUserKey" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 383f34ec91..8d9d8a1d7c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -20,6 +20,7 @@ class FakeAuthDiskSource : AuthDiskSource { private val mutableUserStateFlow = bufferedMutableSharedFlow(replay = 1) private val storedLastActiveTimeMillis = mutableMapOf() + private val storedInvalidUnlockAttempts = mutableMapOf() private val storedUserKeys = mutableMapOf() private val storedPrivateKeys = mutableMapOf() private val storedUserAutoUnlockKeys = mutableMapOf() @@ -40,6 +41,7 @@ class FakeAuthDiskSource : AuthDiskSource { override fun clearData(userId: String) { storedLastActiveTimeMillis.remove(userId) + storedInvalidUnlockAttempts.remove(userId) storedUserKeys.remove(userId) storedPrivateKeys.remove(userId) storedUserAutoUnlockKeys.remove(userId) @@ -61,6 +63,16 @@ class FakeAuthDiskSource : AuthDiskSource { storedLastActiveTimeMillis[userId] = lastActiveTimeMillis } + override fun getInvalidUnlockAttempts(userId: String): Int? = + storedInvalidUnlockAttempts[userId] + + override fun storeInvalidUnlockAttempts( + userId: String, + invalidUnlockAttempts: Int?, + ) { + storedInvalidUnlockAttempts[userId] = invalidUnlockAttempts + } + override fun getUserKey(userId: String): String? = storedUserKeys[userId] override fun storeUserKey(userId: String, userKey: String?) { @@ -140,6 +152,13 @@ class FakeAuthDiskSource : AuthDiskSource { assertEquals(lastActiveTimeMillis, storedLastActiveTimeMillis[userId]) } + /** + * Assert that the [invalidUnlockAttempts] was stored successfully using the [userId]. + */ + fun assertInvalidUnlockAttempts(userId: String, invalidUnlockAttempts: Int?) { + assertEquals(invalidUnlockAttempts, storedInvalidUnlockAttempts[userId]) + } + /** * Assert that the [userKey] was stored successfully using the [userId]. */