Add support for Twilio voice verification.

This commit is contained in:
Moxie Marlinspike 2013-12-09 17:50:25 -08:00
parent 4ad0dad3d9
commit c194ce153d
7 changed files with 78 additions and 40 deletions

View File

@ -2,6 +2,7 @@ twilio:
accountId: accountId:
accountToken: accountToken:
number: number:
localDomain: # The domain Twilio can call back to.
# Optional. If specified, Nexmo will be used for non-US SMS and # Optional. If specified, Nexmo will be used for non-US SMS and
# voice verification. # voice verification.

View File

@ -9,7 +9,7 @@
<groupId>org.whispersystems.textsecure</groupId> <groupId>org.whispersystems.textsecure</groupId>
<artifactId>TextSecureServer</artifactId> <artifactId>TextSecureServer</artifactId>
<version>0.1</version> <version>0.2</version>
<dependencies> <dependencies>
<dependency> <dependency>
@ -91,6 +91,11 @@
<artifactId>dropwizard-testing</artifactId> <artifactId>dropwizard-testing</artifactId>
<version>0.6.2</version> <version>0.6.2</version>
</dependency> </dependency>
<dependency>
<groupId>com.twilio.sdk</groupId>
<artifactId>twilio-java-sdk</artifactId>
<version>3.4.1</version>
</dependency>
<dependency> <dependency>
<groupId>postgresql</groupId> <groupId>postgresql</groupId>

View File

@ -41,7 +41,7 @@ public class WhisperServerConfiguration extends Configuration {
private TwilioConfiguration twilio; private TwilioConfiguration twilio;
@JsonProperty @JsonProperty
private NexmoConfiguration nexmo = new NexmoConfiguration(); private NexmoConfiguration nexmo;
@NotNull @NotNull
@JsonProperty @JsonProperty

View File

@ -33,6 +33,10 @@ public class TwilioConfiguration {
@JsonProperty @JsonProperty
private String number; private String number;
@NotEmpty
@JsonProperty
private String localDomain;
public String getAccountId() { public String getAccountId() {
return accountId; return accountId;
} }
@ -44,4 +48,8 @@ public class TwilioConfiguration {
public String getNumber() { public String getNumber() {
return number; return number;
} }
public String getLocalDomain() {
return localDomain;
}
} }

View File

@ -29,6 +29,7 @@ import org.whispersystems.textsecuregcm.entities.ApnRegistrationId;
import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; import org.whispersystems.textsecuregcm.entities.GcmRegistrationId;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.sms.SenderFactory; import org.whispersystems.textsecuregcm.sms.SenderFactory;
import org.whispersystems.textsecuregcm.sms.TwilioSmsSender;
import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.Account;
import org.whispersystems.textsecuregcm.storage.AccountsManager; import org.whispersystems.textsecuregcm.storage.AccountsManager;
import org.whispersystems.textsecuregcm.storage.PendingAccountsManager; import org.whispersystems.textsecuregcm.storage.PendingAccountsManager;
@ -40,9 +41,11 @@ import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE; import javax.ws.rs.DELETE;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam; import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
import javax.ws.rs.PUT; import javax.ws.rs.PUT;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.PathParam; import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException; import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
@ -71,7 +74,6 @@ public class AccountController {
this.senderFactory = smsSenderFactory; this.senderFactory = smsSenderFactory;
} }
@Timed @Timed
@GET @GET
@Path("/{transport}/code/{number}") @Path("/{transport}/code/{number}")
@ -182,6 +184,16 @@ public class AccountController {
accounts.update(account); accounts.update(account);
} }
@Timed
@POST
@Path("/voice/twiml/{code}")
@Produces(MediaType.APPLICATION_XML)
public Response getTwiml(@PathParam("code") String encodedVerificationText) {
return Response.ok().entity(String.format(TwilioSmsSender.SAY_TWIML,
SenderFactory.VoxSender.VERIFICATION_TEXT +
encodedVerificationText)).build();
}
private VerificationCode generateVerificationCode() { private VerificationCode generateVerificationCode() {
try { try {
SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); SecureRandom random = SecureRandom.getInstance("SHA1PRNG");

View File

@ -49,9 +49,9 @@ public class SenderFactory {
public VoxSender getVoxSender(String number) { public VoxSender getVoxSender(String number) {
if (nexmoSender.isPresent()) { if (nexmoSender.isPresent()) {
return nexmoSender.get(); return nexmoSender.get();
} else {
return twilioSender;
} }
throw new AssertionError("FIX ME!");
} }
private boolean isTwilioDestination(String number) { private boolean isTwilioDestination(String number) {

View File

@ -16,66 +16,78 @@
*/ */
package org.whispersystems.textsecuregcm.sms; package org.whispersystems.textsecuregcm.sms;
import com.sun.jersey.core.util.Base64; import com.twilio.sdk.TwilioRestClient;
import com.twilio.sdk.TwilioRestException;
import com.twilio.sdk.resource.factory.CallFactory;
import com.twilio.sdk.resource.factory.MessageFactory;
import com.yammer.metrics.Metrics; import com.yammer.metrics.Metrics;
import com.yammer.metrics.core.Meter; import com.yammer.metrics.core.Meter;
import org.apache.http.NameValuePair;
import org.apache.http.message.BasicNameValuePair;
import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration; import org.whispersystems.textsecuregcm.configuration.TwilioConfiguration;
import org.whispersystems.textsecuregcm.util.Util;
import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.URL;
import java.net.URLConnection;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
public class TwilioSmsSender implements SenderFactory.SmsSender { public class TwilioSmsSender implements SenderFactory.SmsSender, SenderFactory.VoxSender {
public static final String SAY_TWIML = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<Response>\n" +
" <Say voice=\"woman\" language=\"en\">%s</Say>\n" +
"</Response>";
private final Meter smsMeter = Metrics.newMeter(TwilioSmsSender.class, "sms", "delivered", TimeUnit.MINUTES); private final Meter smsMeter = Metrics.newMeter(TwilioSmsSender.class, "sms", "delivered", TimeUnit.MINUTES);
private final Meter voxMeter = Metrics.newMeter(TwilioSmsSender.class, "vox", "delivered", TimeUnit.MINUTES);
private static final String TWILIO_URL = "https://api.twilio.com/2010-04-01/Accounts/%s/SMS/Messages";
private final String accountId; private final String accountId;
private final String accountToken; private final String accountToken;
private final String number; private final String number;
private final String localDomain;
public TwilioSmsSender(TwilioConfiguration config) { public TwilioSmsSender(TwilioConfiguration config) {
this.accountId = config.getAccountId(); this.accountId = config.getAccountId();
this.accountToken = config.getAccountToken(); this.accountToken = config.getAccountToken();
this.number = config.getNumber(); this.number = config.getNumber();
this.localDomain = config.getLocalDomain();
} }
@Override @Override
public void deliverSmsVerification(String destination, String verificationCode) throws IOException { public void deliverSmsVerification(String destination, String verificationCode)
URL url = new URL(String.format(TWILIO_URL, accountId)); throws IOException
URLConnection connection = url.openConnection(); {
connection.setDoOutput(true); try {
connection.setRequestProperty("Authorization", getTwilioAuthorizationHeader()); TwilioRestClient client = new TwilioRestClient(accountId, accountToken);
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); MessageFactory messageFactory = client.getAccount().getMessageFactory();
List<NameValuePair> messageParams = new LinkedList<>();
Map<String, String> formData = new HashMap<>(); messageParams.add(new BasicNameValuePair("To", destination));
formData.put("From", number); messageParams.add(new BasicNameValuePair("From", number));
formData.put("To", destination); messageParams.add(new BasicNameValuePair("Body", SenderFactory.SmsSender.VERIFICATION_TEXT + verificationCode));
formData.put("Body", VERIFICATION_TEXT + verificationCode); messageFactory.create(messageParams);
} catch (TwilioRestException e) {
OutputStreamWriter writer = new OutputStreamWriter(connection.getOutputStream()); throw new IOException(e);
writer.write(Util.encodeFormParams(formData)); }
writer.flush();
BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
while (reader.readLine() != null) {}
writer.close();
reader.close();
smsMeter.mark(); smsMeter.mark();
} }
private String getTwilioAuthorizationHeader() { @Override
String encoded = new String(Base64.encode(String.format("%s:%s", accountId, accountToken))); public void deliverVoxVerification(String destination, String verificationCode) throws IOException {
return "Basic " + encoded.replace("\n", ""); try {
} TwilioRestClient client = new TwilioRestClient(accountId, accountToken);
CallFactory callFactory = client.getAccount().getCallFactory();
Map<String, String> callParams = new HashMap<>();
callParams.put("To", destination);
callParams.put("From", number);
callParams.put("Url", "https://" + localDomain + "/v1/accounts/voice/twiml/" + verificationCode);
callFactory.create(callParams);
} catch (TwilioRestException e) {
throw new IOException(e);
}
voxMeter.mark();
}
} }