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 final 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}