diff --git a/src/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java b/src/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java index e6f6973c06..c0b2521b06 100644 --- a/src/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java +++ b/src/org/thoughtcrime/securesms/registration/fragments/EnterPhoneNumberFragment.java @@ -203,6 +203,14 @@ public final class EnterPhoneNumberFragment extends BaseRegistrationFragment { NavController navController = Navigation.findNavController(register); + if (!model.getRequestLimiter().canRequest(mode, e164number, System.currentTimeMillis())) { + Log.i(TAG, "Local rate limited"); + navController.navigate(EnterPhoneNumberFragmentDirections.actionEnterVerificationCode()); + cancelSpinning(register); + enableAllEntries(); + return; + } + RegistrationService registrationService = RegistrationService.getInstance(e164number, model.getRegistrationSecret()); registrationService.requestVerificationCode(requireActivity(), mode, captcha, @@ -213,6 +221,8 @@ public final class EnterPhoneNumberFragment extends BaseRegistrationFragment { navController.navigate(EnterPhoneNumberFragmentDirections.actionRequestCaptcha()); cancelSpinning(register); enableAllEntries(); + model.getRequestLimiter().onUnsuccessfulRequest(); + model.updateLimiter(); } @Override @@ -222,6 +232,8 @@ public final class EnterPhoneNumberFragment extends BaseRegistrationFragment { navController.navigate(EnterPhoneNumberFragmentDirections.actionEnterVerificationCode()); cancelSpinning(register); enableAllEntries(); + model.getRequestLimiter().onSuccessfulRequest(mode, e164number, System.currentTimeMillis()); + model.updateLimiter(); } @Override @@ -229,6 +241,8 @@ public final class EnterPhoneNumberFragment extends BaseRegistrationFragment { Toast.makeText(register.getContext(), R.string.RegistrationActivity_unable_to_connect_to_service, Toast.LENGTH_LONG).show(); cancelSpinning(register); enableAllEntries(); + model.getRequestLimiter().onUnsuccessfulRequest(); + model.updateLimiter(); } }); } diff --git a/src/org/thoughtcrime/securesms/registration/viewmodel/LocalCodeRequestRateLimiter.java b/src/org/thoughtcrime/securesms/registration/viewmodel/LocalCodeRequestRateLimiter.java new file mode 100644 index 0000000000..c6b233b821 --- /dev/null +++ b/src/org/thoughtcrime/securesms/registration/viewmodel/LocalCodeRequestRateLimiter.java @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms.registration.viewmodel; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.registration.service.RegistrationCodeRequest; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +public final class LocalCodeRequestRateLimiter implements Parcelable { + + private final long timePeriod; + private final Map dataMap; + + public LocalCodeRequestRateLimiter(long timePeriod) { + this.timePeriod = timePeriod; + this.dataMap = new HashMap<>(); + } + + @MainThread + public boolean canRequest(@NonNull RegistrationCodeRequest.Mode mode, @NonNull String e164Number, long currentTime) { + Data data = dataMap.get(mode); + + return data == null || !data.limited(e164Number, currentTime); + } + + /** + * Call this when the server has returned that it was successful in requesting a code via the specified mode. + */ + @MainThread + public void onSuccessfulRequest(@NonNull RegistrationCodeRequest.Mode mode, @NonNull String e164Number, long currentTime) { + dataMap.put(mode, new Data(e164Number, currentTime + timePeriod)); + } + + /** + * Call this if a mode was unsuccessful in sending. + */ + @MainThread + public void onUnsuccessfulRequest() { + dataMap.clear(); + } + + static class Data { + + final String e164Number; + final long limitedUntil; + + Data(@NonNull String e164Number, long limitedUntil) { + this.e164Number = e164Number; + this.limitedUntil = limitedUntil; + } + + boolean limited(String e164Number, long currentTime) { + return this.e164Number.equals(e164Number) && currentTime < limitedUntil; + } + } + + public static final Creator CREATOR = new Creator() { + @Override + public LocalCodeRequestRateLimiter createFromParcel(Parcel in) { + long timePeriod = in.readLong(); + int numberOfMapEntries = in.readInt(); + + LocalCodeRequestRateLimiter localCodeRequestRateLimiter = new LocalCodeRequestRateLimiter(timePeriod); + + for (int i = 0; i < numberOfMapEntries; i++) { + RegistrationCodeRequest.Mode mode = RegistrationCodeRequest.Mode.values()[in.readInt()]; + String e164Number = in.readString(); + long limitedUntil = in.readLong(); + + localCodeRequestRateLimiter.dataMap.put(mode, new Data(Objects.requireNonNull(e164Number), limitedUntil)); + } + return localCodeRequestRateLimiter; + } + + @Override + public LocalCodeRequestRateLimiter[] newArray(int size) { + return new LocalCodeRequestRateLimiter[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(timePeriod); + dest.writeInt(dataMap.size()); + + for (Map.Entry a : dataMap.entrySet()) { + dest.writeInt(a.getKey().ordinal()); + dest.writeString(a.getValue().e164Number); + dest.writeLong(a.getValue().limitedUntil); + } + } +} + diff --git a/src/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java b/src/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java index 02d17c9631..0a97aa4f60 100644 --- a/src/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java +++ b/src/org/thoughtcrime/securesms/registration/viewmodel/RegistrationViewModel.java @@ -12,13 +12,14 @@ import org.thoughtcrime.securesms.util.Util; public final class RegistrationViewModel extends ViewModel { - private final String secret; - private final MutableLiveData number; - private final MutableLiveData textCodeEntered; - private final MutableLiveData captchaToken; - private final MutableLiveData fcmToken; - private final MutableLiveData restoreFlowShown; - private final MutableLiveData successfulCodeRequestAttempts; + private final String secret; + private final MutableLiveData number; + private final MutableLiveData textCodeEntered; + private final MutableLiveData captchaToken; + private final MutableLiveData fcmToken; + private final MutableLiveData restoreFlowShown; + private final MutableLiveData successfulCodeRequestAttempts; + private final MutableLiveData requestLimiter; public RegistrationViewModel(@NonNull SavedStateHandle savedStateHandle) { secret = loadValue(savedStateHandle, "REGISTRATION_SECRET", Util.getSecret(18)); @@ -29,6 +30,7 @@ public final class RegistrationViewModel extends ViewModel { fcmToken = savedStateHandle.getLiveData("FCM_TOKEN"); restoreFlowShown = savedStateHandle.getLiveData("RESTORE_FLOW_SHOWN", false); successfulCodeRequestAttempts = savedStateHandle.getLiveData("SUCCESSFUL_CODE_REQUEST_ATTEMPTS", 0); + requestLimiter = savedStateHandle.getLiveData("REQUEST_RATE_LIMITER", new LocalCodeRequestRateLimiter(60_000)); } private static T loadValue(@NonNull SavedStateHandle savedStateHandle, @NonNull String key, @NonNull T initialValue) { @@ -127,4 +129,13 @@ public final class RegistrationViewModel extends ViewModel { public LiveData getSuccessfulCodeRequestAttempts() { return successfulCodeRequestAttempts; } + + public @NonNull LocalCodeRequestRateLimiter getRequestLimiter() { + //noinspection ConstantConditions Live data was given an initial value + return requestLimiter.getValue(); + } + + public void updateLimiter() { + requestLimiter.setValue(requestLimiter.getValue()); + } } diff --git a/test/unitTest/java/org/thoughtcrime/securesms/registration/viewmodel/LocalCodeRequestRateLimiterTest.java b/test/unitTest/java/org/thoughtcrime/securesms/registration/viewmodel/LocalCodeRequestRateLimiterTest.java new file mode 100644 index 0000000000..5c29e5f4b6 --- /dev/null +++ b/test/unitTest/java/org/thoughtcrime/securesms/registration/viewmodel/LocalCodeRequestRateLimiterTest.java @@ -0,0 +1,74 @@ +package org.thoughtcrime.securesms.registration.viewmodel; + +import org.junit.Test; +import org.thoughtcrime.securesms.registration.service.RegistrationCodeRequest; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class LocalCodeRequestRateLimiterTest { + + @Test + public void initially_can_request() { + LocalCodeRequestRateLimiter limiter = new LocalCodeRequestRateLimiter(60_000); + + assertTrue(limiter.canRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000)); + } + + @Test + public void cant_request_within_same_time_period() { + LocalCodeRequestRateLimiter limiter = new LocalCodeRequestRateLimiter(60_000); + + assertTrue(limiter.canRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000)); + + limiter.onSuccessfulRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000); + + assertFalse(limiter.canRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000 + 59_000)); + } + + @Test + public void can_request_within_same_time_period_if_different_number() { + LocalCodeRequestRateLimiter limiter = new LocalCodeRequestRateLimiter(60_000); + + assertTrue(limiter.canRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000)); + + limiter.onSuccessfulRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000); + + assertTrue(limiter.canRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+15559874566", 1000 + 59_000)); + } + + @Test + public void can_request_within_same_time_period_if_different_mode() { + LocalCodeRequestRateLimiter limiter = new LocalCodeRequestRateLimiter(60_000); + + assertTrue(limiter.canRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000)); + + limiter.onSuccessfulRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000); + + assertTrue(limiter.canRequest(RegistrationCodeRequest.Mode.SMS_FCM_NO_LISTENER, "+155512345678", 1000 + 59_000)); + } + + @Test + public void can_request_after_time_period() { + LocalCodeRequestRateLimiter limiter = new LocalCodeRequestRateLimiter(60_000); + + assertTrue(limiter.canRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000)); + + limiter.onSuccessfulRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000); + + assertTrue(limiter.canRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000 + 60_001)); + } + + @Test + public void can_request_within_same_time_period_if_an_unsuccessful_request_is_seen() { + LocalCodeRequestRateLimiter limiter = new LocalCodeRequestRateLimiter(60_000); + + assertTrue(limiter.canRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000)); + + limiter.onSuccessfulRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000); + + limiter.onUnsuccessfulRequest(); + + assertTrue(limiter.canRequest(RegistrationCodeRequest.Mode.SMS_NO_FCM, "+155512345678", 1000 + 59_000)); + } +} \ No newline at end of file