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}