Add metrics for captcha scores
This commit is contained in:
parent
d0a8899daf
commit
c14621a09f
|
@ -125,6 +125,7 @@ public class AccountController {
|
||||||
private static final String CHALLENGE_ISSUED_COUNTER_NAME = name(AccountController.class, "challengeIssued");
|
private static final String CHALLENGE_ISSUED_COUNTER_NAME = name(AccountController.class, "challengeIssued");
|
||||||
|
|
||||||
private static final String TWILIO_VERIFY_ERROR_COUNTER_NAME = name(AccountController.class, "twilioVerifyError");
|
private static final String TWILIO_VERIFY_ERROR_COUNTER_NAME = name(AccountController.class, "twilioVerifyError");
|
||||||
|
private static final String TWILIO_VERIFY_UNDELIVERED_COUNTER_NAME = name(AccountController.class, "twilioUndelivered");
|
||||||
|
|
||||||
private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(AccountController.class, "invalidAcceptLanguage");
|
private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(AccountController.class, "invalidAcceptLanguage");
|
||||||
private static final String NONSTANDARD_USERNAME_COUNTER_NAME = name(AccountController.class, "nonStandardUsername");
|
private static final String NONSTANDARD_USERNAME_COUNTER_NAME = name(AccountController.class, "nonStandardUsername");
|
||||||
|
@ -134,6 +135,7 @@ public class AccountController {
|
||||||
private static final String COUNTRY_CODE_TAG_NAME = "countryCode";
|
private static final String COUNTRY_CODE_TAG_NAME = "countryCode";
|
||||||
private static final String REGION_TAG_NAME = "region";
|
private static final String REGION_TAG_NAME = "region";
|
||||||
private static final String VERIFICATION_TRANSPORT_TAG_NAME = "transport";
|
private static final String VERIFICATION_TRANSPORT_TAG_NAME = "transport";
|
||||||
|
private static final String SCORE_TAG_NAME = "score";
|
||||||
|
|
||||||
private static final String VERIFY_EXPERIMENT_TAG_NAME = "twilioVerify";
|
private static final String VERIFY_EXPERIMENT_TAG_NAME = "twilioVerify";
|
||||||
|
|
||||||
|
@ -231,23 +233,34 @@ public class AccountController {
|
||||||
String sourceHost = ForwardedIpUtil.getMostRecentProxy(forwardedFor).orElseThrow();
|
String sourceHost = ForwardedIpUtil.getMostRecentProxy(forwardedFor).orElseThrow();
|
||||||
|
|
||||||
Optional<StoredVerificationCode> storedChallenge = pendingAccounts.getCodeForNumber(number);
|
Optional<StoredVerificationCode> storedChallenge = pendingAccounts.getCodeForNumber(number);
|
||||||
CaptchaRequirement requirement = requiresCaptcha(number, transport, forwardedFor, sourceHost, captcha,
|
|
||||||
storedChallenge, pushChallenge, userAgent);
|
|
||||||
|
|
||||||
if (requirement.isCaptchaRequired()) {
|
final String countryCode = Util.getCountryCode(number);
|
||||||
|
final String region = Util.getRegion(number);
|
||||||
|
|
||||||
|
// if there's a captcha, assess it, otherwise check if we need a captcha
|
||||||
|
final Optional<RecaptchaClient.AssessmentResult> assessmentResult = captcha
|
||||||
|
.map(captchaToken -> recaptchaClient.verify(captchaToken, sourceHost));
|
||||||
|
|
||||||
|
assessmentResult.ifPresent(result ->
|
||||||
|
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(
|
||||||
|
Tag.of("success", String.valueOf(result.valid())),
|
||||||
|
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||||
|
Tag.of(COUNTRY_CODE_TAG_NAME, countryCode),
|
||||||
|
Tag.of(REGION_TAG_NAME, region),
|
||||||
|
Tag.of(SCORE_TAG_NAME, result.score())))
|
||||||
|
.increment());
|
||||||
|
|
||||||
|
final boolean requiresCaptcha = assessmentResult
|
||||||
|
.map(result -> !result.valid())
|
||||||
|
.orElseGet(() -> requiresCaptcha(number, transport, forwardedFor, sourceHost, storedChallenge, pushChallenge));
|
||||||
|
|
||||||
|
if (requiresCaptcha) {
|
||||||
captchaRequiredMeter.mark();
|
captchaRequiredMeter.mark();
|
||||||
|
|
||||||
Metrics.counter(CHALLENGE_ISSUED_COUNTER_NAME, Tags.of(
|
Metrics.counter(CHALLENGE_ISSUED_COUNTER_NAME, Tags.of(
|
||||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||||
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
|
Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),
|
||||||
Tag.of(REGION_TAG_NAME, Util.getRegion(number))))
|
Tag.of(REGION_TAG_NAME, Util.getRegion(number))))
|
||||||
.increment();
|
.increment();
|
||||||
|
|
||||||
if (requirement.isAutoBlock() && shouldAutoBlock(sourceHost)) {
|
|
||||||
logger.info("Auto-block: {}", sourceHost);
|
|
||||||
abusiveHostRules.setBlockedHost(sourceHost);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.status(402).build();
|
return Response.status(402).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -315,6 +328,14 @@ public class AccountController {
|
||||||
logger.warn("Error with Twilio Verify", throwable);
|
logger.warn("Error with Twilio Verify", throwable);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (enrolledInVerifyExperiment && maybeVerificationSid.isEmpty() && assessmentResult.isPresent()) {
|
||||||
|
Metrics.counter(TWILIO_VERIFY_UNDELIVERED_COUNTER_NAME, Tags.of(
|
||||||
|
Tag.of(COUNTRY_CODE_TAG_NAME, countryCode),
|
||||||
|
Tag.of(REGION_TAG_NAME, region),
|
||||||
|
UserAgentTagUtil.getPlatformTag(userAgent),
|
||||||
|
Tag.of(SCORE_TAG_NAME, assessmentResult.get().score())))
|
||||||
|
.increment();
|
||||||
|
}
|
||||||
maybeVerificationSid.ifPresent(twilioVerificationSid -> {
|
maybeVerificationSid.ifPresent(twilioVerificationSid -> {
|
||||||
StoredVerificationCode storedVerificationCodeWithVerificationSid = new StoredVerificationCode(
|
StoredVerificationCode storedVerificationCodeWithVerificationSid = new StoredVerificationCode(
|
||||||
storedVerificationCode.getCode(),
|
storedVerificationCode.getCode(),
|
||||||
|
@ -798,37 +819,17 @@ public class AccountController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private CaptchaRequirement requiresCaptcha(String number, String transport, String forwardedFor,
|
private boolean requiresCaptcha(String number, String transport, String forwardedFor,
|
||||||
String sourceHost,
|
String sourceHost,
|
||||||
Optional<String> captchaToken,
|
|
||||||
Optional<StoredVerificationCode> storedVerificationCode,
|
Optional<StoredVerificationCode> storedVerificationCode,
|
||||||
Optional<String> pushChallenge,
|
Optional<String> pushChallenge) {
|
||||||
String userAgent)
|
|
||||||
{
|
|
||||||
if (testDevices.containsKey(number)) {
|
if (testDevices.containsKey(number)) {
|
||||||
return new CaptchaRequirement(false, false);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final String countryCode = Util.getCountryCode(number);
|
final String countryCode = Util.getCountryCode(number);
|
||||||
final String region = Util.getRegion(number);
|
final String region = Util.getRegion(number);
|
||||||
|
|
||||||
if (captchaToken.isPresent()) {
|
|
||||||
boolean validToken = recaptchaClient.verify(captchaToken.get(), sourceHost);
|
|
||||||
|
|
||||||
Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(
|
|
||||||
Tag.of("success", String.valueOf(validToken)),
|
|
||||||
UserAgentTagUtil.getPlatformTag(userAgent),
|
|
||||||
Tag.of(COUNTRY_CODE_TAG_NAME, countryCode),
|
|
||||||
Tag.of(REGION_TAG_NAME, region)))
|
|
||||||
.increment();
|
|
||||||
|
|
||||||
if (validToken) {
|
|
||||||
return new CaptchaRequirement(false, false);
|
|
||||||
} else {
|
|
||||||
return new CaptchaRequirement(true, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
final List<Tag> tags = new ArrayList<>();
|
final List<Tag> tags = new ArrayList<>();
|
||||||
tags.add(Tag.of(COUNTRY_CODE_TAG_NAME, countryCode));
|
tags.add(Tag.of(COUNTRY_CODE_TAG_NAME, countryCode));
|
||||||
|
@ -842,14 +843,13 @@ public class AccountController {
|
||||||
|
|
||||||
if (!pushChallenge.get().equals(storedPushChallenge.orElse(null))) {
|
if (!pushChallenge.get().equals(storedPushChallenge.orElse(null))) {
|
||||||
tags.add(Tag.of(CHALLENGE_MATCH_TAG_NAME, "false"));
|
tags.add(Tag.of(CHALLENGE_MATCH_TAG_NAME, "false"));
|
||||||
return new CaptchaRequirement(true, false);
|
return true;
|
||||||
} else {
|
} else {
|
||||||
tags.add(Tag.of(CHALLENGE_MATCH_TAG_NAME, "true"));
|
tags.add(Tag.of(CHALLENGE_MATCH_TAG_NAME, "true"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tags.add(Tag.of(CHALLENGE_PRESENT_TAG_NAME, "false"));
|
tags.add(Tag.of(CHALLENGE_PRESENT_TAG_NAME, "false"));
|
||||||
|
return true;
|
||||||
return new CaptchaRequirement(true, false);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
Metrics.counter(PUSH_CHALLENGE_COUNTER_NAME, tags).increment();
|
Metrics.counter(PUSH_CHALLENGE_COUNTER_NAME, tags).increment();
|
||||||
|
@ -870,7 +870,7 @@ public class AccountController {
|
||||||
// would be caught by country filter as well
|
// would be caught by country filter as well
|
||||||
countryFilterApplicable.mark();
|
countryFilterApplicable.mark();
|
||||||
}
|
}
|
||||||
return new CaptchaRequirement(true, false);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -878,7 +878,11 @@ public class AccountController {
|
||||||
} catch (RateLimitExceededException e) {
|
} catch (RateLimitExceededException e) {
|
||||||
logger.info("Rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
|
logger.info("Rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
|
||||||
rateLimitedHostMeter.mark();
|
rateLimitedHostMeter.mark();
|
||||||
return new CaptchaRequirement(true, true);
|
if (shouldAutoBlock(sourceHost)) {
|
||||||
|
logger.info("Auto-block: {}", sourceHost);
|
||||||
|
abusiveHostRules.setBlockedHost(sourceHost);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -886,15 +890,18 @@ public class AccountController {
|
||||||
} catch (RateLimitExceededException e) {
|
} catch (RateLimitExceededException e) {
|
||||||
logger.info("Prefix rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
|
logger.info("Prefix rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor);
|
||||||
rateLimitedPrefixMeter.mark();
|
rateLimitedPrefixMeter.mark();
|
||||||
return new CaptchaRequirement(true, true);
|
if (shouldAutoBlock(sourceHost)) {
|
||||||
|
logger.info("Auto-block: {}", sourceHost);
|
||||||
|
abusiveHostRules.setBlockedHost(sourceHost);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (countryFiltered) {
|
if (countryFiltered) {
|
||||||
countryFilteredHostMeter.mark();
|
countryFilteredHostMeter.mark();
|
||||||
return new CaptchaRequirement(true, false);
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
return new CaptchaRequirement(false, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Timed
|
@Timed
|
||||||
|
@ -941,22 +948,4 @@ public class AccountController {
|
||||||
|
|
||||||
return Hex.toStringCondensed(challenge);
|
return Hex.toStringCondensed(challenge);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class CaptchaRequirement {
|
|
||||||
private final boolean captchaRequired;
|
|
||||||
private final boolean autoBlock;
|
|
||||||
|
|
||||||
private CaptchaRequirement(boolean captchaRequired, boolean autoBlock) {
|
|
||||||
this.captchaRequired = captchaRequired;
|
|
||||||
this.autoBlock = autoBlock;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isCaptchaRequired() {
|
|
||||||
return captchaRequired;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isAutoBlock() {
|
|
||||||
return autoBlock;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,7 +62,7 @@ public class RateLimitChallengeManager {
|
||||||
|
|
||||||
rateLimiters.getRecaptchaChallengeAttemptLimiter().validate(account.getUuid());
|
rateLimiters.getRecaptchaChallengeAttemptLimiter().validate(account.getUuid());
|
||||||
|
|
||||||
final boolean challengeSuccess = recaptchaClient.verify(captcha, mostRecentProxyIp);
|
final boolean challengeSuccess = recaptchaClient.verify(captcha, mostRecentProxyIp).valid();
|
||||||
|
|
||||||
final Tags tags = Tags.of(
|
final Tags tags = Tags.of(
|
||||||
Tag.of(SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())),
|
Tag.of(SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())),
|
||||||
|
|
|
@ -77,7 +77,20 @@ public class RecaptchaClient {
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean verify(final String input, final String ip) {
|
/**
|
||||||
|
* 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) {
|
||||||
|
public static AssessmentResult invalid() {
|
||||||
|
return new AssessmentResult(false, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public AssessmentResult verify(final String input, final String ip) {
|
||||||
final String[] parts = parseInputToken(input);
|
final String[] parts = parseInputToken(input);
|
||||||
|
|
||||||
final String sitekey = parts[0];
|
final String sitekey = parts[0];
|
||||||
|
@ -102,11 +115,13 @@ public class RecaptchaClient {
|
||||||
.increment();
|
.increment();
|
||||||
|
|
||||||
if (assessment.getTokenProperties().getValid()) {
|
if (assessment.getTokenProperties().getValid()) {
|
||||||
return assessment.getRiskAnalysis().getScore() >=
|
final float score = assessment.getRiskAnalysis().getScore();
|
||||||
dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration().getScoreFloor().floatValue();
|
return new AssessmentResult(
|
||||||
|
score >=
|
||||||
|
dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration().getScoreFloor().floatValue(),
|
||||||
|
Integer.toString((int) score));
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return AssessmentResult.invalid();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,7 +68,10 @@ class RateLimitChallengeManagerTest {
|
||||||
when(account.getNumber()).thenReturn("+18005551234");
|
when(account.getNumber()).thenReturn("+18005551234");
|
||||||
when(account.getUuid()).thenReturn(UUID.randomUUID());
|
when(account.getUuid()).thenReturn(UUID.randomUUID());
|
||||||
|
|
||||||
when(recaptchaClient.verify(any(), any())).thenReturn(successfulChallenge);
|
when(recaptchaClient.verify(any(), any()))
|
||||||
|
.thenReturn(successfulChallenge
|
||||||
|
? new RecaptchaClient.AssessmentResult(true, "")
|
||||||
|
: RecaptchaClient.AssessmentResult.invalid());
|
||||||
|
|
||||||
when(rateLimiters.getRecaptchaChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class));
|
when(rateLimiters.getRecaptchaChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class));
|
||||||
when(rateLimiters.getRecaptchaChallengeSuccessLimiter()).thenReturn(mock(RateLimiter.class));
|
when(rateLimiters.getRecaptchaChallengeSuccessLimiter()).thenReturn(mock(RateLimiter.class));
|
||||||
|
|
|
@ -300,8 +300,10 @@ class AccountControllerTest {
|
||||||
when(abusiveHostRules.isBlocked(eq(ABUSIVE_HOST))).thenReturn(true);
|
when(abusiveHostRules.isBlocked(eq(ABUSIVE_HOST))).thenReturn(true);
|
||||||
when(abusiveHostRules.isBlocked(eq(NICE_HOST))).thenReturn(false);
|
when(abusiveHostRules.isBlocked(eq(NICE_HOST))).thenReturn(false);
|
||||||
|
|
||||||
when(recaptchaClient.verify(eq(INVALID_CAPTCHA_TOKEN), anyString())).thenReturn(false);
|
when(recaptchaClient.verify(eq(INVALID_CAPTCHA_TOKEN), anyString()))
|
||||||
when(recaptchaClient.verify(eq(VALID_CAPTCHA_TOKEN), anyString())).thenReturn(true);
|
.thenReturn(RecaptchaClient.AssessmentResult.invalid());
|
||||||
|
when(recaptchaClient.verify(eq(VALID_CAPTCHA_TOKEN), anyString()))
|
||||||
|
.thenReturn(new RecaptchaClient.AssessmentResult(true, ""));
|
||||||
|
|
||||||
doThrow(new RateLimitExceededException(Duration.ZERO)).when(pinLimiter).validate(eq(SENDER_OVER_PIN));
|
doThrow(new RateLimitExceededException(Duration.ZERO)).when(pinLimiter).validate(eq(SENDER_OVER_PIN));
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue