001/* 002 * Copyright (c) 2011-2017 Nexmo Inc 003 * 004 * Permission is hereby granted, free of charge, to any person obtaining a copy 005 * of this software and associated documentation files (the "Software"), to deal 006 * in the Software without restriction, including without limitation the rights 007 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 008 * copies of the Software, and to permit persons to whom the Software is 009 * furnished to do so, subject to the following conditions: 010 * 011 * The above copyright notice and this permission notice shall be included in 012 * all copies or substantial portions of the Software. 013 * 014 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 015 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 016 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 017 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 018 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 019 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 020 * THE SOFTWARE. 021 */ 022package com.nexmo.client.sms.callback; 023 024 025import java.io.IOException; 026import java.io.PrintWriter; 027import java.math.BigDecimal; 028import java.text.ParseException; 029import java.text.SimpleDateFormat; 030import java.util.Date; 031import java.util.concurrent.Executor; 032import java.util.concurrent.Executors; 033 034import javax.servlet.ServletException; 035import javax.servlet.http.HttpServlet; 036import javax.servlet.http.HttpServletRequest; 037import javax.servlet.http.HttpServletResponse; 038 039import com.nexmo.client.sms.HexUtil; 040import com.nexmo.client.sms.callback.messages.MO; 041import com.nexmo.client.auth.RequestSigning; 042 043/** 044 * An abstract Servlet that receives and parses an incoming callback request for an MO message. 045 * This class parses and validates the request, optionally checks any provided signature or credentials, 046 * and constructs an MO object for your subclass to consume. 047 * 048 * Note: This servlet will immediately ack the callback as soon as it is validated. Your subclass will 049 * consume the callback object asynchronously. This is because it is important to keep latency of 050 * the acknowledgement to a minimum in order to maintain throughput when operating at any sort of volume. 051 * You are responsible for persisting this object in the event of any failure whilst processing 052 * 053 * @author Paul Cook 054 */ 055public abstract class AbstractMOServlet extends HttpServlet { 056 057 private static final long serialVersionUID = 8745764381059238419L; 058 059 private static final int MAX_CONSUMER_THREADS = 10; 060 061 private static final ThreadLocal<SimpleDateFormat> TIMESTAMP_DATE_FORMAT = new ThreadLocal<SimpleDateFormat>() { 062 @Override 063 protected SimpleDateFormat initialValue() { 064 return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); 065 } 066 }; 067 068 private final boolean validateSignature; 069 private final String signatureSharedSecret; 070 private final boolean validateUsernamePassword; 071 private final String expectedUsername; 072 private final String expectedPassword; 073 074 protected Executor consumer; 075 076 public AbstractMOServlet(final boolean validateSignature, 077 final String signatureSharedSecret, 078 final boolean validateUsernamePassword, 079 final String expectedUsername, 080 final String expectedPassword) { 081 this.validateSignature = validateSignature; 082 this.signatureSharedSecret = signatureSharedSecret; 083 this.validateUsernamePassword = validateUsernamePassword; 084 this.expectedUsername = expectedUsername; 085 this.expectedPassword = expectedPassword; 086 087 this.consumer = Executors.newFixedThreadPool(MAX_CONSUMER_THREADS); 088 } 089 090 @Override 091 protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { 092 handleRequest(request, response); 093 } 094 095 @Override 096 protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { 097 handleRequest(request, response); 098 } 099 100 private void validateRequest(HttpServletRequest request) throws NexmoCallbackRequestValidationException { 101 boolean passed = true; 102 if (this.validateUsernamePassword) { 103 String username = request.getParameter("username"); 104 String password = request.getParameter("password"); 105 if (this.expectedUsername != null) 106 if (username == null || !this.expectedUsername.equals(username)) 107 passed = false; 108 if (this.expectedPassword != null) 109 if (password == null || !this.expectedPassword.equals(password)) 110 passed = false; 111 } 112 113 if (!passed) { 114 throw new NexmoCallbackRequestValidationException("Bad Credentials"); 115 } 116 117 if (this.validateSignature) { 118 if (!RequestSigning.verifyRequestSignature(request, this.signatureSharedSecret)) { 119 throw new NexmoCallbackRequestValidationException("Bad Signature"); 120 } 121 } 122 } 123 124 private void handleRequest(HttpServletRequest request, HttpServletResponse response) throws IOException { 125 response.setContentType("text/plain"); 126 127 try { 128 validateRequest(request); 129 130 String messageId = request.getParameter("messageId"); 131 String sender = request.getParameter("msisdn"); 132 String destination = request.getParameter("to"); 133 if (sender == null || 134 destination == null || 135 messageId == null) { 136 throw new NexmoCallbackRequestValidationException("Missing mandatory fields"); 137 } 138 139 MO.MESSAGE_TYPE messageType = parseMessageType(request.getParameter("type")); 140 141 BigDecimal price = parsePrice(request.getParameter("price")); 142 Date timeStamp = parseTimeStamp(request.getParameter("message-timestamp")); 143 144 MO mo = new MO(messageId, messageType, sender, destination, price, timeStamp); 145 if (messageType == MO.MESSAGE_TYPE.TEXT || messageType == MO.MESSAGE_TYPE.UNICODE) { 146 String messageBody = request.getParameter("text"); 147 if (messageBody == null) { 148 throw new NexmoCallbackRequestValidationException("Missing text field"); 149 } 150 mo.setTextData(messageBody, request.getParameter("keyword")); 151 } else if (messageType == MO.MESSAGE_TYPE.BINARY) { 152 byte[] data = parseBinaryData(request.getParameter("data")); 153 if (data == null) { 154 throw new NexmoCallbackRequestValidationException("Missing data field"); 155 } 156 mo.setBinaryData(data, parseBinaryData(request.getParameter("udh"))); 157 } 158 extractConcatenationData(request, mo); 159 160 // TODO: These are undocumented: 161 mo.setNetworkCode(request.getParameter("network-code")); 162 mo.setSessionId(request.getParameter("sessionId")); 163 164 // Push the task to an async consumption thread 165 ConsumeTask task = new ConsumeTask(this, mo); 166 this.consumer.execute(task); 167 168 // immediately ack the receipt 169 try (PrintWriter out = response.getWriter()) { 170 out.print("OK"); 171 out.flush(); 172 } 173 } catch (NexmoCallbackRequestValidationException exc) { 174 // TODO: Log this - it's mainly for our own use! 175 response.sendError(400, exc.getMessage()); 176 } 177 } 178 179 private static void extractConcatenationData(HttpServletRequest request, MO mo) throws NexmoCallbackRequestValidationException { 180 String concatString = request.getParameter("concat"); 181 if (concatString != null && concatString.equals("true")) { 182 int totalParts; 183 int partNumber; 184 String reference = request.getParameter("concat-ref"); 185 try { 186 totalParts = Integer.parseInt(request.getParameter("concat-total")); 187 partNumber = Integer.parseInt(request.getParameter("concat-part")); 188 } catch (Exception e) { 189 throw new NexmoCallbackRequestValidationException("bad concat fields"); 190 } 191 mo.setConcatenationData(reference, totalParts, partNumber); 192 } 193 } 194 195 private static MO.MESSAGE_TYPE parseMessageType(String str) throws NexmoCallbackRequestValidationException { 196 if (str != null) 197 for (MO.MESSAGE_TYPE type : MO.MESSAGE_TYPE.values()) 198 if (type.getType().equals(str)) 199 return type; 200 throw new NexmoCallbackRequestValidationException("Unrecognized message type: " + str); 201 } 202 203 private static Date parseTimeStamp(String str) throws NexmoCallbackRequestValidationException { 204 if (str != null) { 205 try { 206 return TIMESTAMP_DATE_FORMAT.get().parse(str); 207 } catch (ParseException e) { 208 throw new NexmoCallbackRequestValidationException("Bad message-timestamp format", e); 209 } 210 } 211 return null; 212 } 213 214 private static BigDecimal parsePrice(String str) throws NexmoCallbackRequestValidationException { 215 if (str != null) { 216 try { 217 return new BigDecimal(str); 218 } catch (Exception e) { 219 throw new NexmoCallbackRequestValidationException("Bad price field", e); 220 } 221 } 222 return null; 223 } 224 225 private static byte[] parseBinaryData(String str) { 226 if (str != null) 227 return HexUtil.hexToBytes(str); 228 return null; 229 } 230 231 /** 232 * This is the task that is pushed to the thread pool upon receipt of an incoming MO callback 233 * It detaches the consumption of the MO from the acknowledgement of the incoming http request 234 */ 235 private static final class ConsumeTask implements Runnable, java.io.Serializable { 236 237 private static final long serialVersionUID = -5270583545977374866L; 238 239 private final AbstractMOServlet parent; 240 private final MO mo; 241 242 public ConsumeTask(final AbstractMOServlet parent, 243 final MO mo) { 244 this.parent = parent; 245 this.mo = mo; 246 } 247 248 @Override 249 public void run() { 250 this.parent.consume(this.mo); 251 } 252 } 253 254 /** 255 * This method is asynchronously passed a complete MO instance to be dealt with by your application logic 256 * 257 * @param mo The message object that was provided in the HTTP request. 258 */ 259 public abstract void consume(MO mo); 260 261}