Commit e3946202 authored by Daniel Gultsch's avatar Daniel Gultsch
Browse files

support nexmo as SMS provider

parent a40ee70b
......@@ -62,6 +62,18 @@
<version>0.8.2</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.9.0</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>logging-interceptor</artifactId>
<version>4.9.0</version>
</dependency>
<dependency>
<groupId>de.gultsch.ejabberd</groupId>
<artifactId>ejabberd-api</artifactId>
......
......@@ -3,7 +3,6 @@ package de.gultsch.xmpp.addr.adapter;
import com.google.gson.GsonBuilder;
import de.gultsch.xmpp.addr.adapter.gson.JidDeserializer;
import de.gultsch.xmpp.addr.adapter.gson.JidSerializer;
import de.gultsch.xmpp.addr.adapter.sql2o.IllegalJidStrategy;
import de.gultsch.xmpp.addr.adapter.sql2o.JidConverter;
import org.sql2o.converters.Converter;
import rocks.xmpp.addr.Jid;
......@@ -17,12 +16,9 @@ public class Adapter {
gsonBuilder.registerTypeAdapter(Jid.class, new JidSerializer());
}
public static void register(Map<Class, Converter> converters) {
register(converters, IllegalJidStrategy.THROW);
}
public static void register(Map<Class, Converter> converters, IllegalJidStrategy illegalJidStrategy) {
final JidConverter jidConverter = new JidConverter(illegalJidStrategy);
public static void register(Map<Class, Converter> converters) {
final JidConverter jidConverter = new JidConverter();
converters.put(Jid.class, jidConverter);
try {
converters.put(Class.forName("rocks.xmpp.addr.FullJid"), jidConverter);
......
......@@ -44,6 +44,9 @@ public class Configuration {
private HashMap<String, DatabaseConfiguration> db;
private PayPal payPal = new PayPal();
private String twilioAuthToken;
private String nexmoApiKey;
private String nexmoApiSecret;
private String cimAuthToken;
private Version minVersion;
private Duration accountInactivity = Duration.ofDays(28);
......@@ -134,6 +137,14 @@ public class Configuration {
return twilioAuthToken;
}
public String getNexmoApiKey() {
return nexmoApiKey;
}
public String getNexmoApiSecret() {
return nexmoApiSecret;
}
public Optional<String> getCimAuthToken() {
return Optional.ofNullable(cimAuthToken);
}
......
......@@ -21,6 +21,7 @@ import com.github.zafarkhaja.semver.Version;
import com.google.common.base.Splitter;
import com.google.common.net.InetAddresses;
import im.quicksy.server.configuration.Configuration;
import im.quicksy.server.verification.NexmoVerificationProvider;
import im.quicksy.server.verification.TwilioVerificationProvider;
import im.quicksy.server.verification.VerificationProvider;
import org.slf4j.Logger;
......@@ -51,7 +52,7 @@ public class BaseController {
protected static Pattern PIN_PATTERN = Pattern.compile("^[0-9]{6}$");
protected static Pattern UUID_PATTERN = Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
protected static final VerificationProvider VERIFICATION_PROVIDER = new TwilioVerificationProvider();
protected static final VerificationProvider VERIFICATION_PROVIDER = new NexmoVerificationProvider();
protected static InetAddress getClientIp(Request request) {
final InetAddress remote = InetAddresses.forString(request.ip());
......
......@@ -31,6 +31,7 @@ import im.quicksy.server.pojo.Voucher;
import im.quicksy.server.utils.CimUtils;
import im.quicksy.server.utils.CodeGenerator;
import im.quicksy.server.utils.PayPal;
import im.quicksy.server.verification.NexmoVerificationProvider;
import im.quicksy.server.verification.TwilioVerificationProvider;
import im.quicksy.server.verification.VerificationProvider;
import org.slf4j.Logger;
......@@ -44,6 +45,8 @@ public class EnterController extends BaseController {
private static final Logger LOGGER = LoggerFactory.getLogger(EnterController.class);
private static final VerificationProvider VERIFICATION_PROVIDER = new TwilioVerificationProvider();
public static TemplateViewRoute intro = (request, response) -> {
HashMap<String, Object> model = new HashMap<>();
model.put("fee", Payment.FEE);
......
......@@ -28,6 +28,7 @@ import im.quicksy.server.ejabberd.MyEjabberdApi;
import im.quicksy.server.throttle.RateLimiter;
import im.quicksy.server.throttle.Strategy;
import im.quicksy.server.verification.RequestFailedException;
import im.quicksy.server.verification.TokenExpiredException;
import im.quicksy.server.verification.TwilioVerificationProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
......@@ -90,7 +91,7 @@ public class PasswordController extends BaseController {
if (MyEjabberdApi.getInstance().checkAccount(jid.getEscapedLocal(), jid.getDomain())) {
final Last last = MyEjabberdApi.getInstance().getLast(jid.getEscapedLocal(), jid.getDomain());
final Duration lastActivity = Duration.between(last.getTimestamp(), Instant.now());
LOGGER.info("user "+jid.getEscapedLocal()+" was last active "+lastActivity+" ago.");
LOGGER.info("user " + jid.getEscapedLocal() + " was last active " + lastActivity + " ago.");
if (Configuration.getInstance().getAccountInactivity().minus(lastActivity).isNegative()) {
LOGGER.info("delete old and create new user " + jid);
MyEjabberdApi.getInstance().unregister(jid.getEscapedLocal(), jid.getDomain());
......@@ -110,13 +111,12 @@ public class PasswordController extends BaseController {
System.out.println("verification provider reported failed");
return halt(401);
}
} catch (TokenExpiredException e) {
LOGGER.warn("Contacting verification provider failed with: " + e.getMessage());
return halt(404);
} catch (RequestFailedException e) {
if (e.getCode() == TwilioVerificationProvider.PHONE_VERIFICATION_NOT_FOUND) {
return halt(404);
} else {
LOGGER.warn("Contacting verification provider failed with: " + e.getMessage());
return halt(500);
}
LOGGER.warn("Contacting verification provider failed with: " + e.getMessage());
return halt(500);
} catch (de.gultsch.ejabberd.api.RequestFailedException e) {
LOGGER.warn("Contacting ejabberd failed with: " + e.getMessage());
return halt(500);
......
package im.quicksy.server.verification;
import com.google.common.base.Strings;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.math.IntMath;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.i18n.phonenumbers.Phonenumber;
import im.quicksy.server.configuration.Configuration;
import im.quicksy.server.verification.nexmo.GenericResponse;
import okhttp3.*;
import okhttp3.logging.HttpLoggingInterceptor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.List;
public class NexmoVerificationProvider implements VerificationProvider {
private static final Logger LOGGER = LoggerFactory.getLogger(NexmoVerificationProvider.class);
private static final OkHttpClient OK_HTTP_CLIENT = new OkHttpClient.Builder()
//.addInterceptor(new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
.build();
private static final Gson GSON = new GsonBuilder().create();
private static final HttpUrl NEXMO_API_URL = HttpUrl.get("https://rest.nexmo.com/sms/json");
private static final String BRAND_NAME = "Quicksy.im";
private static final String MESSAGE = "Your Quicksy code is: %s\n\nDon't share this code with others.\n\nOYITl6r6eIp";
private static final int MAX_ATTEMPTS = 3;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final Cache<Phonenumber.PhoneNumber, Pin> PIN_CACHE = CacheBuilder.newBuilder()
.expireAfterWrite(Duration.ofMinutes(5))
.build();
@Override
public boolean verify(Phonenumber.PhoneNumber phoneNumber, String input) throws RequestFailedException {
final Pin pin = PIN_CACHE.getIfPresent(phoneNumber);
if (pin == null) {
throw new TokenExpiredException("No pin found for this phone number");
}
try {
return pin.verify(input);
} catch (TooManyAttemptsException e) {
throw new TokenExpiredException(e);
}
}
@Override
public void request(Phonenumber.PhoneNumber phoneNumber, Method method) throws RequestFailedException {
final Pin pin = Pin.generate();
PIN_CACHE.put(phoneNumber, pin);
System.out.println("pin: " + pin);
final String to = String.format("%d%d", phoneNumber.getCountryCode(), phoneNumber.getNationalNumber());
LOGGER.info("requesting SMS through nexmo for {}", to);
final Call call = OK_HTTP_CLIENT.newCall(new Request.Builder()
.post(new FormBody.Builder()
.add("from", BRAND_NAME)
.add("text", String.format(MESSAGE, pin.toString()))
.add("to", to)
.add("api_key", Configuration.getInstance().getNexmoApiKey())
.add("api_secret", Configuration.getInstance().getNexmoApiSecret())
.build())
.url(NEXMO_API_URL)
.build());
try {
final Response response = call.execute();
final int code = response.code();
if (code != 200) {
LOGGER.warn("failed to request SMS verification. error code was {}", code);
throw new RequestFailedException("Response code was " + code);
} else {
final ResponseBody body = response.body();
if (body == null) {
throw new RequestFailedException("Empty body");
}
final GenericResponse nexmoResponse = GSON.fromJson(body.charStream(), GenericResponse.class);
final List<GenericResponse.Message> messages = nexmoResponse.getMessages();
if (messages.size() >= 1) {
final GenericResponse.Message message = messages.get(0);
final String status = message.getStatus();
if (!"0".equals(status)) {
LOGGER.error("Unable to requests SMS. Status={} text={}",message.getStatus(), message.getErrorText());
throw new RequestFailedException(message.getErrorText());
}
} else {
throw new RequestFailedException("Invalid number of result messages");
}
}
LOGGER.info("call was successful");
} catch (IOException e) {
LOGGER.warn("failed to request SMS verification", e);
throw new RequestFailedException(e);
}
}
@Override
public void request(Phonenumber.PhoneNumber phoneNumber, Method method, String language) throws RequestFailedException {
request(phoneNumber, method);
}
public static class Pin {
private final String pin;
private int attempts = 0;
Pin(String pin) {
this.pin = pin;
}
public static Pin generate() {
final int pin = SECURE_RANDOM.nextInt(IntMath.pow(10, VerificationProvider.VERIFICATION_CODE_LENGTH));
return new Pin(Strings.padStart(
String.valueOf(pin),
VerificationProvider.VERIFICATION_CODE_LENGTH,
'0'
));
}
public synchronized boolean verify(String pin) {
if (this.attempts >= MAX_ATTEMPTS) {
throw new TooManyAttemptsException();
}
this.attempts++;
return this.pin.equals(pin);
}
@Override
public String toString() {
return this.pin;
}
}
public static class TooManyAttemptsException extends RuntimeException {
}
}
package im.quicksy.server.verification;
public class TokenExpiredException extends RequestFailedException {
public TokenExpiredException(String message, int code) {
super(message, code);
}
public TokenExpiredException(String message) {
super(message,0);
}
public TokenExpiredException(Exception e) {
super(e);
}
}
......@@ -141,8 +141,12 @@ public class TwilioVerificationProvider implements VerificationProvider {
return gson.fromJson(result, clazz);
} else {
LOGGER.debug("json was " + result);
ErrorResponse error = gson.fromJson(result, ErrorResponse.class);
throw new RequestFailedException(error.getMessage(), error.getErrorCode());
final ErrorResponse error = gson.fromJson(result, ErrorResponse.class);
if (error.getErrorCode() == PHONE_VERIFICATION_NOT_FOUND) {
throw new TokenExpiredException(error.getMessage(), error.getErrorCode());
} else {
throw new RequestFailedException(error.getMessage(), error.getErrorCode());
}
}
} catch (JsonSyntaxException e) {
final String firstLine = result == null ? "" : result.split("\n")[0];
......
......@@ -20,6 +20,8 @@ import com.google.i18n.phonenumbers.Phonenumber;
public interface VerificationProvider {
int VERIFICATION_CODE_LENGTH = 6;
boolean verify(Phonenumber.PhoneNumber phoneNumber, String pin) throws RequestFailedException;
void request(Phonenumber.PhoneNumber phoneNumber, Method method) throws RequestFailedException;
......
package im.quicksy.server.verification.nexmo;
public class ErrorResponse {
private String type;
private String title;
private String detail;
}
package im.quicksy.server.verification.nexmo;
import com.google.gson.annotations.SerializedName;
import java.util.List;
public class GenericResponse {
@SerializedName("message_count")
private int messageCount;
private List<Message> messages;
public int getMessageCount() {
return messageCount;
}
public List<Message> getMessages() {
return messages;
}
public static class Message {
private String to;
private String status;
@SerializedName("error-text")
private String errorText;
public String getTo() {
return to;
}
public String getStatus() {
return status;
}
public String getErrorText() {
return errorText;
}
}
}
package im.quicksy.server;
import im.quicksy.server.verification.NexmoVerificationProvider;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
public class NexmoVerificationProviderTest {
@Rule
public final ExpectedException expectedException = ExpectedException.none();
@Test
public void pinExpiry() {
NexmoVerificationProvider.Pin pin = NexmoVerificationProvider.Pin.generate();
System.out.println(pin);
pin.verify("000000");
pin.verify("000000");
pin.verify("000000");
expectedException.expect(NexmoVerificationProvider.TooManyAttemptsException.class);
pin.verify("000000");
}
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment