diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreDynamoDb.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreDynamoDb.java new file mode 100644 index 000000000..338aeba08 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreDynamoDb.java @@ -0,0 +1,106 @@ +/* + * Copyright 2013-2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.storage; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.google.common.annotations.VisibleForTesting; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.Timer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; +import org.whispersystems.textsecuregcm.util.AttributeValues; +import org.whispersystems.textsecuregcm.util.SystemMapper; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; +import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; +import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; +import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +import static com.codahale.metrics.MetricRegistry.name; + +public class VerificationCodeStoreDynamoDb implements VerificationCodeStore { + + private final DynamoDbClient dynamoDbClient; + private final String tableName; + + private final Timer insertTimer; + private final Timer getTimer; + private final Timer removeTimer; + + @VisibleForTesting + static final String KEY_E164 = "P"; + + private static final String ATTR_STORED_CODE = "C"; + private static final String ATTR_TTL = "E"; + + private static final Logger log = LoggerFactory.getLogger(VerificationCodeStoreDynamoDb.class); + + public VerificationCodeStoreDynamoDb(final DynamoDbClient dynamoDbClient, final String tableName) { + this.dynamoDbClient = dynamoDbClient; + this.tableName = tableName; + + this.insertTimer = Metrics.timer(name(getClass(), "insert"), "table", tableName); + this.getTimer = Metrics.timer(name(getClass(), "get"), "table", tableName); + this.removeTimer = Metrics.timer(name(getClass(), "remove"), "table", tableName); + } + + @Override + public void insert(final String number, final StoredVerificationCode verificationCode) { + insertTimer.record(() -> { + try { + dynamoDbClient.putItem(PutItemRequest.builder() + .tableName(tableName) + .item(Map.of( + KEY_E164, AttributeValues.fromString(number), + ATTR_STORED_CODE, AttributeValues.fromString(SystemMapper.getMapper().writeValueAsString(verificationCode)), + ATTR_TTL, AttributeValues.fromLong(getExpirationTimestamp(verificationCode)))) + .build()); + } catch (final JsonProcessingException e) { + // This should never happen when writing directly to a string except in cases of serious misconfiguration, which + // would be caught by tests. + throw new AssertionError(e); + } + }); + } + + private long getExpirationTimestamp(final StoredVerificationCode storedVerificationCode) { + return Instant.ofEpochMilli(storedVerificationCode.getTimestamp()).plus(StoredVerificationCode.EXPIRATION).getEpochSecond(); + } + + @Override + public Optional findForNumber(final String number) { + return getTimer.record(() -> { + final GetItemResponse response = dynamoDbClient.getItem(GetItemRequest.builder() + .tableName(tableName) + .consistentRead(true) + .key(Map.of(KEY_E164, AttributeValues.fromString(number))) + .build()); + + try { + return response.hasItem() + ? Optional.of(SystemMapper.getMapper().readValue(response.item().get(ATTR_STORED_CODE).s(), StoredVerificationCode.class)) + : Optional.empty(); + } catch (final JsonProcessingException e) { + log.error("Failed to parse stored verification code", e); + return Optional.empty(); + } + }); + } + + @Override + public void remove(final String number) { + removeTimer.record(() -> { + dynamoDbClient.deleteItem(DeleteItemRequest.builder() + .tableName(tableName) + .key(Map.of(KEY_E164, AttributeValues.fromString(number))) + .build()); + }); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreDynamoDbTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreDynamoDbTest.java new file mode 100644 index 000000000..82d6d192f --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreDynamoDbTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2013-2021 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.storage; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; +import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; +import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; + +class VerificationCodeStoreDynamoDbTest extends VerificationCodeStoreTest { + + private VerificationCodeStoreDynamoDb verificationCodeStore; + + private static final String TABLE_NAME = "verification_code_test"; + + @RegisterExtension + static final DynamoDbExtension DYNAMO_DB_EXTENSION = DynamoDbExtension.builder() + .tableName(TABLE_NAME) + .hashKey(VerificationCodeStoreDynamoDb.KEY_E164) + .attributeDefinition(AttributeDefinition.builder() + .attributeName(VerificationCodeStoreDynamoDb.KEY_E164) + .attributeType(ScalarAttributeType.S) + .build()) + .build(); + + @BeforeEach + void setUp() { + verificationCodeStore = new VerificationCodeStoreDynamoDb(DYNAMO_DB_EXTENSION.getDynamoDbClient(), TABLE_NAME); + } + + @Override + protected VerificationCodeStore getVerificationCodeStore() { + return verificationCodeStore; + } + + @Override + protected boolean expectNullPushCode() { + return false; + } + + @Override + protected boolean expectEmptyTwilioSid() { + return false; + } +}