001/* 002 * (C) Copyright 2010-2019 Nuxeo (http://nuxeo.com/) and others. 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 * 016 * Contributors: 017 * Gagnavarslan ehf 018 * Thomas Haines 019 * Florent Guillaume 020 */ 021package org.nuxeo.ecm.ui.web.auth.digest; 022 023import java.io.IOException; 024import java.io.StringReader; 025import java.util.List; 026import java.util.Map; 027 028import javax.servlet.http.HttpServletRequest; 029import javax.servlet.http.HttpServletResponse; 030 031import org.apache.commons.codec.binary.Base64; 032import org.apache.commons.codec.digest.DigestUtils; 033import org.apache.commons.lang3.StringUtils; 034import org.apache.commons.logging.Log; 035import org.apache.commons.logging.LogFactory; 036import org.nuxeo.ecm.core.api.DocumentModel; 037import org.nuxeo.ecm.directory.Directory; 038import org.nuxeo.ecm.directory.DirectoryException; 039import org.nuxeo.ecm.directory.Session; 040import org.nuxeo.ecm.directory.api.DirectoryService; 041import org.nuxeo.ecm.platform.api.login.UserIdentificationInfo; 042import org.nuxeo.ecm.platform.ui.web.auth.interfaces.NuxeoAuthenticationPlugin; 043import org.nuxeo.ecm.platform.usermanager.UserManager; 044import org.nuxeo.runtime.api.Framework; 045 046/** 047 * Nuxeo Authenticator for HTTP Digest Access Authentication (RFC 2617). 048 */ 049public class DigestAuthenticator implements NuxeoAuthenticationPlugin { 050 051 private static final Log log = LogFactory.getLog(DigestAuthenticator.class); 052 053 protected static final String DEFAULT_REALMNAME = "NUXEO"; 054 055 protected static final long DEFAULT_NONCE_VALIDITY_SECONDS = 1000; 056 057 protected static final String REALM = "realm"; 058 059 protected static final String HTTP_METHOD = "httpMethod"; 060 061 protected static final String URI = "uri"; 062 063 protected static final String QOP = "qop"; 064 065 protected static final String NONCE = "nonce"; 066 067 protected static final String NC = "nc"; 068 069 protected static final String CNONCE = "cnonce"; 070 071 protected static final String REALM_NAME_KEY = "RealmName"; 072 073 protected static final String BA_HEADER_NAME = "WWW-Authenticate"; 074 075 protected String realmName; 076 077 protected long nonceValiditySeconds = DEFAULT_NONCE_VALIDITY_SECONDS; 078 079 protected String accessKey = "key"; 080 081 @Override 082 public Boolean handleLoginPrompt(HttpServletRequest httpRequest, HttpServletResponse httpResponse, String baseURL) { 083 084 long expiryTime = System.currentTimeMillis() + (nonceValiditySeconds * 1000); 085 String signature = DigestUtils.md5Hex(expiryTime + ":" + accessKey); 086 String nonce = expiryTime + ":" + signature; 087 String nonceB64 = new String(Base64.encodeBase64(nonce.getBytes())); 088 089 String authenticateHeader = String.format("Digest realm=\"%s\", qop=\"auth\", nonce=\"%s\"", realmName, 090 nonceB64); 091 092 try { 093 httpResponse.addHeader(BA_HEADER_NAME, authenticateHeader); 094 httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED); 095 return Boolean.TRUE; 096 } catch (IOException e) { 097 return Boolean.FALSE; 098 } 099 } 100 101 @Override 102 public UserIdentificationInfo handleRetrieveIdentity(HttpServletRequest httpRequest, 103 HttpServletResponse httpResponse) { 104 105 String header = httpRequest.getHeader("Authorization"); 106 if (StringUtils.isEmpty(header)) { 107 return null; 108 } 109 Map<String, String> headerMap = splitParameters(header); 110 if (headerMap == null) { 111 // parsing failed 112 return null; 113 } 114 headerMap.put("httpMethod", httpRequest.getMethod()); 115 116 String nonceB64 = headerMap.get(NONCE); 117 String nonce = new String(Base64.decodeBase64(nonceB64.getBytes())); 118 String[] nonceTokens = nonce.split(":"); 119 @SuppressWarnings("unused") 120 long nonceExpiryTime = Long.parseLong(nonceTokens[0]); 121 // @TODO: check expiry time and do something 122 123 String username = getValidatedUsername(headerMap); 124 if (username == null) { 125 return null; // invalid 126 } 127 return new UserIdentificationInfo(username); 128 } 129 130 @Override 131 public Boolean needLoginPrompt(HttpServletRequest httpRequest) { 132 // @TODO: Use DIGEST authentication for WebDAV and WSS 133 return Boolean.TRUE; 134 } 135 136 @Override 137 public void initPlugin(Map<String, String> parameters) { 138 if (parameters.containsKey(REALM_NAME_KEY)) { 139 realmName = parameters.get(REALM_NAME_KEY); 140 } else { 141 realmName = DEFAULT_REALMNAME; 142 } 143 } 144 145 @Override 146 public List<String> getUnAuthenticatedURLPrefix() { 147 return null; 148 } 149 150 public static Map<String, String> splitParameters(String auth) { 151 Map<String, String> map; 152 try { 153 map = org.apache.tomcat.util.http.parser.Authorization.parseAuthorizationDigest(new StringReader(auth)); 154 } catch (IllegalArgumentException | IOException e) { 155 log.error(e.getMessage(), e); 156 map = null; 157 } 158 return map; 159 } 160 161 protected String getValidatedUsername(Map<String, String> headerMap) { 162 String username = headerMap.get("username"); 163 try { 164 String storedHA1 = getStoredHA1(username); 165 if (StringUtils.isEmpty(storedHA1)) { 166 log.warn("Digest authentication failed, stored HA1 is empty for user: " + username); 167 return null; 168 } 169 String computedDigest = computeDigest(storedHA1, // 170 headerMap.get(HTTP_METHOD), // 171 headerMap.get(URI), // 172 headerMap.get(QOP), // RFC 2617 extension 173 headerMap.get(NONCE), // 174 headerMap.get(NC), // RFC 2617 extension 175 headerMap.get(CNONCE) // RFC 2617 extension 176 ); 177 String digest = headerMap.get("response"); 178 if (!computedDigest.equals(digest)) { 179 log.warn("Digest authentication failed for user: " + username + ", realm: " + headerMap.get(REALM)); 180 return null; 181 } 182 } catch (IllegalArgumentException | DirectoryException e) { 183 log.error("Digest authentication failed for user: " + username, e); 184 return null; 185 } 186 return username; 187 } 188 189 protected static String computeDigest(String ha1, String httpMethod, String uri, String qop, String nonce, 190 String nc, String cnonce) throws IllegalArgumentException { 191 String a2 = httpMethod + ":" + uri; 192 String ha2 = DigestUtils.md5Hex(a2); 193 String digest; 194 if (qop == null) { 195 digest = ha1 + ":" + nonce + ":" + ha2; 196 } else if ("auth".equals(qop)) { 197 digest = ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2; 198 } else { 199 throw new IllegalArgumentException("This method does not support a qop: '" + qop + "'"); 200 } 201 return DigestUtils.md5Hex(digest); 202 } 203 204 protected String getStoredHA1(String username) { 205 UserManager userManager = Framework.getService(UserManager.class); 206 String dirName = userManager.getDigestAuthDirectory(); 207 DirectoryService directoryService = Framework.getService(DirectoryService.class); 208 Directory directory = directoryService.getDirectory(dirName); 209 if (directory == null) { 210 throw new IllegalArgumentException("Digest Auth directory not found: " + dirName); 211 } 212 try (Session dir = directoryService.open(dirName)) { 213 dir.setReadAllColumns(true); // needed to read digest password 214 String schema = directoryService.getDirectorySchema(dirName); 215 DocumentModel entry = Framework.doPrivileged(() -> dir.getEntry(username, true)); 216 String passwordField = dir.getPasswordField(); 217 return entry == null ? null : (String) entry.getProperty(schema, passwordField); 218 } 219 } 220 221}