001/* 002 * Copyright 2017 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.plugins.zimbra; 017 018import java.io.IOException; 019import java.io.StringReader; 020import java.io.UnsupportedEncodingException; 021import java.net.SocketTimeoutException; 022import java.net.UnknownHostException; 023import java.nio.charset.StandardCharsets; 024import java.security.InvalidKeyException; 025import java.security.NoSuchAlgorithmException; 026import java.time.Duration; 027import java.time.Instant; 028import java.time.ZonedDateTime; 029import java.util.ArrayList; 030import java.util.Collections; 031import java.util.Date; 032import java.util.List; 033import java.util.Map; 034import java.util.TreeMap; 035import java.util.stream.Collectors; 036 037import javax.crypto.Mac; 038import javax.crypto.spec.SecretKeySpec; 039 040import org.apache.avalon.framework.activity.Disposable; 041import org.apache.avalon.framework.service.ServiceException; 042import org.apache.avalon.framework.service.ServiceManager; 043import org.apache.cocoon.ProcessingException; 044import org.apache.cocoon.environment.Redirector; 045import org.apache.commons.codec.binary.Hex; 046import org.apache.commons.lang3.StringUtils; 047import org.apache.excalibur.xml.dom.DOMParser; 048import org.apache.excalibur.xml.xpath.PrefixResolver; 049import org.apache.excalibur.xml.xpath.XPathProcessor; 050import org.apache.http.Header; 051import org.apache.http.HeaderElement; 052import org.apache.http.HeaderElementIterator; 053import org.apache.http.HttpResponse; 054import org.apache.http.NameValuePair; 055import org.apache.http.client.config.RequestConfig; 056import org.apache.http.client.methods.CloseableHttpResponse; 057import org.apache.http.client.methods.HttpGet; 058import org.apache.http.client.methods.HttpPost; 059import org.apache.http.client.protocol.HttpClientContext; 060import org.apache.http.client.utils.URLEncodedUtils; 061import org.apache.http.conn.ConnectionKeepAliveStrategy; 062import org.apache.http.conn.ConnectionPoolTimeoutException; 063import org.apache.http.cookie.Cookie; 064import org.apache.http.entity.StringEntity; 065import org.apache.http.impl.client.CloseableHttpClient; 066import org.apache.http.impl.client.HttpClients; 067import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; 068import org.apache.http.message.BasicHeaderElementIterator; 069import org.apache.http.message.BasicNameValuePair; 070import org.apache.http.protocol.HTTP; 071import org.apache.http.protocol.HttpContext; 072import org.apache.http.util.EntityUtils; 073import org.w3c.dom.Document; 074import org.w3c.dom.Element; 075import org.w3c.dom.Node; 076import org.w3c.dom.NodeList; 077import org.xml.sax.InputSource; 078import org.xml.sax.SAXException; 079 080import org.ametys.core.cache.AbstractCacheManager; 081import org.ametys.core.cache.Cache; 082import org.ametys.core.user.User; 083import org.ametys.core.user.UserIdentity; 084import org.ametys.core.user.UserManager; 085import org.ametys.core.util.JSONUtils; 086import org.ametys.plugins.messagingconnector.AbstractMessagingConnector; 087import org.ametys.plugins.messagingconnector.CalendarEvent; 088import org.ametys.plugins.messagingconnector.EmailMessage; 089import org.ametys.plugins.messagingconnector.MessagingConnectorException; 090import org.ametys.runtime.config.Config; 091import org.ametys.runtime.i18n.I18nizableText; 092 093/** 094 * The connector used by the messaging connector plugin when the zimbra mail 095 * server is used. Implements the methods of the MessagingConnector interface in 096 * order to get the informations from the mail server 097 */ 098public class ZimbraConnector extends AbstractMessagingConnector implements Disposable 099{ 100 /** token cache id */ 101 public static final String TOKEN_CACHE = ZimbraConnector.class.getName() + "$token"; 102 103 /** The number of seconds after what kept alive connections are dropt */ 104 protected static final int _DROP_KEPTALIVE_CONNECTION_AFTER = 5; 105 106 private static final ZimbraPrefixResolver __PREFIX_RESOLVER = new ZimbraPrefixResolver(); 107 108 /** The user manager */ 109 protected UserManager _usersManager; 110 111 /** The JSON Utils */ 112 protected JSONUtils _jsonUtils; 113 114 /** Url to zimbra */ 115 protected String _zimbraUrl; 116 117 /** Preauth secret key */ 118 protected String _domainPreauthSecretKey; 119 120 /** Request to the remote app will be ppoled for perfs purposes */ 121 protected PoolingHttpClientConnectionManager _connectionManager; 122 123 /** The keep-alive stragegy to optimize http clients */ 124 protected ConnectionKeepAliveStrategy _connectionKeepAliveStrategy; 125 126 /** The shared configuration for request (for timeout purposes) */ 127 protected RequestConfig _connectionConfig; 128 129 private XPathProcessor _xPathProcessor; 130 private DOMParser _domParser; 131 132 @Override 133 public void service(ServiceManager smanager) throws ServiceException 134 { 135 super.service(smanager); 136 _usersManager = (UserManager) smanager.lookup(UserManager.ROLE); 137 _jsonUtils = (JSONUtils) smanager.lookup(JSONUtils.ROLE); 138 _cacheManager = (AbstractCacheManager) smanager.lookup(AbstractCacheManager.ROLE); 139 _xPathProcessor = (XPathProcessor) smanager.lookup(XPathProcessor.ROLE); 140 _domParser = (DOMParser) smanager.lookup(DOMParser.ROLE); 141 } 142 143 @Override 144 public void initialize() 145 { 146 super.initialize(); 147 _zimbraUrl = StringUtils.removeEnd(Config.getInstance().getValue("zimbra.config.zimbra.baseUrl"), "/"); 148 _domainPreauthSecretKey = Config.getInstance().getValue("zimbra.config.preauth.key"); 149 150 int maxSimultaneousConnections = (int) (long) Config.getInstance().getValue("zimbra.config.maxconnections"); 151 // Same value for 3 timeouts (socket, connection and response) so one connection can last 3 times this value 152 int connectionTimeout = (int) Math.max(0, (long) Config.getInstance().getValue("zimbra.config.timeout")); 153 154 // A pooling connection manager to avoid flooding remote server by reusing existing connections AND by limiting their number 155 _connectionManager = new PoolingHttpClientConnectionManager(); 156 _connectionManager.setMaxTotal(maxSimultaneousConnections > 0 ? maxSimultaneousConnections : Integer.MAX_VALUE); 157 _connectionManager.setDefaultMaxPerRoute(maxSimultaneousConnections > 0 ? maxSimultaneousConnections : Integer.MAX_VALUE); 158 159 // inspired from http://www.baeldung.com/httpclient-connection-management 160 // to keep a connection alive for a few seconds if the remote server did not send back this information 161 _connectionKeepAliveStrategy = new ConnectionKeepAliveStrategy() 162 { 163 @Override 164 public long getKeepAliveDuration(HttpResponse response, HttpContext context) 165 { 166 HeaderElementIterator it = new BasicHeaderElementIterator(response.headerIterator(HTTP.CONN_KEEP_ALIVE)); 167 while (it.hasNext()) 168 { 169 HeaderElement he = it.nextElement(); 170 String param = he.getName(); 171 String value = he.getValue(); 172 if (value != null && param.equalsIgnoreCase("timeout")) 173 { 174 try 175 { 176 return Long.parseLong(value) * 1000; 177 } 178 catch (NumberFormatException ignore) 179 { 180 // Ignore 181 } 182 } 183 } 184 return _DROP_KEPTALIVE_CONNECTION_AFTER * 1000; 185 } 186 }; 187 188 _connectionConfig = RequestConfig.custom() 189 .setConnectTimeout(connectionTimeout * 1000) 190 .setConnectionRequestTimeout(connectionTimeout * 1000) // Time to get an object from the pool 191 .setSocketTimeout(connectionTimeout * 1000).build(); 192 193 long tokenDuration = Math.max(0, (long) Config.getInstance().getValue("zimbra.config.tokenDuration")); 194 195 _cacheManager.createMemoryCache(TOKEN_CACHE, 196 new I18nizableText("plugin.zimbra", "PLUGINS_ZIMBRA_CACHE_TOKEN_LABEL"), 197 new I18nizableText("plugin.zimbra", "PLUGINS_ZIMBRA_CACHE_TOKEN_DESCRIPTION"), 198 false, 199 Duration.ofMinutes(tokenDuration)); 200 } 201 202 public void dispose() 203 { 204 _connectionManager.close(); 205 } 206 207 /** 208 * Get a new pooled http client. Do not forget to close it. 209 * @return The client 210 */ 211 protected CloseableHttpClient _getHttpClient() 212 { 213 return HttpClients.custom() 214 .setConnectionManager(_connectionManager) 215 .setConnectionManagerShared(true) // avoid automatic pool closing 216 .setKeepAliveStrategy(_connectionKeepAliveStrategy) 217 .setDefaultRequestConfig(_connectionConfig) 218 .build(); 219 } 220 221 /** 222 * Preauth user and redirect to the zimbra application 223 * @param redirector The redirector 224 * @param targetApp The zimbra application (ex: mail) 225 * @throws ProcessingException if failed to redirect 226 * @throws IOException if failed to redirect 227 */ 228 public void redirect(Redirector redirector, String targetApp) throws ProcessingException, IOException 229 { 230 UserIdentity identity = _currentUserProvider.getUser(); 231 User user = _usersManager.getUser(identity); 232 233 if (user != null) 234 { 235 String qs = _computeQueryString(user, targetApp); 236 if (qs != null) 237 { 238 redirector.redirect(false, _zimbraUrl + "/service/preauth?" + qs); 239 } 240 } 241 } 242 243 244 /** 245 * Zimbra preauth request to log the current user into zimbra and retrieve the ZM_AUTH_TOKEN 246 * @param userIdentity The user for which the preauth request will be done. 247 * @return The Zimbra ZM_AUTH_TOKEN which can be used in future request made through the Zimbra REST API or <code>null</code> if user is null or has no email. 248 * @throws MessagingConnectorException if failed to get zimbra token for user 249 */ 250 protected String _doPreauthRequest(UserIdentity userIdentity) 251 { 252 User user = _usersManager.getUser(userIdentity); 253 // Computing query string 254 String qs = _computeQueryString(user, null); 255 if (qs == null) 256 { 257 return null; 258 } 259 260 HttpGet get = new HttpGet(_zimbraUrl + "/service/preauth?" + qs); 261 262 if (getLogger().isDebugEnabled()) 263 { 264 getLogger().debug("ZimbraConnector: performing preauth request for user {}", user.getIdentity()); 265 } 266 267 try (CloseableHttpClient httpClient = _getHttpClient()) 268 { 269 HttpClientContext context = HttpClientContext.create(); 270 try (CloseableHttpResponse response = httpClient.execute(get, context)) 271 { 272 List<Cookie> cookies = context.getCookieStore().getCookies(); 273 274 for (Cookie cookie : cookies) 275 { 276 if (StringUtils.equals(cookie.getName(), "ZM_AUTH_TOKEN")) 277 { 278 return cookie.getValue(); 279 } 280 } 281 282 throw new MessagingConnectorException("Zimbra authentification failed for user " + user.getEmail(), MessagingConnectorException.ExceptionType.CONFIGURATION_EXCEPTION); 283 } 284 } 285 catch (UnknownHostException e) 286 { 287 throw new MessagingConnectorException("Unknown host for zimbra server. Giving up to proceed to the Zimbra preauth action for user " + user, MessagingConnectorException.ExceptionType.CONFIGURATION_EXCEPTION, e); 288 } 289 catch (ConnectionPoolTimeoutException | SocketTimeoutException e) 290 { 291 throw new MessagingConnectorException("There are already too many connections to zimbra server. Giving up to proceed to the Zimbra preauth action for user " + user, MessagingConnectorException.ExceptionType.TIMEOUT, e); 292 } 293 catch (IOException e) 294 { 295 throw new MessagingConnectorException("Unable to proceed to the Zimbra preauth action for user : " + user.getEmail(), MessagingConnectorException.ExceptionType.UNKNOWN, e); 296 } 297 } 298 299 private String _computeQueryString(User user, String targetApp) 300 { 301 if (user == null) 302 { 303 return null; 304 } 305 306 String zimbraUser = user.getEmail(); 307 308 if (StringUtils.isEmpty(zimbraUser)) 309 { 310 if (getLogger().isDebugEnabled()) 311 { 312 getLogger().debug("Cannot retreive zimbra information with empty email for user " + user); 313 } 314 return null; 315 } 316 317 String timestamp = String.valueOf(System.currentTimeMillis()); 318 String computedPreauth = null; 319 320 try 321 { 322 computedPreauth = _getComputedPreauth(zimbraUser, timestamp, _domainPreauthSecretKey); 323 } 324 catch (Exception e) 325 { 326 throw new MessagingConnectorException("Unable to compute the preauth key during the Zimbra preauth action for user : " + zimbraUser, MessagingConnectorException.ExceptionType.UNKNOWN, e); 327 } 328 329 // Preauth request parameters 330 List<NameValuePair> params = new ArrayList<>(); 331 params.add(new BasicNameValuePair("account", zimbraUser)); 332 params.add(new BasicNameValuePair("timestamp", timestamp)); 333 params.add(new BasicNameValuePair("expires", "0")); 334 params.add(new BasicNameValuePair("preauth", computedPreauth)); 335 if (targetApp != null) 336 { 337 params.add(new BasicNameValuePair("redirectURL", "/?app=" + targetApp)); 338 } 339 340 // Computing query string 341 return URLEncodedUtils.format(params, StandardCharsets.UTF_8); 342 } 343 344 /** 345 * Compute the preauth key. 346 * @param zimbraUser The Zimbra User 347 * @param timestamp The timestamp 348 * @param secretKey The secret key 349 * @return The computed preauth key 350 * @throws NoSuchAlgorithmException if no Provider supports a MacSpi 351 * implementation for the specified algorithm (HmacSHA1). 352 * @throws InvalidKeyException if the given key is inappropriate for 353 * initializing the MAC 354 * @throws UnsupportedEncodingException If the named charset (UTF-8) is not 355 * supported 356 */ 357 protected String _getComputedPreauth(String zimbraUser, String timestamp, String secretKey) throws NoSuchAlgorithmException, InvalidKeyException, UnsupportedEncodingException 358 { 359 SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes(), "HmacSHA1"); 360 Mac mac = Mac.getInstance("HmacSHA1"); 361 mac.init(signingKey); 362 363 String data = StringUtils.join(new String[] {zimbraUser, "name", "0", timestamp}, '|'); 364 byte[] rawHmac = mac.doFinal(data.getBytes()); 365 byte[] hexBytes = new Hex().encode(rawHmac); 366 return new String(hexBytes, "UTF-8"); 367 } 368 369 @Override 370 protected List<CalendarEvent> internalGetEvents(UserIdentity userIdentity, int maxDays, int maxEvents) throws MessagingConnectorException 371 { 372 Document response = _getSoapEvents(userIdentity, maxDays); 373 374 if (response == null) 375 { 376 return Collections.EMPTY_LIST; 377 } 378 379 NodeList events = _xPathProcessor.selectNodeList(response, "/soap:Envelope/soap:Body/mail:SearchResponse/mail:appt/mail:inst", __PREFIX_RESOLVER); 380 int count = 0; 381 Map<Long, CalendarEvent> calendarEvents = new TreeMap<>(); 382 while (count < events.getLength()) 383 { 384 Element item = (Element) events.item(count); 385 386 String rawStart = _getAttribute(item, "s"); 387 long longStart = Long.parseLong(rawStart); 388 Instant startInst = Instant.ofEpochMilli(longStart); 389 Date start = Date.from(startInst); 390 391 String duration = _getAttribute(item, "dur"); 392 Instant endInst = startInst.plusMillis(Long.parseLong(duration)); 393 Date end = Date.from(endInst); 394 395 String name = _getAttribute(item, "name"); 396 String location = _getAttribute(item, "loc"); 397 398 CalendarEvent newEvent = new CalendarEvent(); 399 newEvent.setStartDate(start); 400 newEvent.setEndDate(end); 401 newEvent.setSubject(name); 402 newEvent.setLocation(location); 403 calendarEvents.put(longStart, newEvent); 404 405 count++; 406 } 407 408 return calendarEvents.entrySet().stream().limit(maxEvents).map(e -> e.getValue()).collect(Collectors.toList()); 409 } 410 411 private String _getAttribute(Element item, String name) 412 { 413 String value = item.getAttribute(name); 414 if (StringUtils.isEmpty(value)) 415 { 416 value = ((Element) item.getParentNode()).getAttribute(name); 417 } 418 419 return value; 420 } 421 422 @Override 423 protected int internalGetEventsCount(UserIdentity userIdentity, int maxDays) throws MessagingConnectorException 424 { 425 Document response = _getSoapEvents(userIdentity, maxDays); 426 427 if (response == null) 428 { 429 return 0; 430 } 431 432 return _xPathProcessor.evaluateAsNumber(response, "count(/soap:Envelope/soap:Body/mail:SearchResponse/mail:appt/mail:inst)", __PREFIX_RESOLVER).intValue(); 433 } 434 435 private Document _getSoapEvents(UserIdentity userIdentity, int maxDays) throws MessagingConnectorException 436 { 437 ZonedDateTime now = ZonedDateTime.now(); 438 String soapRequestBody = "<SearchRequest xmlns=\"urn:zimbraMail\" types=\"appointment\" calExpandInstStart=\"" + now.toInstant().toEpochMilli() + "\" calExpandInstEnd =\"" + now.plusDays(maxDays).toInstant().toEpochMilli() + "\" >" 439 + " <query>in:calendar</query>" 440 + "</SearchRequest>"; 441 442 return _executeSoapRequest(userIdentity, soapRequestBody, "next events"); 443 } 444 445 @Override 446 protected List<EmailMessage> internalGetEmails(UserIdentity userIdentity, int maxEmails) throws MessagingConnectorException 447 { 448 String soapRequestBody = "<SearchRequest xmlns=\"urn:zimbraMail\" types=\"message\" limit=\"" + maxEmails + "\">" 449 + " <query>is:unread in:inbox</query>" 450 + "</SearchRequest>"; 451 452 Document document = _executeSoapRequest(userIdentity, soapRequestBody, "unread email"); 453 454 if (document != null) 455 { 456 NodeList emails = _xPathProcessor.selectNodeList(document, "/soap:Envelope/soap:Body/mail:SearchResponse/mail:m", __PREFIX_RESOLVER); 457 458 List<EmailMessage> result = new ArrayList<>(); 459 int count = 0; 460 while (count < emails.getLength()) 461 { 462 Node item = emails.item(count); 463 464 EmailMessage email = new EmailMessage(); 465 email.setSender(_xPathProcessor.evaluateAsString(item, "mail:e/@a", __PREFIX_RESOLVER)); 466 email.setSubject(_xPathProcessor.evaluateAsString(item, "mail:su", __PREFIX_RESOLVER)); 467 email.setSummary(_xPathProcessor.evaluateAsString(item, "mail:fr", __PREFIX_RESOLVER)); 468 469 result.add(email); 470 471 count++; 472 } 473 474 return result; 475 } 476 477 return Collections.emptyList(); 478 } 479 480 @Override 481 protected int internalGetEmailsCount(UserIdentity userIdentity) throws MessagingConnectorException 482 { 483 String soapRequestBody = "<GetFolderRequest xmlns=\"urn:zimbraMail\" depth=\"0\">" 484 + " <folder path=\"inbox\" />" 485 + "</GetFolderRequest>"; 486 487 Document document = _executeSoapRequest(userIdentity, soapRequestBody, "email count"); 488 if (document != null) 489 { 490 return _xPathProcessor.evaluateAsNumber(document, "/soap:Envelope/soap:Body/mail:GetFolderResponse/mail:folder/@u", __PREFIX_RESOLVER).intValue(); 491 } 492 493 return 0; 494 } 495 496 private Document _executeSoapRequest(UserIdentity userIdentity, String soapRequestBody, String shortDescription) throws MessagingConnectorException 497 { 498 // Connection to the zimbra mail server 499 String authToken = _getToken(userIdentity); 500 501 if (StringUtils.isEmpty(authToken)) 502 { 503 return null; 504 } 505 506 String soapRequest = "<soap:Envelope xmlns:soap=\"http://www.w3.org/2003/05/soap-envelope\">" 507 + " <soap:Header>" 508 + " <context xmlns=\"urn:zimbra\">" 509 + " <authToken>" + authToken + "</authToken>" 510 + " </context>" 511 + " </soap:Header>" 512 + " <soap:Body>" 513 + soapRequestBody 514 + " </soap:Body>" 515 + "</soap:Envelope>"; 516 517 HttpPost req = new HttpPost(_zimbraUrl + "/service/soap"); 518 req.setEntity(new StringEntity(soapRequest, StandardCharsets.UTF_8)); 519 520 try ( 521 CloseableHttpClient httpClient = _getHttpClient(); 522 CloseableHttpResponse response = httpClient.execute(req, HttpClientContext.create()) 523 ) 524 { 525 int status = response.getStatusLine().getStatusCode(); 526 if (status != 200) 527 { 528 getLogger().error("Zimbra failed on calling '{}' with status {} and reason: {}", shortDescription, status, response.getStatusLine().getReasonPhrase()); 529 if (getLogger().isDebugEnabled()) 530 { 531 StringBuilder sb = new StringBuilder("Headers:"); 532 for (Header header : response.getAllHeaders()) 533 { 534 sb.append("\n - ").append(header.getName()).append(": ").append(header.getValue()); 535 } 536 getLogger().debug(sb.toString()); 537 } 538 return null; 539 } 540 541 String content = EntityUtils.toString(response.getEntity()); 542 if (getLogger().isDebugEnabled()) 543 { 544 getLogger().debug("{}: {}", userIdentity, content); 545 } 546 Document document = _domParser.parseDocument(new InputSource(new StringReader(content))); 547 548 String failure = _xPathProcessor.evaluateAsString(document, "/soap:Envelope/soap:Body/soap:Fault/soap:Reason/soap:Text", __PREFIX_RESOLVER); 549 550 if (StringUtils.isEmpty(failure)) 551 { 552 return document; 553 } 554 else 555 { 556 getLogger().warn("Zimbra failed to return {} for user {}: {}", shortDescription, userIdentity, failure); 557 return null; 558 } 559 } 560 catch (IOException | SAXException e) 561 { 562 throw new MessagingConnectorException("Failed to get Zimbra " + shortDescription + " for user " + userIdentity, MessagingConnectorException.ExceptionType.UNKNOWN, e); 563 } 564 } 565 566 private static class ZimbraPrefixResolver implements PrefixResolver 567 { 568 private Map<String, String> _namespaces = Map.of("soap", "http://www.w3.org/2003/05/soap-envelope", 569 "mail", "urn:zimbraMail"); 570 571 public String prefixToNamespace(String prefix) 572 { 573 return _namespaces.get(prefix); 574 } 575 } 576 577 private String _getToken (UserIdentity user) 578 { 579 if (user == null) 580 { 581 return null; 582 } 583 584 Cache<UserIdentity, String> cache = _getCache(); 585 586 return cache.get(user, key -> _doPreauthRequest(key)); 587 } 588 589 private Cache<UserIdentity, String> _getCache() 590 { 591 return this._cacheManager.get(TOKEN_CACHE); 592 } 593}