From ee53260d72ce8d018e69c9fa9288b57163b215a4 Mon Sep 17 00:00:00 2001 From: Ravi Khadiwala Date: Tue, 21 Mar 2023 17:26:42 -0500 Subject: [PATCH] Add filter-provided captcha score thresholds --- .../textsecuregcm/WhisperServerService.java | 12 ++ .../captcha/AssessmentResult.java | 108 ++++++++++++++++-- .../textsecuregcm/captcha/CaptchaChecker.java | 3 +- .../textsecuregcm/captcha/HCaptchaClient.java | 10 +- .../captcha/RecaptchaClient.java | 9 +- .../controllers/AccountController.java | 25 ++-- .../controllers/VerificationController.java | 21 ++-- .../limits/RateLimitChallengeManager.java | 2 +- .../textsecuregcm/spam/Extract.java | 20 ++++ .../textsecuregcm/spam/ScoreThreshold.java | 46 ++++++++ .../spam/ScoreThresholdProvider.java | 55 +++++++++ .../captcha/CaptchaCheckerTest.java | 2 +- .../captcha/HCaptchaClientTest.java | 3 +- .../controllers/AccountControllerTest.java | 6 +- .../VerificationControllerTest.java | 6 +- .../limits/RateLimitChallengeManagerTest.java | 2 +- 16 files changed, 280 insertions(+), 50 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/spam/Extract.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/spam/ScoreThreshold.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/spam/ScoreThresholdProvider.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index bf7bb7f7a..6f781327d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -166,6 +166,7 @@ import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; import org.whispersystems.textsecuregcm.spam.FilterSpam; import org.whispersystems.textsecuregcm.spam.RateLimitChallengeListener; import org.whispersystems.textsecuregcm.spam.ReportSpamTokenProvider; +import org.whispersystems.textsecuregcm.spam.ScoreThresholdProvider; import org.whispersystems.textsecuregcm.spam.SpamFilter; import org.whispersystems.textsecuregcm.sqs.DirectoryQueue; import org.whispersystems.textsecuregcm.storage.AccountCleaner; @@ -780,6 +781,7 @@ public class WhisperServerService extends Application provisioningEnvironment = new WebSocketEnvironment<>(environment, webSocketEnvironment.getRequestLog(), 60000); provisioningEnvironment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager)); @@ -789,6 +791,7 @@ public class WhisperServerService extends Application webSocketEnvironment, + WebSocketEnvironment provisioningEnvironment) { + environment.jersey().register(ScoreThresholdProvider.ScoreThresholdFeature.class); + webSocketEnvironment.jersey().register(ScoreThresholdProvider.ScoreThresholdFeature.class); + provisioningEnvironment.jersey().register(ScoreThresholdProvider.ScoreThresholdFeature.class); + } + private void registerExceptionMappers(Environment environment, WebSocketEnvironment webSocketEnvironment, WebSocketEnvironment provisioningEnvironment) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/AssessmentResult.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/AssessmentResult.java index fbf0486d4..0db5d0753 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/AssessmentResult.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/AssessmentResult.java @@ -5,23 +5,111 @@ package org.whispersystems.textsecuregcm.captcha; -/** - * A captcha assessment - * - * @param valid whether the captcha was passed - * @param score string representation of the risk level - */ -public record AssessmentResult(boolean valid, String score) { +import java.util.Objects; +import java.util.Optional; - public static AssessmentResult invalid() { - return new AssessmentResult(false, ""); +public class AssessmentResult { + + private final boolean solved; + private final float actualScore; + private final float defaultScoreThreshold; + private final String scoreString; + + /** + * A captcha assessment + * + * @param solved if false, the captcha was not successfully completed + * @param actualScore float representation of the risk level from [0, 1.0], with 1.0 being the least risky + * @param defaultScoreThreshold the score threshold which the score will be evaluated against by default + * @param scoreString a quantized string representation of the risk level, suitable for use in metrics + */ + private AssessmentResult(boolean solved, float actualScore, float defaultScoreThreshold, final String scoreString) { + this.solved = solved; + this.actualScore = actualScore; + this.defaultScoreThreshold = defaultScoreThreshold; + this.scoreString = scoreString; } + /** + * Construct an {@link AssessmentResult} from a captcha evaluation score + * + * @param actualScore the score + * @param defaultScoreThreshold the threshold to compare the score against by default + */ + public static AssessmentResult fromScore(float actualScore, float defaultScoreThreshold) { + if (actualScore < 0 || actualScore > 1.0 || defaultScoreThreshold < 0 || defaultScoreThreshold > 1.0) { + throw new IllegalArgumentException("invalid captcha score"); + } + return new AssessmentResult(true, actualScore, defaultScoreThreshold, AssessmentResult.scoreString(actualScore)); + } + + /** + * Construct a captcha assessment that will always be invalid + */ + public static AssessmentResult invalid() { + return new AssessmentResult(false, 0.0f, 0.0f, ""); + } + + /** + * Construct a captcha assessment that will always be valid + */ + public static AssessmentResult alwaysValid() { + return new AssessmentResult(true, 1.0f, 0.0f, "1.0"); + } + + /** + * Check if the captcha assessment should be accepted using the default score threshold + * + * @return true if this assessment should be accepted under the default score threshold + */ + public boolean isValid() { + return isValid(Optional.empty()); + } + + /** + * Check if the captcha assessment should be accepted + * + * @param scoreThreshold the minimum score the assessment requires to pass, uses default if empty + * @return true if the assessment scored higher than the provided scoreThreshold + */ + public boolean isValid(Optional scoreThreshold) { + if (!solved) { + return false; + } + return this.actualScore >= scoreThreshold.orElse(this.defaultScoreThreshold); + } + + public String getScoreString() { + return scoreString; + } + + public float getScore() { + return this.actualScore; + } + + /** * Map a captcha score in [0.0, 1.0] to a low cardinality discrete space in [0, 100] suitable for use in metrics */ - static String scoreString(final float score) { + private static String scoreString(final float score) { final int x = Math.round(score * 10); // [0, 10] return Integer.toString(x * 10); // [0, 100] in increments of 10 } + + @Override + public boolean equals(final Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + AssessmentResult that = (AssessmentResult) o; + return solved == that.solved && Float.compare(that.actualScore, actualScore) == 0 + && Float.compare(that.defaultScoreThreshold, defaultScoreThreshold) == 0 && Objects.equals(scoreString, + that.scoreString); + } + + @Override + public int hashCode() { + return Objects.hash(solved, actualScore, defaultScoreThreshold, scoreString); + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaChecker.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaChecker.java index 920ec679e..5d2e984df 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaChecker.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaChecker.java @@ -91,8 +91,7 @@ public class CaptchaChecker { final AssessmentResult result = client.verify(siteKey, parsedAction, token, ip); Metrics.counter(ASSESSMENTS_COUNTER_NAME, "action", action, - "valid", String.valueOf(result.valid()), - "score", result.score(), + "score", result.getScoreString(), "provider", prefix) .increment(); return result; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClient.java index a68c40334..f74601669 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClient.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClient.java @@ -33,7 +33,6 @@ public class HCaptchaClient implements CaptchaClient { private static final String PREFIX = "signal-hcaptcha"; private static final String ASSESSMENT_REASON_COUNTER_NAME = name(HCaptchaClient.class, "assessmentReason"); private static final String INVALID_REASON_COUNTER_NAME = name(HCaptchaClient.class, "invalidReason"); - private static final String INVALID_SITEKEY_COUNTER_NAME = name(HCaptchaClient.class, "invalidSiteKey"); private final String apiKey; private final HttpClient client; private final DynamicConfigurationManager dynamicConfigurationManager; @@ -115,16 +114,15 @@ public class HCaptchaClient implements CaptchaClient { logger.error("Invalid score {} from hcaptcha response {}", hCaptchaResponse.score, hCaptchaResponse); return AssessmentResult.invalid(); } - final String scoreString = AssessmentResult.scoreString(score); + final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor()); + final AssessmentResult assessmentResult = AssessmentResult.fromScore(score, threshold.floatValue()); for (String reason : hCaptchaResponse.scoreReasons) { Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME, "action", action.getActionName(), "reason", reason, - "score", scoreString).increment(); + "score", assessmentResult.getScoreString()).increment(); } - - final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor()); - return new AssessmentResult(score >= threshold.floatValue(), scoreString); + return assessmentResult; } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/RecaptchaClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/RecaptchaClient.java index 092a6b2de..216901365 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/RecaptchaClient.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/RecaptchaClient.java @@ -113,17 +113,16 @@ public class RecaptchaClient implements CaptchaClient { if (assessment.getTokenProperties().getValid()) { final float score = assessment.getRiskAnalysis().getScore(); log.debug("assessment for {} was valid, score: {}", action.getActionName(), score); + final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor()); + final AssessmentResult assessmentResult = AssessmentResult.fromScore(score, threshold.floatValue()); for (RiskAnalysis.ClassificationReason reason : assessment.getRiskAnalysis().getReasonsList()) { Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME, "action", action.getActionName(), - "score", AssessmentResult.scoreString(score), + "score", assessmentResult.getScoreString(), "reason", reason.name()) .increment(); } - final BigDecimal threshold = config.getScoreFloorByAction().getOrDefault(action, config.getScoreFloor()); - return new AssessmentResult( - score >= threshold.floatValue(), - AssessmentResult.scoreString(score)); + return assessmentResult; } else { Metrics.counter(INVALID_REASON_COUNTER_NAME, "action", action.getActionName(), diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java index 50c6f0ba2..5cb63a06e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java @@ -93,7 +93,9 @@ import org.whispersystems.textsecuregcm.push.PushNotificationManager; import org.whispersystems.textsecuregcm.registration.ClientType; import org.whispersystems.textsecuregcm.registration.MessageTransport; import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; +import org.whispersystems.textsecuregcm.spam.Extract; import org.whispersystems.textsecuregcm.spam.FilterSpam; +import org.whispersystems.textsecuregcm.spam.ScoreThreshold; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.ChangeNumberManager; @@ -255,14 +257,15 @@ public class AccountController { @Path("/{transport}/code/{number}") @FilterSpam @Produces(MediaType.APPLICATION_JSON) - public Response createAccount(@PathParam("transport") String transport, - @PathParam("number") String number, - @HeaderParam(HttpHeaders.X_FORWARDED_FOR) String forwardedFor, - @HeaderParam(HttpHeaders.USER_AGENT) String userAgent, - @HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) Optional acceptLanguage, - @QueryParam("client") Optional client, - @QueryParam("captcha") Optional captcha, - @QueryParam("challenge") Optional pushChallenge) + public Response createAccount(@PathParam("transport") String transport, + @PathParam("number") String number, + @HeaderParam(HttpHeaders.X_FORWARDED_FOR) String forwardedFor, + @HeaderParam(HttpHeaders.USER_AGENT) String userAgent, + @HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) Optional acceptLanguage, + @QueryParam("client") Optional client, + @QueryParam("captcha") Optional captcha, + @QueryParam("challenge") Optional pushChallenge, + @Extract ScoreThreshold captchaScoreThreshold) throws RateLimitExceededException, ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException, IOException { Util.requireNormalizedNumber(number); @@ -278,12 +281,12 @@ public class AccountController { assessmentResult.ifPresent(result -> Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of( - Tag.of("success", String.valueOf(result.valid())), + Tag.of("success", String.valueOf(result.isValid(captchaScoreThreshold.getScoreThreshold()))), UserAgentTagUtil.getPlatformTag(userAgent), Tag.of(COUNTRY_CODE_TAG_NAME, countryCode), Tag.of(REGION_TAG_NAME, region), Tag.of(REGION_CODE_TAG_NAME, region), - Tag.of(SCORE_TAG_NAME, result.score()))) + Tag.of(SCORE_TAG_NAME, result.getScoreString()))) .increment()); final boolean pushChallengeMatch = pushChallengeMatches(number, pushChallenge, maybeStoredVerificationCode); @@ -293,7 +296,7 @@ public class AccountController { } final boolean requiresCaptcha = assessmentResult - .map(result -> !result.valid()) + .map(result -> !result.isValid(captchaScoreThreshold.getScoreThreshold())) .orElseGet( () -> registrationCaptchaManager.requiresCaptcha(number, forwardedFor, sourceHost, pushChallengeMatch)); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java index d7955e37a..34611996b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java @@ -75,8 +75,10 @@ import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; import org.whispersystems.textsecuregcm.registration.RegistrationServiceException; import org.whispersystems.textsecuregcm.registration.RegistrationServiceSenderException; import org.whispersystems.textsecuregcm.registration.VerificationSession; +import org.whispersystems.textsecuregcm.spam.Extract; import org.whispersystems.textsecuregcm.spam.FilterSpam; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.spam.ScoreThreshold; import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.VerificationSessionManager; import org.whispersystems.textsecuregcm.util.ExceptionUtils; @@ -89,7 +91,6 @@ import org.whispersystems.textsecuregcm.util.Util; public class VerificationController { private static final Logger logger = LoggerFactory.getLogger(VerificationController.class); - private static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15); private static final Duration DYNAMODB_TIMEOUT = Duration.ofSeconds(5); @@ -195,7 +196,8 @@ public class VerificationController { public VerificationSessionResponse updateSession(@PathParam("sessionId") final String encodedSessionId, @HeaderParam(com.google.common.net.HttpHeaders.X_FORWARDED_FOR) String forwardedFor, @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent, - @NotNull @Valid final UpdateVerificationSessionRequest updateVerificationSessionRequest) { + @NotNull @Valid final UpdateVerificationSessionRequest updateVerificationSessionRequest, + @NotNull @Extract final ScoreThreshold captchaScoreThreshold) { final String sourceHost = HeaderUtils.getMostRecentProxy(forwardedFor).orElseThrow(); @@ -213,7 +215,7 @@ public class VerificationController { verificationSession); verificationSession = handleCaptcha(sourceHost, updateVerificationSessionRequest, registrationServiceSession, - verificationSession, userAgent); + verificationSession, userAgent, captchaScoreThreshold.getScoreThreshold()); } catch (final RateLimitExceededException e) { final Response response = buildResponseForRateLimitExceeded(verificationSession, registrationServiceSession, @@ -351,11 +353,13 @@ public class VerificationController { * @throws ForbiddenException if assessment is not valid. * @throws RateLimitExceededException if too many captchas have been submitted */ - private VerificationSession handleCaptcha(final String sourceHost, + private VerificationSession handleCaptcha( + final String sourceHost, final UpdateVerificationSessionRequest updateVerificationSessionRequest, final RegistrationServiceSession registrationServiceSession, VerificationSession verificationSession, - final String userAgent) throws RateLimitExceededException { + final String userAgent, + final Optional captchaScoreThreshold) throws RateLimitExceededException { if (updateVerificationSessionRequest.captcha() == null) { return verificationSession; @@ -366,23 +370,24 @@ public class VerificationController { final AssessmentResult assessmentResult; try { + assessmentResult = registrationCaptchaManager.assessCaptcha( Optional.of(updateVerificationSessionRequest.captcha()), sourceHost) .orElseThrow(() -> new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR)); Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of( - Tag.of(SUCCESS_TAG_NAME, String.valueOf(assessmentResult.valid())), + Tag.of(SUCCESS_TAG_NAME, String.valueOf(assessmentResult.isValid(captchaScoreThreshold))), UserAgentTagUtil.getPlatformTag(userAgent), Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number())), Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number())), - Tag.of(SCORE_TAG_NAME, assessmentResult.score()))) + Tag.of(SCORE_TAG_NAME, assessmentResult.getScoreString()))) .increment(); } catch (IOException e) { throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE); } - if (assessmentResult.valid()) { + if (assessmentResult.isValid(captchaScoreThreshold)) { final List submittedInformation = new ArrayList<>( verificationSession.submittedInformation()); submittedInformation.add(VerificationSession.Information.CAPTCHA); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java index 3295324ab..3625d7308 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java @@ -68,7 +68,7 @@ public class RateLimitChallengeManager { rateLimiters.getRecaptchaChallengeAttemptLimiter().validate(account.getUuid()); - final boolean challengeSuccess = captchaChecker.verify(Action.CHALLENGE, captcha, mostRecentProxyIp).valid(); + final boolean challengeSuccess = captchaChecker.verify(Action.CHALLENGE, captcha, mostRecentProxyIp).isValid(); final Tags tags = Tags.of( Tag.of(SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())), diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/spam/Extract.java b/service/src/main/java/org/whispersystems/textsecuregcm/spam/Extract.java new file mode 100644 index 000000000..aa0a407c4 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/spam/Extract.java @@ -0,0 +1,20 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.spam; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates that a parameter should be parsed from a {@link org.glassfish.jersey.server.ContainerRequest} + */ +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Extract { + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/spam/ScoreThreshold.java b/service/src/main/java/org/whispersystems/textsecuregcm/spam/ScoreThreshold.java new file mode 100644 index 000000000..e2d463435 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/spam/ScoreThreshold.java @@ -0,0 +1,46 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.spam; + +import org.glassfish.jersey.server.ContainerRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Optional; + +/** + * A ScoreThreshold may be provided by an upstream request filter. If request contains a property for + * SCORE_THRESHOLD_PROPERTY_NAME it can be forwarded to a downstream filter to indicate it can use + * a more or less strict score threshold when evaluating whether a request should be allowed to continue. + */ +public class ScoreThreshold { + private static final Logger logger = LoggerFactory.getLogger(ScoreThreshold.class); + + public static final String PROPERTY_NAME = "scoreThreshold"; + + /** + * A score threshold in the range [0, 1.0] + */ + private final Optional scoreThreshold; + + /** + * Extract an optional score threshold parameter provided by an upstream request filter + */ + public ScoreThreshold(final ContainerRequest containerRequest) { + this.scoreThreshold = Optional + .ofNullable(containerRequest.getProperty(PROPERTY_NAME)) + .flatMap(obj -> { + if (obj instanceof Float f) { + return Optional.of(f); + } + logger.warn("invalid format for filter provided score threshold {}", obj); + return Optional.empty(); + }); + } + + public Optional getScoreThreshold() { + return this.scoreThreshold; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/spam/ScoreThresholdProvider.java b/service/src/main/java/org/whispersystems/textsecuregcm/spam/ScoreThresholdProvider.java new file mode 100644 index 000000000..844294fa7 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/spam/ScoreThresholdProvider.java @@ -0,0 +1,55 @@ +package org.whispersystems.textsecuregcm.spam; + +import java.util.function.Function; +import javax.inject.Singleton; +import javax.ws.rs.core.Feature; +import javax.ws.rs.core.FeatureContext; +import org.glassfish.jersey.internal.inject.AbstractBinder; +import org.glassfish.jersey.server.ContainerRequest; +import org.glassfish.jersey.server.model.Parameter; +import org.glassfish.jersey.server.spi.internal.ValueParamProvider; + +/** + * Parses a {@link ScoreThreshold} out of a {@link ContainerRequest} to provide to jersey resources. + * + * A request filter may enrich a ContainerRequest with a scoreThreshold by providing a float property with the name + * {@link ScoreThreshold#PROPERTY_NAME}. This indicates the desired scoreThreshold to use when evaluating whether a + * request should proceed. + * + * A resource can consume a ScoreThreshold with by annotating a ScoreThreshold parameter with {@link Extract} + */ +public class ScoreThresholdProvider implements ValueParamProvider { + + /** + * Configures the ScoreThresholdProvider + */ + public static class ScoreThresholdFeature implements Feature { + @Override + public boolean configure(FeatureContext context) { + context.register(new AbstractBinder() { + @Override + protected void configure() { + bind(ScoreThresholdProvider.class) + .to(ValueParamProvider.class) + .in(Singleton.class); + } + }); + return true; + } + } + + @Override + public Function getValueProvider(final Parameter parameter) { + if (parameter.getRawType().equals(ScoreThreshold.class) + && parameter.isAnnotationPresent(Extract.class)) { + return ScoreThreshold::new; + } + return null; + + } + + @Override + public PriorityType getPriority() { + return Priority.HIGH; + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java index 0896ab896..31df41847 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java @@ -84,7 +84,7 @@ public class CaptchaCheckerTest { @ParameterizedTest @MethodSource void scoreString(float score, String expected) { - assertThat(AssessmentResult.scoreString(score)).isEqualTo(expected); + assertThat(AssessmentResult.fromScore(score, 0.0f).getScoreString()).isEqualTo(expected); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClientTest.java index 58bf6f364..04e22bc03 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClientTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClientTest.java @@ -58,8 +58,7 @@ public class HCaptchaClientTest { if (!success) { assertThat(result).isEqualTo(AssessmentResult.invalid()); } else { - assertThat(result) - .isEqualTo(new AssessmentResult(expectedResult, AssessmentResult.scoreString(score))); + assertThat(result.isValid()).isEqualTo(expectedResult); } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java index aed2d23d9..302797bcf 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java @@ -109,6 +109,8 @@ import org.whispersystems.textsecuregcm.push.PushNotificationManager; import org.whispersystems.textsecuregcm.registration.ClientType; import org.whispersystems.textsecuregcm.registration.MessageTransport; import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; +import org.whispersystems.textsecuregcm.spam.Extract; +import org.whispersystems.textsecuregcm.spam.ScoreThresholdProvider; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.ChangeNumberManager; @@ -215,6 +217,7 @@ class AccountControllerTest { .addProvider(new ImpossiblePhoneNumberExceptionMapper()) .addProvider(new NonNormalizedPhoneNumberExceptionMapper()) .addProvider(new RateLimitByIpFilter(rateLimiters)) + .addProvider(ScoreThresholdProvider.ScoreThresholdFeature.class) .setMapper(SystemMapper.jsonMapper()) .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) .addResource(new AccountController(pendingAccountsManager, @@ -349,7 +352,7 @@ class AccountControllerTest { when(captchaChecker.verify(eq(Action.REGISTRATION), eq(INVALID_CAPTCHA_TOKEN), anyString())) .thenReturn(AssessmentResult.invalid()); when(captchaChecker.verify(eq(Action.REGISTRATION), eq(VALID_CAPTCHA_TOKEN), anyString())) - .thenReturn(new AssessmentResult(true, "")); + .thenReturn(AssessmentResult.alwaysValid()); doThrow(new RateLimitExceededException(Duration.ZERO, true)).when(pinLimiter).validate(eq(SENDER_OVER_PIN)); @@ -690,6 +693,7 @@ class AccountControllerTest { final Response response = resources.getJerseyTest() .target(String.format("/v1/accounts/sms/code/%s", number)) + .register(Extract.class) .queryParam("challenge", "1234-push") .request() .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/VerificationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/VerificationControllerTest.java index 9b62bbb82..f54e20d93 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/VerificationControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/VerificationControllerTest.java @@ -69,6 +69,7 @@ import org.whispersystems.textsecuregcm.registration.RegistrationServiceSenderEx import org.whispersystems.textsecuregcm.registration.VerificationSession; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.spam.ScoreThresholdProvider; import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; import org.whispersystems.textsecuregcm.storage.VerificationSessionManager; import org.whispersystems.textsecuregcm.util.SystemMapper; @@ -100,6 +101,7 @@ class VerificationControllerTest { .addProvider(new ImpossiblePhoneNumberExceptionMapper()) .addProvider(new NonNormalizedPhoneNumberExceptionMapper()) .addProvider(new RegistrationServiceSenderExceptionMapper()) + .addProvider(ScoreThresholdProvider.ScoreThresholdFeature.class) .setMapper(SystemMapper.jsonMapper()) .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) .addResource( @@ -621,7 +623,7 @@ class VerificationControllerTest { registrationServiceSession.expiration())))); when(registrationCaptchaManager.assessCaptcha(any(), any())) - .thenReturn(Optional.of(new AssessmentResult(true, "1"))); + .thenReturn(Optional.of(AssessmentResult.alwaysValid())); when(verificationSessionManager.update(any(), any())) .thenReturn(CompletableFuture.completedFuture(null)); @@ -669,7 +671,7 @@ class VerificationControllerTest { registrationServiceSession.expiration())))); when(registrationCaptchaManager.assessCaptcha(any(), any())) - .thenReturn(Optional.of(new AssessmentResult(true, "1"))); + .thenReturn(Optional.of(AssessmentResult.alwaysValid())); when(verificationSessionManager.update(any(), any())) .thenReturn(CompletableFuture.completedFuture(null)); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManagerTest.java index 60a2afd65..938ff757e 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManagerTest.java @@ -78,7 +78,7 @@ class RateLimitChallengeManagerTest { when(captchaChecker.verify(eq(Action.CHALLENGE), any(), any())) .thenReturn(successfulChallenge - ? new AssessmentResult(true, "") + ? AssessmentResult.alwaysValid() : AssessmentResult.invalid()); when(rateLimiters.getRecaptchaChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class));