diff --git a/pom.xml b/pom.xml index f96a8fc7c287851c11d065e3072b12e62c1433ea..ec25d664a9d8331134c05faca6ce5b9f51ba10d6 100644 --- a/pom.xml +++ b/pom.xml @@ -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> diff --git a/src/main/java/de/gultsch/xmpp/addr/adapter/Adapter.java b/src/main/java/de/gultsch/xmpp/addr/adapter/Adapter.java index 52a52eb600a9b330037a2f7a79b95911bc9b8c9f..ed6eb323a29a17d6722e2253f839ec9b6763d47c 100644 --- a/src/main/java/de/gultsch/xmpp/addr/adapter/Adapter.java +++ b/src/main/java/de/gultsch/xmpp/addr/adapter/Adapter.java @@ -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); diff --git a/src/main/java/im/quicksy/server/configuration/Configuration.java b/src/main/java/im/quicksy/server/configuration/Configuration.java index 0cab9aaa3ca1b126183ed5cb34e68cc19a1bad3b..d33bdf6338a1452726713bf6c510de50b8a40ede 100644 --- a/src/main/java/im/quicksy/server/configuration/Configuration.java +++ b/src/main/java/im/quicksy/server/configuration/Configuration.java @@ -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); } diff --git a/src/main/java/im/quicksy/server/controller/BaseController.java b/src/main/java/im/quicksy/server/controller/BaseController.java index 104bf4d547f481a61362a70f304dab99cfaa7394..d037d27dfdc0e70b273fcd31fe1b287e308eb4f6 100644 --- a/src/main/java/im/quicksy/server/controller/BaseController.java +++ b/src/main/java/im/quicksy/server/controller/BaseController.java @@ -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()); diff --git a/src/main/java/im/quicksy/server/controller/EnterController.java b/src/main/java/im/quicksy/server/controller/EnterController.java index 6fb3ad835383cdf04439c65ac8d3bf1daa9b5d1b..5a56f588f82fc9592f8bca0702734ef24cd032a9 100644 --- a/src/main/java/im/quicksy/server/controller/EnterController.java +++ b/src/main/java/im/quicksy/server/controller/EnterController.java @@ -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); diff --git a/src/main/java/im/quicksy/server/controller/PasswordController.java b/src/main/java/im/quicksy/server/controller/PasswordController.java index 2f6cb64c0ec32b19df303ec48757aa5948cd1ef0..99dee69c742ff7b05ff9d564ed548ca0da8baee0 100644 --- a/src/main/java/im/quicksy/server/controller/PasswordController.java +++ b/src/main/java/im/quicksy/server/controller/PasswordController.java @@ -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); diff --git a/src/main/java/im/quicksy/server/verification/NexmoVerificationProvider.java b/src/main/java/im/quicksy/server/verification/NexmoVerificationProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..e8df3fe5f643f6db7eb4e42ddc835d938514bb99 --- /dev/null +++ b/src/main/java/im/quicksy/server/verification/NexmoVerificationProvider.java @@ -0,0 +1,143 @@ +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 { + + } +} diff --git a/src/main/java/im/quicksy/server/verification/TokenExpiredException.java b/src/main/java/im/quicksy/server/verification/TokenExpiredException.java new file mode 100644 index 0000000000000000000000000000000000000000..af29a5f8d5dbc5faa285b932bd1f5a47e67ff442 --- /dev/null +++ b/src/main/java/im/quicksy/server/verification/TokenExpiredException.java @@ -0,0 +1,15 @@ +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); + } +} diff --git a/src/main/java/im/quicksy/server/verification/TwilioVerificationProvider.java b/src/main/java/im/quicksy/server/verification/TwilioVerificationProvider.java index e389c13cd99554df1a2ee5c72646f1d1c1fbf7c6..d8ad2f91244aad3fc0f71be066756ed089c1bbdf 100644 --- a/src/main/java/im/quicksy/server/verification/TwilioVerificationProvider.java +++ b/src/main/java/im/quicksy/server/verification/TwilioVerificationProvider.java @@ -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]; diff --git a/src/main/java/im/quicksy/server/verification/VerificationProvider.java b/src/main/java/im/quicksy/server/verification/VerificationProvider.java index 75cd8aeeffb0064d3a08724bd112d8554d5cc56b..1b06f772aee27a78e028b510ffdb16ab71be9741 100644 --- a/src/main/java/im/quicksy/server/verification/VerificationProvider.java +++ b/src/main/java/im/quicksy/server/verification/VerificationProvider.java @@ -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; diff --git a/src/main/java/im/quicksy/server/verification/nexmo/ErrorResponse.java b/src/main/java/im/quicksy/server/verification/nexmo/ErrorResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..3a47be4fb910ad1d2604cd6c77f82dafac6c2ce5 --- /dev/null +++ b/src/main/java/im/quicksy/server/verification/nexmo/ErrorResponse.java @@ -0,0 +1,9 @@ +package im.quicksy.server.verification.nexmo; + +public class ErrorResponse { + + private String type; + private String title; + private String detail; + +} diff --git a/src/main/java/im/quicksy/server/verification/nexmo/GenericResponse.java b/src/main/java/im/quicksy/server/verification/nexmo/GenericResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..df0f235e90c585e96c80983b4dbec0ccd52b002a --- /dev/null +++ b/src/main/java/im/quicksy/server/verification/nexmo/GenericResponse.java @@ -0,0 +1,40 @@ +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; + } + } +} diff --git a/src/test/java/im/quicksy/server/NexmoVerificationProviderTest.java b/src/test/java/im/quicksy/server/NexmoVerificationProviderTest.java new file mode 100644 index 0000000000000000000000000000000000000000..d258f44c2a981181413068a6cc229857e6d18b63 --- /dev/null +++ b/src/test/java/im/quicksy/server/NexmoVerificationProviderTest.java @@ -0,0 +1,23 @@ +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"); + } +}