/*
* Password Management Servlets (PWM)
* http://www.pwm-project.org
*
* Copyright (c) 2006-2009 Novell, Inc.
* Copyright (c) 2009-2017 The PWM Project
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package password.pwm.util.queue;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.entity.StringEntity;
import org.apache.http.util.EntityUtils;
import password.pwm.AppProperty;
import password.pwm.PwmApplication;
import password.pwm.bean.SmsItemBean;
import password.pwm.config.Configuration;
import password.pwm.config.PwmSetting;
import password.pwm.error.ErrorInformation;
import password.pwm.error.PwmError;
import password.pwm.error.PwmException;
import password.pwm.error.PwmOperationalException;
import password.pwm.error.PwmUnrecoverableException;
import password.pwm.health.HealthMessage;
import password.pwm.health.HealthRecord;
import password.pwm.http.HttpHeader;
import password.pwm.http.client.PwmHttpClient;
import password.pwm.svc.PwmService;
import password.pwm.svc.stats.Statistic;
import password.pwm.svc.stats.StatisticsManager;
import password.pwm.util.BasicAuthInfo;
import password.pwm.util.PasswordData;
import password.pwm.util.java.TimeDuration;
import password.pwm.util.localdb.WorkQueueProcessor;
import password.pwm.util.java.JsonUtil;
import password.pwm.util.java.StringUtil;
import password.pwm.util.localdb.LocalDB;
import password.pwm.util.localdb.LocalDBStoredQueue;
import password.pwm.util.logging.PwmLogger;
import password.pwm.util.secure.PwmRandom;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @author Menno Pieters, Jason D. Rivard
*/
public class SmsQueueManager implements PwmService {
private static final PwmLogger LOGGER = PwmLogger.forClass(SmsQueueManager.class);
// ------------------------------ FIELDS ------------------------------
public enum SmsNumberFormat {
PLAIN,
PLUS,
ZEROS
}
public enum SmsDataEncoding {
NONE,
URL,
XML,
HTML,
CSV,
JAVA,
JAVASCRIPT,
SQL
}
public static final String TOKEN_USER = "%USER%";
public static final String TOKEN_SENDERID = "%SENDERID%";
public static final String TOKEN_MESSAGE = "%MESSAGE%";
public static final String TOKEN_TO = "%TO%";
public static final String TOKEN_PASS = "%PASS%";
public static final String TOKEN_REQUESTID = "%REQUESTID%";
private SmsSendEngine smsSendEngine;
private WorkQueueProcessor<SmsItemBean> workQueueProcessor;
private PwmApplication pwmApplication;
private STATUS status = STATUS.NEW;
private ErrorInformation lastError;
public SmsQueueManager() {
}
public void init(
final PwmApplication pwmApplication
)
throws PwmException
{
status = STATUS.OPENING;
this.pwmApplication = pwmApplication;
if (pwmApplication.getLocalDB() == null || pwmApplication.getLocalDB().status() != LocalDB.Status.OPEN) {
LOGGER.warn("localdb is not open, will remain closed");
status = STATUS.CLOSED;
return;
}
final WorkQueueProcessor.Settings settings = new WorkQueueProcessor.Settings();
settings.setMaxEvents(Integer.parseInt(pwmApplication.getConfig().readAppProperty(AppProperty.QUEUE_SMS_MAX_COUNT)));
settings.setRetryDiscardAge(new TimeDuration(Long.parseLong(pwmApplication.getConfig().readAppProperty(AppProperty.QUEUE_SMS_MAX_AGE_MS))));
settings.setRetryInterval(new TimeDuration(Long.parseLong(pwmApplication.getConfig().readAppProperty(AppProperty.QUEUE_SMS_RETRY_TIMEOUT_MS))));
final LocalDBStoredQueue localDBStoredQueue = LocalDBStoredQueue.createLocalDBStoredQueue(pwmApplication, pwmApplication.getLocalDB(), LocalDB.DB.SMS_QUEUE);
workQueueProcessor = new WorkQueueProcessor<>(pwmApplication, localDBStoredQueue, settings, new SmsItemProcessor(), this.getClass());
smsSendEngine = new SmsSendEngine(pwmApplication.getConfig());
status = STATUS.OPEN;
}
private class SmsItemProcessor implements WorkQueueProcessor.ItemProcessor<SmsItemBean> {
@Override
public WorkQueueProcessor.ProcessResult process(final SmsItemBean workItem) {
try {
for (final String msgPart : splitMessage(workItem.getMessage())) {
smsSendEngine.sendSms(workItem.getTo(), msgPart);
}
StatisticsManager.incrementStat(pwmApplication, Statistic.SMS_SEND_SUCCESSES);
lastError = null;
} catch (PwmUnrecoverableException e) {
StatisticsManager.incrementStat(pwmApplication, Statistic.SMS_SEND_DISCARDS);
StatisticsManager.incrementStat(pwmApplication, Statistic.SMS_SEND_FAILURES);
LOGGER.error("discarding sms message due to permanent failure: " + e.getErrorInformation().toDebugStr());
lastError = e.getErrorInformation();
return WorkQueueProcessor.ProcessResult.FAILED;
} catch (PwmOperationalException e) {
StatisticsManager.incrementStat(pwmApplication, Statistic.SMS_SEND_FAILURES);
lastError = e.getErrorInformation();
return WorkQueueProcessor.ProcessResult.RETRY;
}
return WorkQueueProcessor.ProcessResult.SUCCESS;
}
@Override
public String convertToDebugString(final SmsItemBean workItem) {
final Map<String,Object> debugOutputMap = new LinkedHashMap<>();
debugOutputMap.put("to", workItem.getTo());
return JsonUtil.serializeMap(debugOutputMap);
}
}
public void addSmsToQueue(final SmsItemBean smsItem)
throws PwmUnrecoverableException
{
final SmsItemBean shortenedBean = shortenMessageIfNeeded(smsItem);
if (!determineIfItemCanBeDelivered(shortenedBean)) {
return;
}
try {
workQueueProcessor.submit(shortenedBean);
} catch (Exception e) {
LOGGER.error("error writing to LocalDB queue, discarding sms send request: " + e.getMessage());
}
}
SmsItemBean shortenMessageIfNeeded(final SmsItemBean smsItem) throws PwmUnrecoverableException {
final Boolean shorten = pwmApplication.getConfig().readSettingAsBoolean(PwmSetting.SMS_USE_URL_SHORTENER);
if (shorten) {
final String message = smsItem.getMessage();
final String shortenedMessage = pwmApplication.getUrlShortener().shortenUrlInText(message);
return new SmsItemBean(smsItem.getTo(), shortenedMessage);
}
return smsItem;
}
public static boolean smsIsConfigured(final Configuration config) {
final String gatewayUrl = config.readSettingAsString(PwmSetting.SMS_GATEWAY_URL);
final String gatewayUser = config.readSettingAsString(PwmSetting.SMS_GATEWAY_USER);
final PasswordData gatewayPass = config.readSettingAsPassword(PwmSetting.SMS_GATEWAY_PASSWORD);
if (gatewayUrl == null || gatewayUrl.length() < 1) {
LOGGER.debug("SMS gateway url is not configured");
return false;
}
if (gatewayUser != null && gatewayUser.length() > 0 && (gatewayPass == null)) {
LOGGER.debug("SMS gateway user configured, but no password provided");
return false;
}
return true;
}
boolean determineIfItemCanBeDelivered(final SmsItemBean smsItem) {
final Configuration config = pwmApplication.getConfig();
if (!smsIsConfigured(config)) {
return false;
}
if (smsItem.getTo() == null || smsItem.getTo().length() < 1) {
LOGGER.debug("discarding sms send event (no to address) " + smsItem.toString());
return false;
}
if (smsItem.getMessage() == null || smsItem.getMessage().length() < 1) {
LOGGER.debug("discarding sms send event (no message) " + smsItem.toString());
return false;
}
return true;
}
@Override
public STATUS status() {
return status;
}
@Override
public void close() {
if (workQueueProcessor != null) {
workQueueProcessor.close();
}
workQueueProcessor = null;
status = STATUS.CLOSED;
}
@Override
public List<HealthRecord> healthCheck() {
if (lastError != null) {
return Collections.singletonList(HealthRecord.forMessage(HealthMessage.SMS_SendFailure, lastError.toDebugStr()));
}
return null;
}
@Override
public ServiceInfo serviceInfo() {
return null;
}
private List<String> splitMessage(final String input) {
final int size = (int)pwmApplication.getConfig().readSettingAsLong(PwmSetting.SMS_MAX_TEXT_LENGTH);
final List<String> returnObj = new ArrayList<>((input.length() + size - 1) / size);
for (int start = 0; start < input.length(); start += size) {
returnObj.add(input.substring(start, Math.min(input.length(), start + size)));
}
return returnObj;
}
protected static String smsDataEncode(final String data, final SmsDataEncoding encoding) {
final String returnData;
switch (encoding) {
case NONE:
returnData = data;
break;
case CSV:
returnData = StringUtil.escapeCsv(data);
break;
case HTML:
returnData = StringUtil.escapeHtml(data);
break;
case JAVA:
returnData = StringUtil.escapeJava(data);
break;
case JAVASCRIPT:
returnData = StringUtil.escapeJS(data);
break;
case XML:
returnData = StringUtil.escapeXml(data);
break;
default:
returnData = data == null ? "" : StringUtil.urlEncode(data);
break;
}
return returnData;
}
private static void determineIfResultSuccessful(
final Configuration config,
final int resultCode,
final String resultBody
)
throws PwmOperationalException
{
final List<String> resultCodeTests = config.readSettingAsStringArray(PwmSetting.SMS_SUCCESS_RESULT_CODE);
if (resultCodeTests != null && !resultCodeTests.isEmpty()) {
final String resultCodeStr = String.valueOf(resultCode);
if (!resultCodeTests.contains(resultCodeStr)) {
throw new PwmOperationalException(new ErrorInformation(
PwmError.ERROR_SMS_SEND_ERROR,
"response result code " + resultCode + " is not a configured successful result code"
));
}
}
final List<String> regexBodyTests = config.readSettingAsStringArray(PwmSetting.SMS_RESPONSE_OK_REGEX);
if (regexBodyTests == null || regexBodyTests.isEmpty()) {
return;
}
if (resultBody == null || resultBody.isEmpty()) {
throw new PwmOperationalException(new ErrorInformation(
PwmError.ERROR_SMS_SEND_ERROR,
"result has no body but there are configured regex response matches, so send not considered successful"
));
}
for (final String regex : regexBodyTests) {
final Pattern p = Pattern.compile(regex, Pattern.DOTALL);
final Matcher m = p.matcher(resultBody);
if (m.matches()) {
LOGGER.trace("result body matched configured regex match setting: " + regex);
return;
}
}
throw new PwmOperationalException(new ErrorInformation(
PwmError.ERROR_SMS_SEND_ERROR,
"result body did not matching any configured regex match settings"
));
}
static String formatSmsNumber(final Configuration config, final String smsNumber) {
final long ccLong = config.readSettingAsLong(PwmSetting.SMS_DEFAULT_COUNTRY_CODE);
String countryCodeNumber = "";
if (ccLong > 0) {
countryCodeNumber = String.valueOf(ccLong);
}
final SmsNumberFormat format = config.readSettingAsEnum(PwmSetting.SMS_PHONE_NUMBER_FORMAT,SmsNumberFormat.class);
String returnValue = smsNumber;
// Remove (0)
returnValue = returnValue.replaceAll("\\(0\\)","");
// Remove leading double zero, replace by plus
if (returnValue.startsWith("00")) {
returnValue = "+" + returnValue.substring(2, returnValue.length());
}
// Replace leading zero by country code
if (returnValue.startsWith("0")) {
returnValue = countryCodeNumber + returnValue.substring(1, returnValue.length());
}
// Add a leading plus if necessary
if (!returnValue.startsWith("+")) {
returnValue = "+" + returnValue;
}
// Remove any non-numeric, non-plus characters
returnValue = returnValue.replaceAll("[^0-9\\+]","");
// Now the number should be in full international format
// Let's see if we need to change anything:
switch(format) {
case PLAIN:
// remove plus
returnValue = returnValue.replaceAll("^\\+","");
// add country code
returnValue = countryCodeNumber + returnValue;
break;
case PLUS:
// keep full international format
break;
case ZEROS:
// replace + with 00
returnValue = "00" + returnValue.substring(1);
break;
default:
// keep full international format
break;
}
return returnValue;
}
private static class SmsSendEngine {
private static final PwmLogger LOGGER = PwmLogger.forClass(SmsSendEngine.class);
private final Configuration config;
private String lastResponseBody;
private SmsSendEngine(final Configuration configuration)
{
this.config = configuration;
}
/**
*
* @param to
* @param message
* @throws PwmUnrecoverableException - If operation failed and a retry is unlikely to succeed
* @throws PwmOperationalException - If operation failed and should be retried.
*/
protected void sendSms(final String to, final String message)
throws PwmUnrecoverableException, PwmOperationalException
{
lastResponseBody = null;
final String gatewayUser = config.readSettingAsString(PwmSetting.SMS_GATEWAY_USER);
final PasswordData gatewayPass = config.readSettingAsPassword(PwmSetting.SMS_GATEWAY_PASSWORD);
final String contentType = config.readSettingAsString(PwmSetting.SMS_REQUEST_CONTENT_TYPE);
final SmsDataEncoding encoding = SmsDataEncoding.valueOf(config.readSettingAsString(PwmSetting.SMS_REQUEST_CONTENT_ENCODING));
final List<String> extraHeaders = config.readSettingAsStringArray(PwmSetting.SMS_GATEWAY_REQUEST_HEADERS);
String requestData = config.readSettingAsString(PwmSetting.SMS_REQUEST_DATA);
// Replace strings in requestData
{
final String senderId = config.readSettingAsString(PwmSetting.SMS_SENDER_ID);
requestData = requestData.replace(TOKEN_USER, smsDataEncode(gatewayUser, encoding));
requestData = requestData.replace(TOKEN_SENDERID, smsDataEncode(senderId, encoding));
requestData = requestData.replace(TOKEN_MESSAGE, smsDataEncode(message, encoding));
requestData = requestData.replace(TOKEN_TO, smsDataEncode(formatSmsNumber(config, to), encoding));
}
try {
final String gatewayStrPass = gatewayPass == null ? null : gatewayPass.getStringValue();
requestData = requestData.replace(TOKEN_PASS, smsDataEncode(gatewayStrPass, encoding));
} catch (PwmUnrecoverableException e) {
LOGGER.error("unable to read sms password while reading configuration");
}
if (requestData.contains(TOKEN_REQUESTID)) {
final String chars = config.readSettingAsString(PwmSetting.SMS_REQUESTID_CHARS);
final int idLength = new Long(config.readSettingAsLong(PwmSetting.SMS_REQUESTID_LENGTH)).intValue();
final String requestId = PwmRandom.getInstance().alphaNumericString(chars, idLength);
requestData = requestData.replaceAll(TOKEN_REQUESTID, smsDataEncode(requestId, encoding));
}
final String gatewayUrl = config.readSettingAsString(PwmSetting.SMS_GATEWAY_URL);
final String gatewayMethod = config.readSettingAsString(PwmSetting.SMS_GATEWAY_METHOD);
final String gatewayAuthMethod = config.readSettingAsString(PwmSetting.SMS_GATEWAY_AUTHMETHOD);
LOGGER.trace("preparing to send SMS data: " + requestData);
try {
final HttpRequestBase httpRequest;
if ("POST".equalsIgnoreCase(gatewayMethod)) {
// POST request
httpRequest = new HttpPost(gatewayUrl);
if (contentType != null && contentType.length()>0) {
httpRequest.setHeader("Content-Type", contentType);
}
((HttpPost) httpRequest).setEntity(new StringEntity(requestData));
} else {
// GET request
final String fullUrl = gatewayUrl.endsWith("?") ? gatewayUrl + requestData : gatewayUrl + "?" + requestData;
httpRequest = new HttpGet(fullUrl);
}
if (extraHeaders != null) {
final Pattern pattern = Pattern.compile("^([A-Za-z0-9_\\.-]+):[ \t]*([^ \t].*)");
for (final String header : extraHeaders) {
final Matcher matcher = pattern.matcher(header);
if (matcher.matches()) {
final String hname = matcher.group(1);
final String hvalue = matcher.group(2);
LOGGER.debug("Adding HTTP header \"" + hname + "\" with value \"" + hvalue + "\"");
httpRequest.addHeader(hname, hvalue);
} else {
LOGGER.warn("Cannot parse HTTP header: " + header);
}
}
}
if ("HTTP".equalsIgnoreCase(gatewayAuthMethod) && gatewayUser != null && gatewayPass != null) {
LOGGER.debug("Using Basic Authentication");
final BasicAuthInfo ba = new BasicAuthInfo(gatewayUser, gatewayPass);
httpRequest.addHeader(HttpHeader.Authorization.getHttpName(), ba.toAuthHeader());
}
final HttpClient httpClient = PwmHttpClient.getHttpClient(config);
final HttpResponse httpResponse = httpClient.execute(httpRequest);
final String responseBody = EntityUtils.toString(httpResponse.getEntity());
final int resultCode = httpResponse.getStatusLine().getStatusCode();
lastResponseBody = httpResponse.getStatusLine() + "\n" + responseBody;
LOGGER.trace("sms send result body: " + httpResponse.getStatusLine().toString() + "\n" + responseBody);
determineIfResultSuccessful(config, resultCode, responseBody);
LOGGER.debug("SMS send successful, HTTP status: " + httpResponse.getStatusLine().getStatusCode());
} catch (IOException e) {
final ErrorInformation errorInformation = new ErrorInformation(
PwmError.ERROR_SMS_SEND_ERROR,
"IO error while sending SMS: " + e.getMessage());
throw new PwmOperationalException(errorInformation);
} catch (PwmOperationalException e) {
throw e;
} catch (Exception e) {
final ErrorInformation errorInformation = new ErrorInformation(
PwmError.ERROR_SMS_SEND_ERROR,
"unexpected error while sending SMS, discarding message: " + e.getMessage());
throw new PwmUnrecoverableException(errorInformation);
}
}
public String getLastResponseBody()
{
return lastResponseBody;
}
}
public static String sendDirectMessage(
final Configuration configuration,
final SmsItemBean smsItemBean
)
throws PwmUnrecoverableException, PwmOperationalException
{
final SmsSendEngine smsSendEngine = new SmsSendEngine(configuration);
smsSendEngine.sendSms(smsItemBean.getTo(), smsItemBean.getMessage());
return smsSendEngine.getLastResponseBody();
}
public int queueSize() {
return workQueueProcessor.queueSize();
}
public Instant eldestItem() {
return workQueueProcessor.eldestItem();
}
}