mirror of
https://github.com/signalapp/Signal-Server
synced 2026-04-19 00:13:31 +01:00
Instrument /v2/svr/auth/check credential age
This commit is contained in:
committed by
ravi-signal
parent
0357b25f92
commit
fb1c20582e
@@ -192,17 +192,9 @@ public class ExternalServiceCredentialsGenerator {
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an instance of {@link ExternalServiceCredentials} object and the max allowed age for those credentials,
|
||||
* checks if credentials are valid and not expired.
|
||||
* @param credentials an instance of {@link ExternalServiceCredentials}
|
||||
* @param maxAgeSeconds age in seconds
|
||||
* @return An optional with a timestamp (seconds) of when the credentials were generated,
|
||||
* or an empty optional if the password doesn't match the username for any reason (including malformed data)
|
||||
*/
|
||||
public Optional<Long> validateAndGetTimestamp(final ExternalServiceCredentials credentials, final long maxAgeSeconds) {
|
||||
return validateAndGetTimestamp(credentials)
|
||||
.filter(ts -> currentTimeSeconds() - ts <= maxAgeSeconds);
|
||||
@VisibleForTesting
|
||||
boolean isCredentialExpired(final long credentialTimestamp, final long maxAgeSeconds) {
|
||||
return currentTimeSeconds() - credentialTimestamp > maxAgeSeconds;
|
||||
}
|
||||
|
||||
private boolean shouldDeriveUsername() {
|
||||
|
||||
@@ -15,12 +15,27 @@ public class ExternalServiceCredentialsSelector {
|
||||
|
||||
private ExternalServiceCredentialsSelector() {}
|
||||
|
||||
public record CredentialInfo(String token, boolean valid, ExternalServiceCredentials credentials, long timestamp) {
|
||||
public enum CredentialStatus {
|
||||
// The token passes verification, is not expired, and is the most recent token available
|
||||
VALID,
|
||||
// The token was malformed or the signature was invalid
|
||||
FAILED_VERIFICATION,
|
||||
// The credential was correctly signed, but exceeded the maximum credential age
|
||||
EXPIRED,
|
||||
// There was a more recent credential available with the same username
|
||||
REPLACED
|
||||
}
|
||||
|
||||
public record CredentialInfo(String token, ExternalServiceCredentials credentials, long timestamp, CredentialStatus status) {
|
||||
/**
|
||||
* @return a copy of this record with valid=false
|
||||
* @return a copy of this record that indicates it has been replaced with a more up-to-date token
|
||||
*/
|
||||
private CredentialInfo invalidate() {
|
||||
return new CredentialInfo(token, false, credentials, timestamp);
|
||||
private CredentialInfo replaced() {
|
||||
return new CredentialInfo(token, credentials, timestamp, CredentialStatus.REPLACED);
|
||||
}
|
||||
|
||||
public boolean valid() {
|
||||
return status == CredentialStatus.VALID;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +62,18 @@ public class ExternalServiceCredentialsSelector {
|
||||
// (note that password part may also contain ':' characters)
|
||||
final String[] parts = token.split(":", 2);
|
||||
if (parts.length != 2) {
|
||||
results.add(new CredentialInfo(token, false, null, 0L));
|
||||
results.add(new CredentialInfo(token, null, 0L, CredentialStatus.FAILED_VERIFICATION));
|
||||
continue;
|
||||
}
|
||||
final ExternalServiceCredentials credentials = new ExternalServiceCredentials(parts[0], parts[1]);
|
||||
final Optional<Long> maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials, maxAgeSeconds);
|
||||
final Optional<Long> maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials);
|
||||
if (maybeTimestamp.isEmpty()) {
|
||||
results.add(new CredentialInfo(token, false, null, 0L));
|
||||
results.add(new CredentialInfo(token, credentials, 0L, CredentialStatus.FAILED_VERIFICATION));
|
||||
continue;
|
||||
}
|
||||
final long credentialTs = maybeTimestamp.get();
|
||||
if (credentialsGenerator.isCredentialExpired(credentialTs, maxAgeSeconds)) {
|
||||
results.add(new CredentialInfo(token, credentials, credentialTs, CredentialStatus.EXPIRED));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -62,17 +82,17 @@ public class ExternalServiceCredentialsSelector {
|
||||
final long timestamp = maybeTimestamp.get();
|
||||
final CredentialInfo best = bestForUsername.get(credentials.username());
|
||||
if (best == null) {
|
||||
bestForUsername.put(credentials.username(), new CredentialInfo(token, true, credentials, timestamp));
|
||||
bestForUsername.put(credentials.username(), new CredentialInfo(token, credentials, timestamp, CredentialStatus.VALID));
|
||||
continue;
|
||||
}
|
||||
if (best.timestamp() < timestamp) {
|
||||
// we found a better credential for the username
|
||||
bestForUsername.put(credentials.username(), new CredentialInfo(token, true, credentials, timestamp));
|
||||
bestForUsername.put(credentials.username(), new CredentialInfo(token, credentials, timestamp, CredentialStatus.VALID));
|
||||
// mark the previous best as an invalid credential, since we have a better credential now
|
||||
results.add(best.invalidate());
|
||||
results.add(best.replaced());
|
||||
} else {
|
||||
// the credential we already had was more recent, this one can be marked invalid
|
||||
results.add(new CredentialInfo(token, false, null, 0L));
|
||||
results.add(new CredentialInfo(token, null, 0L, CredentialStatus.REPLACED));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,23 +7,29 @@ package org.whispersystems.textsecuregcm.controllers;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import io.dropwizard.auth.Auth;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.Metrics;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.HeaderParam;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;
|
||||
@@ -34,13 +40,18 @@ import org.whispersystems.textsecuregcm.entities.AuthCheckRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.AuthCheckResponseV2;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimitedByIp;
|
||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||
import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;
|
||||
import org.whispersystems.textsecuregcm.storage.Account;
|
||||
import org.whispersystems.textsecuregcm.storage.AccountsManager;
|
||||
|
||||
@Path("/v2/{name: backup|svr}")
|
||||
@Tag(name = "Secure Value Recovery")
|
||||
@io.swagger.v3.oas.annotations.tags.Tag(name = "Secure Value Recovery")
|
||||
@Schema(description = "Note: /v2/backup is deprecated. Use /v2/svr instead.")
|
||||
public class SecureValueRecovery2Controller {
|
||||
private static final String CREDENTIAL_AGE_DISTRIBUTION_NAME =
|
||||
MetricsUtil.name(SecureValueRecovery2Controller.class, "credentialAge");
|
||||
|
||||
|
||||
public static final Duration MAX_AGE = Duration.ofDays(30);
|
||||
|
||||
@@ -102,7 +113,9 @@ public class SecureValueRecovery2Controller {
|
||||
@ApiResponse(responseCode = "200", description = "`JSON` with the check results.", useReturnTypeSchema = true)
|
||||
@ApiResponse(responseCode = "422", description = "Provided list of SVR2 credentials could not be parsed")
|
||||
@ApiResponse(responseCode = "400", description = "`POST` request body is not a valid `JSON`")
|
||||
public AuthCheckResponseV2 authCheck(@NotNull @Valid final AuthCheckRequest request) {
|
||||
public AuthCheckResponseV2 authCheck(
|
||||
@HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,
|
||||
@NotNull @Valid final AuthCheckRequest request) {
|
||||
final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(
|
||||
request.tokens(),
|
||||
backupServiceCredentialGenerator,
|
||||
@@ -115,6 +128,22 @@ public class SecureValueRecovery2Controller {
|
||||
.map(backupServiceCredentialGenerator::generateForUuid)
|
||||
.map(ExternalServiceCredentials::username);
|
||||
|
||||
// Instrument how expired or not the best credential is
|
||||
credentials.stream()
|
||||
.filter(info -> switch (info.status()) {
|
||||
case VALID, EXPIRED -> true;
|
||||
default -> false;
|
||||
})
|
||||
// Look only at credentials that match the current account for the e164
|
||||
.filter(info -> matchingUsername.filter(info.credentials().username()::equals).isPresent())
|
||||
// Instrument the matching credential with the most recent timestamp
|
||||
.max(Comparator.comparing(ExternalServiceCredentialsSelector.CredentialInfo::timestamp))
|
||||
.ifPresent(info -> DistributionSummary.builder(CREDENTIAL_AGE_DISTRIBUTION_NAME)
|
||||
.baseUnit("days")
|
||||
.tags(Tags.of(UserAgentTagUtil.getPlatformTag(userAgent), Tag.of("valid", Boolean.toString(info.valid()))))
|
||||
.register(Metrics.globalRegistry)
|
||||
.record(Duration.between(Instant.ofEpochMilli(info.timestamp()), Instant.now()).toDays()));
|
||||
|
||||
return new AuthCheckResponseV2(credentials.stream().collect(Collectors.toMap(
|
||||
ExternalServiceCredentialsSelector.CredentialInfo::token,
|
||||
info -> {
|
||||
|
||||
Reference in New Issue
Block a user