mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-20 04:28:02 +01:00
Get captcha clients from spam-filter module
This commit is contained in:
@@ -37,7 +37,6 @@ import org.whispersystems.textsecuregcm.configuration.FcmConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.GenericZkConfig;
|
||||
import org.whispersystems.textsecuregcm.configuration.GooglePlayBillingConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.HCaptchaClientFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration;
|
||||
@@ -202,11 +201,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
@JsonProperty
|
||||
private UnidentifiedDeliveryConfiguration unidentifiedDelivery;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
private HCaptchaClientFactory hCaptcha;
|
||||
|
||||
@Valid
|
||||
@NotNull
|
||||
@JsonProperty
|
||||
@@ -379,10 +373,6 @@ public class WhisperServerConfiguration extends Configuration {
|
||||
return dynamoDbTables;
|
||||
}
|
||||
|
||||
public HCaptchaClientFactory getHCaptchaConfiguration() {
|
||||
return hCaptcha;
|
||||
}
|
||||
|
||||
public ShortCodeExpanderConfiguration getShortCodeRetrieverConfiguration() {
|
||||
return shortCode;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ import org.whispersystems.textsecuregcm.calls.routing.CallRoutingTableManager;
|
||||
import org.whispersystems.textsecuregcm.calls.routing.DynamicConfigTurnRouter;
|
||||
import org.whispersystems.textsecuregcm.calls.routing.TurnCallRouter;
|
||||
import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
|
||||
import org.whispersystems.textsecuregcm.captcha.HCaptchaClient;
|
||||
import org.whispersystems.textsecuregcm.captcha.CaptchaClient;
|
||||
import org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;
|
||||
import org.whispersystems.textsecuregcm.captcha.ShortCodeExpander;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
@@ -499,8 +499,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
.scheduledExecutorService(name(getClass(), "secureValueRecoveryServiceRetry-%d")).threads(1).build();
|
||||
ScheduledExecutorService storageServiceRetryExecutor = environment.lifecycle()
|
||||
.scheduledExecutorService(name(getClass(), "storageServiceRetry-%d")).threads(1).build();
|
||||
ScheduledExecutorService hcaptchaRetryExecutor = environment.lifecycle()
|
||||
.scheduledExecutorService(name(getClass(), "hCaptchaRetry-%d")).threads(1).build();
|
||||
ScheduledExecutorService remoteStorageRetryExecutor = environment.lifecycle()
|
||||
.scheduledExecutorService(name(getClass(), "remoteStorageRetry-%d")).threads(1).build();
|
||||
ScheduledExecutorService registrationIdentityTokenRefreshExecutor = environment.lifecycle()
|
||||
@@ -551,14 +549,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
.maxThreads(8)
|
||||
.build();
|
||||
// unbounded executor (same as cachedThreadPool)
|
||||
ExecutorService hcaptchaHttpExecutor = environment.lifecycle()
|
||||
.executorService(name(getClass(), "hcaptcha-%d"))
|
||||
.minThreads(0)
|
||||
.maxThreads(Integer.MAX_VALUE)
|
||||
.workQueue(new SynchronousQueue<>())
|
||||
.keepAliveTime(io.dropwizard.util.Duration.seconds(60L))
|
||||
.build();
|
||||
// unbounded executor (same as cachedThreadPool)
|
||||
ExecutorService remoteStorageHttpExecutor = environment.lifecycle()
|
||||
.executorService(name(getClass(), "remoteStorage-%d"))
|
||||
.minThreads(0)
|
||||
@@ -706,13 +696,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
"message_byte_limit",
|
||||
config.getMessageByteLimitCardinalityEstimator().period());
|
||||
|
||||
HCaptchaClient hCaptchaClient = config.getHCaptchaConfiguration()
|
||||
.build(hcaptchaRetryExecutor, hcaptchaHttpExecutor, dynamicConfigurationManager);
|
||||
HttpClient shortCodeRetrieverHttpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2)
|
||||
.connectTimeout(Duration.ofSeconds(10)).build();
|
||||
ShortCodeExpander shortCodeRetriever = new ShortCodeExpander(shortCodeRetrieverHttpClient, config.getShortCodeRetrieverConfiguration().baseUrl());
|
||||
CaptchaChecker captchaChecker = new CaptchaChecker(shortCodeRetriever, List.of(hCaptchaClient));
|
||||
|
||||
PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager,
|
||||
pushChallengeDynamoDb);
|
||||
|
||||
@@ -765,7 +748,6 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
environment.lifecycle().manage(virtualThreadPinEventMonitor);
|
||||
environment.lifecycle().manage(accountsManager);
|
||||
|
||||
final RegistrationCaptchaManager registrationCaptchaManager = new RegistrationCaptchaManager(captchaChecker);
|
||||
|
||||
AwsCredentialsProvider cdnCredentialsProvider = config.getCdnConfiguration().credentials().build();
|
||||
S3Client cdnS3Client = S3Client.builder()
|
||||
@@ -1075,10 +1057,22 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||
log.warn("No registration-recovery-checkers found; using default (no-op) provider as a default");
|
||||
return RegistrationRecoveryChecker.noop();
|
||||
});
|
||||
|
||||
final List<CaptchaClient> captchaClients = spamFilter
|
||||
.map(SpamFilter::getCaptchaClients)
|
||||
.orElseGet(() -> {
|
||||
log.warn("No captcha clients found; using default (no-op) client as default");
|
||||
return List.of(CaptchaClient.noop());
|
||||
});
|
||||
|
||||
spamFilter.map(SpamFilter::getReportedMessageListener).ifPresent(reportMessageManager::addListener);
|
||||
|
||||
final HttpClient shortCodeRetrieverHttpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2)
|
||||
.connectTimeout(Duration.ofSeconds(10)).build();
|
||||
final ShortCodeExpander shortCodeRetriever = new ShortCodeExpander(shortCodeRetrieverHttpClient, config.getShortCodeRetrieverConfiguration().baseUrl());
|
||||
final CaptchaChecker captchaChecker = new CaptchaChecker(shortCodeRetriever, captchaClients);
|
||||
|
||||
final RegistrationCaptchaManager registrationCaptchaManager = new RegistrationCaptchaManager(captchaChecker);
|
||||
|
||||
final RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,
|
||||
captchaChecker, rateLimiters, spamFilter.map(SpamFilter::getRateLimitChallengeListener).stream().toList());
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -40,4 +41,24 @@ public interface CaptchaClient {
|
||||
final String token,
|
||||
final String ip,
|
||||
final String userAgent) throws IOException;
|
||||
|
||||
static CaptchaClient noop() {
|
||||
return new CaptchaClient() {
|
||||
@Override
|
||||
public String scheme() {
|
||||
return "noop";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> validSiteKeys(final Action action) {
|
||||
return Set.of("noop");
|
||||
}
|
||||
|
||||
@Override
|
||||
public AssessmentResult verify(final String siteKey, final Action action, final String token, final String ip,
|
||||
final String userAgent) throws IOException {
|
||||
return AssessmentResult.alwaysValid();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import javax.ws.rs.core.Response;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
public class HCaptchaClient implements CaptchaClient {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(HCaptchaClient.class);
|
||||
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 final String apiKey;
|
||||
private final FaultTolerantHttpClient client;
|
||||
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||
|
||||
@VisibleForTesting
|
||||
HCaptchaClient(final String apiKey,
|
||||
final FaultTolerantHttpClient faultTolerantHttpClient,
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
this.apiKey = apiKey;
|
||||
this.client = faultTolerantHttpClient;
|
||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||
}
|
||||
|
||||
public HCaptchaClient(
|
||||
final String apiKey,
|
||||
final ScheduledExecutorService retryExecutor,
|
||||
final ExecutorService httpExecutor,
|
||||
final CircuitBreakerConfiguration circuitBreakerConfiguration,
|
||||
final RetryConfiguration retryConfiguration,
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
this(apiKey,
|
||||
FaultTolerantHttpClient.newBuilder()
|
||||
.withName("hcaptcha")
|
||||
.withCircuitBreaker(circuitBreakerConfiguration)
|
||||
.withExecutor(httpExecutor)
|
||||
.withRetryExecutor(retryExecutor)
|
||||
.withRetry(retryConfiguration)
|
||||
.withRetryOnException(ex -> ex instanceof IOException)
|
||||
.withConnectTimeout(Duration.ofSeconds(10))
|
||||
.withVersion(HttpClient.Version.HTTP_2)
|
||||
.build(),
|
||||
dynamicConfigurationManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String scheme() {
|
||||
return PREFIX;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> validSiteKeys(final Action action) {
|
||||
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
|
||||
if (!config.isAllowHCaptcha()) {
|
||||
logger.warn("Received request to verify an hCaptcha, but hCaptcha is not enabled");
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return Optional
|
||||
.ofNullable(config.getHCaptchaSiteKeys().get(action))
|
||||
.orElse(Collections.emptySet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public AssessmentResult verify(
|
||||
final String siteKey,
|
||||
final Action action,
|
||||
final String token,
|
||||
final String ip,
|
||||
final String userAgent)
|
||||
throws IOException {
|
||||
|
||||
final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration();
|
||||
final String body = String.format("response=%s&secret=%s&remoteip=%s",
|
||||
URLEncoder.encode(token, StandardCharsets.UTF_8),
|
||||
URLEncoder.encode(this.apiKey, StandardCharsets.UTF_8),
|
||||
ip);
|
||||
final HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://hcaptcha.com/siteverify"))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
|
||||
final HttpResponse<String> response;
|
||||
try {
|
||||
response = this.client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
|
||||
} catch (CompletionException e) {
|
||||
logger.warn("failed to make http request to hCaptcha: {}", e.getMessage());
|
||||
throw new IOException(ExceptionUtils.unwrap(e));
|
||||
}
|
||||
|
||||
if (response.statusCode() != Response.Status.OK.getStatusCode()) {
|
||||
logger.warn("failure submitting token to hCaptcha (code={}): {}", response.statusCode(), response);
|
||||
throw new IOException("hCaptcha http failure : " + response.statusCode());
|
||||
}
|
||||
|
||||
final HCaptchaResponse hCaptchaResponse = SystemMapper.jsonMapper()
|
||||
.readValue(response.body(), HCaptchaResponse.class);
|
||||
|
||||
logger.debug("received hCaptcha response: {}", hCaptchaResponse);
|
||||
|
||||
if (!hCaptchaResponse.success) {
|
||||
for (String errorCode : hCaptchaResponse.errorCodes) {
|
||||
Metrics.counter(INVALID_REASON_COUNTER_NAME, Tags.of(
|
||||
Tag.of("action", action.getActionName()),
|
||||
Tag.of("reason", errorCode),
|
||||
UserAgentTagUtil.getPlatformTag(userAgent)
|
||||
)).increment();
|
||||
}
|
||||
return AssessmentResult.invalid();
|
||||
}
|
||||
|
||||
// hcaptcha uses the inverse scheme of recaptcha (for hcaptcha, a low score is less risky)
|
||||
final float score = 1.0f - hCaptchaResponse.score;
|
||||
if (score < 0.0f || score > 1.0f) {
|
||||
logger.error("Invalid score {} from hcaptcha response {}", hCaptchaResponse.score, hCaptchaResponse);
|
||||
return AssessmentResult.invalid();
|
||||
}
|
||||
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", assessmentResult.getScoreString()).increment();
|
||||
}
|
||||
return assessmentResult;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.captcha;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Verify response returned by hcaptcha
|
||||
* <p>
|
||||
* see <a href="https://docs.hcaptcha.com/#verify-the-user-response-server-side">...</a>
|
||||
*/
|
||||
public class HCaptchaResponse {
|
||||
|
||||
@JsonProperty
|
||||
boolean success;
|
||||
|
||||
@JsonProperty(value = "challenge_ts")
|
||||
Instant challengeTs;
|
||||
|
||||
@JsonProperty
|
||||
String hostname;
|
||||
|
||||
@JsonProperty(value = "error-codes")
|
||||
List<String> errorCodes = Collections.emptyList();
|
||||
|
||||
@JsonProperty
|
||||
float score;
|
||||
|
||||
@JsonProperty(value = "score_reason")
|
||||
List<String> scoreReasons = Collections.emptyList();
|
||||
|
||||
public HCaptchaResponse() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "HCaptchaResponse{" +
|
||||
"success=" + success +
|
||||
", challengeTs=" + challengeTs +
|
||||
", hostname='" + hostname + '\'' +
|
||||
", errorCodes=" + errorCodes +
|
||||
", score=" + score +
|
||||
", scoreReasons=" + scoreReasons +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import io.dropwizard.jackson.Discoverable;
|
||||
import org.whispersystems.textsecuregcm.captcha.HCaptchaClient;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", defaultImpl = HCaptchaConfiguration.class)
|
||||
public interface HCaptchaClientFactory extends Discoverable {
|
||||
|
||||
HCaptchaClient build(ScheduledExecutorService retryExecutor, ExecutorService httpExecutor,
|
||||
DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.textsecuregcm.configuration;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import org.whispersystems.textsecuregcm.captcha.HCaptchaClient;
|
||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
@JsonTypeName("default")
|
||||
public class HCaptchaConfiguration implements HCaptchaClientFactory {
|
||||
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
SecretString apiKey;
|
||||
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
@Valid
|
||||
CircuitBreakerConfiguration circuitBreaker = new CircuitBreakerConfiguration();
|
||||
|
||||
@JsonProperty
|
||||
@NotNull
|
||||
@Valid
|
||||
RetryConfiguration retry = new RetryConfiguration();
|
||||
|
||||
|
||||
public SecretString getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public CircuitBreakerConfiguration getCircuitBreaker() {
|
||||
return circuitBreaker;
|
||||
}
|
||||
|
||||
public RetryConfiguration getRetry() {
|
||||
return retry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HCaptchaClient build(
|
||||
final ScheduledExecutorService retryExecutor,
|
||||
final ExecutorService httpExecutor,
|
||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
|
||||
return new HCaptchaClient(
|
||||
apiKey.value(),
|
||||
retryExecutor,
|
||||
httpExecutor,
|
||||
circuitBreaker,
|
||||
retry,
|
||||
dynamicConfigurationManager);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ package org.whispersystems.textsecuregcm.spam;
|
||||
import io.dropwizard.configuration.ConfigurationValidationException;
|
||||
import io.dropwizard.lifecycle.Managed;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import javax.validation.Validator;
|
||||
import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
|
||||
import org.whispersystems.textsecuregcm.captcha.CaptchaClient;
|
||||
import org.whispersystems.textsecuregcm.storage.ReportedMessageListener;
|
||||
|
||||
/**
|
||||
@@ -80,4 +83,6 @@ public interface SpamFilter extends Managed {
|
||||
* @return a {@link RegistrationRecoveryChecker} controlled by the spam filter
|
||||
*/
|
||||
RegistrationRecoveryChecker getRegistrationRecoveryChecker();
|
||||
|
||||
List<CaptchaClient> getCaptchaClients();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user