Support for localized voice verification
This commit is contained in:
parent
c2f2146872
commit
0c3dc3dea2
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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 + "/";
|
||||
}
|
||||
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 + "");
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in New Issue