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}