Support for localized voice verification

This commit is contained in:
Moxie Marlinspike 2018-12-07 09:57:02 -08:00
parent c2f2146872
commit 0c3dc3dea2
11 changed files with 316 additions and 11 deletions

View File

@ -131,9 +131,15 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private UnidentifiedDeliveryConfiguration unidentifiedDelivery;
@Valid
@NotNull
@JsonProperty
private Map<String, Object> hystrix = new HashMap<>();
private VoiceVerificationConfiguration voiceVerification;
public VoiceVerificationConfiguration getVoiceVerificationConfiguration() {
return voiceVerification;
}
public WebSocketConfiguration getWebSocketConfiguration() {
return webSocket;

View File

@ -38,6 +38,7 @@ import org.whispersystems.textsecuregcm.controllers.KeysController;
import org.whispersystems.textsecuregcm.controllers.MessageController;
import org.whispersystems.textsecuregcm.controllers.ProfileController;
import org.whispersystems.textsecuregcm.controllers.ProvisioningController;
import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.liquibase.NameableMigrationsBundle;
import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;
@ -213,6 +214,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
environment.jersey().register(new DirectoryController(rateLimiters, directory, directoryCredentialsGenerator));
environment.jersey().register(new ProvisioningController(rateLimiters, pushSender));
environment.jersey().register(new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().getCertificate(), config.getDeliveryCertificate().getPrivateKey(), config.getDeliveryCertificate().getExpiresDays())));
environment.jersey().register(new VoiceVerificationController(config.getVoiceVerificationConfiguration().getUrl(), config.getVoiceVerificationConfiguration().getLocales()));
environment.jersey().register(attachmentController);
environment.jersey().register(keysController);
environment.jersey().register(messageController);

View File

@ -0,0 +1,32 @@
package org.whispersystems.textsecuregcm.configuration;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.hibernate.validator.constraints.NotEmpty;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class VoiceVerificationConfiguration {
@JsonProperty
@Valid
@NotEmpty
private String url;
@JsonProperty
@Valid
@NotNull
private List<String> locales;
public String getUrl() {
return url;
}
public Set<String> getLocales() {
return new HashSet<>(locales);
}
}

View File

@ -113,6 +113,7 @@ public class AccountController {
public Response createAccount(@PathParam("transport") String transport,
@PathParam("number") String number,
@HeaderParam("X-Forwarded-For") String requester,
@HeaderParam("Accept-Language") Optional<String> locale,
@QueryParam("client") Optional<String> client)
throws IOException, RateLimitExceededException
{
@ -151,7 +152,7 @@ public class AccountController {
} else if (transport.equals("sms")) {
smsSender.deliverSmsVerification(number, client, verificationCode.getVerificationCodeDisplay());
} else if (transport.equals("voice")) {
smsSender.deliverVoxVerification(number, verificationCode.getVerificationCodeSpeech());
smsSender.deliverVoxVerification(number, verificationCode.getVerificationCode(), locale);
}
return Response.ok().build();
@ -337,8 +338,7 @@ public class AccountController {
@Path("/voice/twiml/{code}")
@Produces(MediaType.APPLICATION_XML)
public Response getTwiml(@PathParam("code") String encodedVerificationText) {
return Response.ok().entity(String.format(TwilioSmsSender.SAY_TWIML,
encodedVerificationText)).build();
return Response.ok().entity(String.format(TwilioSmsSender.SAY_TWIML, encodedVerificationText)).build();
}
private void createAccount(String number, String password, String userAgent, AccountAttributes accountAttributes) {

View File

@ -0,0 +1,108 @@
package org.whispersystems.textsecuregcm.controllers;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.Optional;
import java.util.Set;
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
@Path("/v1/voice/")
public class VoiceVerificationController {
private static final String PLAY_TWIML = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<Response>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Pause length=\"1\"/>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Pause length=\"1\"/>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
" <Play>%s</Play>\n" +
"</Response>";
private final String baseUrl;
private final Set<String> supportedLocales;
public VoiceVerificationController(String baseUrl, Set<String> supportedLocales) {
this.baseUrl = baseUrl;
this.supportedLocales = supportedLocales;
}
@POST
@Path("/description/{code}")
@Produces(MediaType.APPLICATION_XML)
public Response getDescription(@PathParam("code") String code, @QueryParam("l") String locale) {
code = code.replaceAll("[^0-9]", "");
if (code.length() != 6) {
return Response.status(400).build();
}
if (locale != null && supportedLocales.contains(locale)) {
return getLocalizedDescription(code, locale);
}
return getLocalizedDescription(code, "en-US");
}
private Response getLocalizedDescription(String code, String locale) {
String path = constructUrlForLocale(baseUrl, locale);
return Response.ok()
.entity(String.format(PLAY_TWIML,
path + "verification.mp3",
path + code.charAt(0) + "_middle.mp3",
path + code.charAt(1) + "_middle.mp3",
path + code.charAt(2) + "_middle.mp3",
path + code.charAt(3) + "_middle.mp3",
path + code.charAt(4) + "_middle.mp3",
path + code.charAt(5) + "_falling.mp3",
path + "verification.mp3",
path + code.charAt(0) + "_middle.mp3",
path + code.charAt(1) + "_middle.mp3",
path + code.charAt(2) + "_middle.mp3",
path + code.charAt(3) + "_middle.mp3",
path + code.charAt(4) + "_middle.mp3",
path + code.charAt(5) + "_falling.mp3",
path + "verification.mp3",
path + code.charAt(0) + "_middle.mp3",
path + code.charAt(1) + "_middle.mp3",
path + code.charAt(2) + "_middle.mp3",
path + code.charAt(3) + "_middle.mp3",
path + code.charAt(4) + "_middle.mp3",
path + code.charAt(5) + "_falling.mp3"))
.build();
}
private String constructUrlForLocale(String baseUrl, String locale) {
if (!baseUrl.endsWith("/")) {
baseUrl += "/";
}
return baseUrl + locale + "/";
}
}

View File

@ -55,11 +55,11 @@ public class SmsSender {
}
}
public void deliverVoxVerification(String destination, String verificationCode)
public void deliverVoxVerification(String destination, String verificationCode, Optional<String> locale)
throws IOException
{
try {
twilioSender.deliverVoxVerification(destination, verificationCode);
twilioSender.deliverVoxVerification(destination, verificationCode, locale);
} catch (TwilioRestException e) {
logger.info("Twilio Vox Failed: " + e.getErrorMessage());
}

View File

@ -97,15 +97,21 @@ public class TwilioSmsSender {
smsMeter.mark();
}
public void deliverVoxVerification(String destination, String verificationCode)
public void deliverVoxVerification(String destination, String verificationCode, Optional<String> locale)
throws IOException, TwilioRestException
{
String url = "https://" + localDomain + "/v1/voice/description/" + verificationCode;
if (locale.isPresent()) {
url += "?l=" + locale.get();
}
TwilioRestClient client = new TwilioRestClient(accountId, accountToken);
CallFactory callFactory = client.getAccount().getCallFactory();
Map<String, String> callParams = new HashMap<>();
callParams.put("To", destination);
callParams.put("From", getRandom(random, numbers));
callParams.put("Url", "https://" + localDomain + "/v1/accounts/voice/twiml/" + verificationCode);
callParams.put("Url", url);
try {
callFactory.create(callParams);

View File

@ -35,9 +35,12 @@ public class VerificationCode {
@VisibleForTesting VerificationCode() {}
public VerificationCode(int verificationCode) {
this.verificationCode = verificationCode + "";
this.verificationCodeDisplay = this.verificationCode.substring(0, 3) + "-" +
this.verificationCode.substring(3, 6);
this(verificationCode + "");
}
public VerificationCode(String verificationCode) {
this.verificationCode = verificationCode;
this.verificationCodeDisplay = this.verificationCode.substring(0, 3) + "-" + this.verificationCode.substring(3, 6);
this.verificationCodeSpeech = delimit(verificationCode + "");
}

View File

@ -0,0 +1,96 @@
package org.whispersystems.textsecuregcm.tests.controllers;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.Rule;
import org.junit.Test;
import org.whispersystems.textsecuregcm.controllers.VoiceVerificationController;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.SystemMapper;
import javax.ws.rs.core.Response;
import java.util.Collections;
import io.dropwizard.auth.AuthValueFactoryProvider;
import io.dropwizard.testing.FixtureHelpers;
import io.dropwizard.testing.junit.ResourceTestRule;
import static org.assertj.core.api.Assertions.assertThat;
public class VoiceVerificationControllerTest {
@Rule
public final ResourceTestRule resources = ResourceTestRule.builder()
.addProvider(AuthHelper.getAuthFilter())
.addProvider(new AuthValueFactoryProvider.Binder<>(Account.class))
.addProvider(new RateLimitExceededExceptionMapper())
.setMapper(SystemMapper.getMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new VoiceVerificationController("https://foo.com/bar",
Collections.singleton("pt-BR")))
.build();
@Test
public void testTwimlLocale() {
Response response =
resources.getJerseyTest()
.target("/v1/voice/description/123456")
.queryParam("l", "pt-BR")
.request()
.post(null);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(String.class)).isXmlEqualTo(FixtureHelpers.fixture("fixtures/voice_verification_pt_br.xml"));
}
@Test
public void testTwimlUnsupportedLocale() {
Response response =
resources.getJerseyTest()
.target("/v1/voice/description/123456")
.queryParam("l", "es-MX")
.request()
.post(null);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(String.class)).isXmlEqualTo(FixtureHelpers.fixture("fixtures/voice_verification_en_us.xml"));
}
@Test
public void testTwimlMissingLocale() {
Response response =
resources.getJerseyTest()
.target("/v1/voice/description/123456")
.request()
.post(null);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(String.class)).isXmlEqualTo(FixtureHelpers.fixture("fixtures/voice_verification_en_us.xml"));
}
@Test
public void testTwimlMalformedCode() {
Response response =
resources.getJerseyTest()
.target("/v1/voice/description/1234...56")
.request()
.post(null);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.readEntity(String.class)).isXmlEqualTo(FixtureHelpers.fixture("fixtures/voice_verification_en_us.xml"));
}
@Test
public void testTwimlBadCodeLength() {
Response response =
resources.getJerseyTest()
.target("/v1/voice/description/1234567")
.request()
.post(null);
assertThat(response.getStatus()).isEqualTo(400);
}
}

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Play>https://foo.com/bar/en-US/verification.mp3</Play>
<Play>https://foo.com/bar/en-US/1_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/2_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/3_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/4_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/5_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/6_falling.mp3</Play>
<Pause length="1"/>
<Play>https://foo.com/bar/en-US/verification.mp3</Play>
<Play>https://foo.com/bar/en-US/1_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/2_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/3_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/4_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/5_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/6_falling.mp3</Play>
<Pause length="1"/>
<Play>https://foo.com/bar/en-US/verification.mp3</Play>
<Play>https://foo.com/bar/en-US/1_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/2_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/3_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/4_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/5_middle.mp3</Play>
<Play>https://foo.com/bar/en-US/6_falling.mp3</Play>
</Response>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Play>https://foo.com/bar/pt-BR/verification.mp3</Play>
<Play>https://foo.com/bar/pt-BR/1_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/2_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/3_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/4_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/5_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/6_falling.mp3</Play>
<Pause length="1"/>
<Play>https://foo.com/bar/pt-BR/verification.mp3</Play>
<Play>https://foo.com/bar/pt-BR/1_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/2_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/3_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/4_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/5_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/6_falling.mp3</Play>
<Pause length="1"/>
<Play>https://foo.com/bar/pt-BR/verification.mp3</Play>
<Play>https://foo.com/bar/pt-BR/1_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/2_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/3_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/4_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/5_middle.mp3</Play>
<Play>https://foo.com/bar/pt-BR/6_falling.mp3</Play>
</Response>