001/*
002 *  Copyright 2023 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.util.ArrayList;
027import java.util.List;
028import java.util.Map;
029
030import javax.crypto.Mac;
031import javax.crypto.spec.SecretKeySpec;
032
033import org.apache.avalon.framework.service.ServiceException;
034import org.apache.avalon.framework.service.ServiceManager;
035import org.apache.avalon.framework.service.Serviceable;
036import org.apache.commons.codec.binary.Hex;
037import org.apache.commons.lang3.StringUtils;
038import org.apache.excalibur.xml.dom.DOMParser;
039import org.apache.excalibur.xml.xpath.PrefixResolver;
040import org.apache.excalibur.xml.xpath.XPathProcessor;
041import org.apache.http.NameValuePair;
042import org.apache.http.client.methods.CloseableHttpResponse;
043import org.apache.http.client.methods.HttpPost;
044import org.apache.http.client.utils.URLEncodedUtils;
045import org.apache.http.conn.ConnectionPoolTimeoutException;
046import org.apache.http.entity.StringEntity;
047import org.apache.http.impl.client.CloseableHttpClient;
048import org.apache.http.message.BasicNameValuePair;
049import org.apache.http.util.EntityUtils;
050import org.w3c.dom.Document;
051import org.xml.sax.InputSource;
052import org.xml.sax.SAXException;
053
054import org.ametys.plugins.messagingconnector.MessagingConnectorException;
055
056/**
057 * Helper for Zimbra SOAP authentication, according to https://wiki.zimbra.com/wiki/Preauth 
058 */
059public class ZimbraPreauthHelper implements Serviceable
060{
061    private static final ZimbraPrefixResolver __PREFIX_RESOLVER = new ZimbraPrefixResolver();
062    
063    private static XPathProcessor _xPathProcessor;
064    private static DOMParser _domParser;
065    
066    @Override
067    public void service(ServiceManager smanager) throws ServiceException
068    {
069        _xPathProcessor = (XPathProcessor) smanager.lookup(XPathProcessor.ROLE);
070        _domParser = (DOMParser) smanager.lookup(DOMParser.ROLE);
071    }
072    
073    /**
074     * Zimbra preauth request to log the current user into zimbra and retrieve the token
075     * @param zimbraUrl the url of zimbra
076     * @param zimbraUser the user
077     * @param preAuthKey the pre-auth key, computed with user, a timestamp and the secret key
078     * @param httpClient The http client
079     * @return The Zimbra 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.
080     * @throws MessagingConnectorException if failed to get zimbra token for user
081     */
082    public static String doPreauthRequest(String zimbraUrl, String zimbraUser, String preAuthKey, CloseableHttpClient httpClient)
083    {
084        String timestamp = String.valueOf(System.currentTimeMillis());
085        String computedPreauth = null;
086
087        try
088        {
089            computedPreauth = _getComputedPreauth(zimbraUser, timestamp, preAuthKey);
090        }
091        catch (Exception e)
092        {
093            throw new MessagingConnectorException("Unable to compute the preauth key during the Zimbra preauth action for user : " + zimbraUser, MessagingConnectorException.ExceptionType.UNKNOWN, e);
094        }
095        
096        try (httpClient)
097        {
098            String soapRequest = "<soap:Envelope xmlns:soap=\"http://www.w3.org/2003/05/soap-envelope\">" 
099                    + "  <soap:Header>"
100                    + "    <context xmlns=\"urn:zimbra\">"
101                    + "    </context>"
102                    + "  </soap:Header>"
103                    + "  <soap:Body>"
104                    + "    <AuthRequest xmlns=\"urn:zimbraAccount\">\n"
105                    + "      <account by=\"name\">" + zimbraUser + "</account>\n"
106                    + "      <preauth timestamp=\"" + timestamp + "\" expires=\"0\">" + computedPreauth + "</preauth>\n"
107                    + "    </AuthRequest>"
108                    + "  </soap:Body>" 
109                    + "</soap:Envelope>";
110            
111            HttpPost req = new HttpPost(zimbraUrl + "/service/soap");
112            req.setEntity(new StringEntity(soapRequest, StandardCharsets.UTF_8));
113            
114            try (CloseableHttpResponse response = httpClient.execute(req))
115            {
116                String content = EntityUtils.toString(response.getEntity());
117                
118                Document document = _domParser.parseDocument(new InputSource(new StringReader(content)));
119                
120                String failure = _xPathProcessor.evaluateAsString(document, "/soap:Envelope/soap:Body/soap:Fault/soap:Reason/soap:Text", __PREFIX_RESOLVER);
121
122                if (StringUtils.isNotBlank(failure))
123                {
124                    throw new MessagingConnectorException("Zimbra authentification failed for user " + zimbraUser + " with message " + failure, MessagingConnectorException.ExceptionType.CONFIGURATION_EXCEPTION);
125                }
126                
127                String token = _xPathProcessor.evaluateAsString(document, "/soap:Envelope/soap:Body/account:AuthResponse/account:authToken", __PREFIX_RESOLVER);
128                
129                if (StringUtils.isBlank(token))
130                {
131                    throw new MessagingConnectorException("Zimbra authentification failed for user " + zimbraUser, MessagingConnectorException.ExceptionType.CONFIGURATION_EXCEPTION);
132                }
133                
134                return token;
135            }
136        }
137        catch (UnknownHostException e)
138        {
139            throw new MessagingConnectorException("Unknown host for zimbra server. Giving up to proceed to the Zimbra preauth action for user " + zimbraUser, MessagingConnectorException.ExceptionType.CONFIGURATION_EXCEPTION, e);
140        }
141        catch (ConnectionPoolTimeoutException | SocketTimeoutException e)
142        {
143            throw new MessagingConnectorException("There are already too many connections to zimbra server. Giving up to proceed to the Zimbra preauth action for user " + zimbraUser, MessagingConnectorException.ExceptionType.TIMEOUT, e);
144        }
145        catch (IOException | SAXException e)
146        {
147            throw new MessagingConnectorException("Unable to proceed to the Zimbra preauth action for user : " + zimbraUser, MessagingConnectorException.ExceptionType.UNKNOWN, e);
148        }
149    }
150
151    /**
152     * Compute the query string
153     * @param zimbraUser the user
154     * @param preAuthKey the pre-auth key, computed with user, a timestamp and the secret key
155     * @param targetApp the target app
156     * @return the query string
157     */
158    static String _computeQueryString(String zimbraUser, String preAuthKey, String targetApp)
159    {
160        String timestamp = String.valueOf(System.currentTimeMillis());
161        String computedPreauth = null;
162
163        try
164        {
165            computedPreauth = _getComputedPreauth(zimbraUser, timestamp, preAuthKey);
166        }
167        catch (Exception e)
168        {
169            throw new MessagingConnectorException("Unable to compute the preauth key during the Zimbra preauth action for user : " + zimbraUser, MessagingConnectorException.ExceptionType.UNKNOWN, e);
170        }
171
172        // Preauth request parameters
173        List<NameValuePair> params = new ArrayList<>();
174        params.add(new BasicNameValuePair("account", zimbraUser));
175        params.add(new BasicNameValuePair("timestamp", timestamp));
176        params.add(new BasicNameValuePair("expires", "0"));
177        params.add(new BasicNameValuePair("preauth", computedPreauth));
178        if (targetApp != null)
179        {
180            params.add(new BasicNameValuePair("redirectURL", "/?app=" + targetApp));
181        }
182        
183        // Computing query string
184        return URLEncodedUtils.format(params, StandardCharsets.UTF_8);
185    }
186    
187    private static String _getComputedPreauth(String zimbraUser, String timestamp, String secretKey) throws NoSuchAlgorithmException, InvalidKeyException, UnsupportedEncodingException
188    {
189        SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes(), "HmacSHA1");
190        Mac mac = Mac.getInstance("HmacSHA1");
191        mac.init(signingKey);
192
193        String data = StringUtils.join(new String[] {zimbraUser, "name", "0", timestamp}, '|');
194        byte[] rawHmac = mac.doFinal(data.getBytes());
195        byte[] hexBytes = new Hex().encode(rawHmac);
196        return new String(hexBytes, "UTF-8");
197    }
198    
199    private static class ZimbraPrefixResolver implements PrefixResolver
200    {
201        private Map<String, String> _namespaces = Map.of("soap", "http://www.w3.org/2003/05/soap-envelope",
202                                                         "account", "urn:zimbraAccount");
203        
204        public String prefixToNamespace(String prefix)
205        {
206            return _namespaces.get(prefix);
207        }
208    }
209}