DigestAuthenticator.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.catalina.authenticator;

import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.apache.catalina.LifecycleException;
import org.apache.catalina.Realm;
import org.apache.catalina.connector.Request;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.buf.HexUtils;
import org.apache.tomcat.util.buf.MessageBytes;
import org.apache.tomcat.util.buf.StringUtils;
import org.apache.tomcat.util.http.parser.Authorization;
import org.apache.tomcat.util.security.ConcurrentMessageDigest;


/**
 * An <b>Authenticator</b> and <b>Valve</b> implementation of HTTP DIGEST Authentication, as outlined in RFC 7616: "HTTP
 * Digest Authentication"
 *
 * @author Craig R. McClanahan
 * @author Remy Maucherat
 */
public class DigestAuthenticator extends AuthenticatorBase {

    private final Log log = LogFactory.getLog(DigestAuthenticator.class); // must not be static


    // -------------------------------------------------------------- Constants

    /**
     * Tomcat's DIGEST implementation only supports auth quality of protection.
     */
    protected static final String QOP = "auth";

    private static final AuthDigest FALLBACK_DIGEST = AuthDigest.MD5;

    private static final String NONCE_DIGEST = "SHA-256";

    // List permitted algorithms and maps them to Java standard names
    private static final Map<String,AuthDigest> PERMITTED_ALGORITHMS = new HashMap<>();
    static {
        // Allows the digester to be configured with either the Standard Java name or the name used the RFC.
        for (AuthDigest authDigest : AuthDigest.values()) {
            PERMITTED_ALGORITHMS.put(authDigest.getJavaName(), authDigest);
            PERMITTED_ALGORITHMS.put(authDigest.getRfcName(), authDigest);
        }
    }


    // ----------------------------------------------------------- Constructors

    public DigestAuthenticator() {
        super();
        setCache(false);
    }


    // ----------------------------------------------------- Instance Variables

    /**
     * List of server nonce values currently being tracked
     */
    protected Map<String,NonceInfo> nonces;


    /**
     * The last timestamp used to generate a nonce. Each nonce should get a unique timestamp.
     */
    protected long lastTimestamp = 0;
    protected final Object lastTimestampLock = new Object();


    /**
     * Maximum number of server nonces to keep in the cache. If not specified, the default value of 1000 is used.
     */
    protected int nonceCacheSize = 1000;


    /**
     * The window size to use to track seen nonce count values for a given nonce. If not specified, the default of 100
     * is used.
     */
    protected int nonceCountWindowSize = 100;

    /**
     * Private key.
     */
    protected String key = null;


    /**
     * How long server nonces are valid for in milliseconds. Defaults to 5 minutes.
     */
    protected long nonceValidity = 5 * 60 * 1000;


    /**
     * Opaque string.
     */
    protected String opaque;


    /**
     * Should the URI be validated as required by RFC2617? Can be disabled in reverse proxies where the proxy has
     * modified the URI.
     */
    protected boolean validateUri = true;


    /**
     * Algorithms to use for WWW-Authenticate challenges.
     */
    private List<AuthDigest> algorithms = Arrays.asList(AuthDigest.SHA_256, AuthDigest.MD5);


    // ------------------------------------------------------------- Properties

    public int getNonceCountWindowSize() {
        return nonceCountWindowSize;
    }


    public void setNonceCountWindowSize(int nonceCountWindowSize) {
        this.nonceCountWindowSize = nonceCountWindowSize;
    }


    public int getNonceCacheSize() {
        return nonceCacheSize;
    }


    public void setNonceCacheSize(int nonceCacheSize) {
        this.nonceCacheSize = nonceCacheSize;
    }


    public String getKey() {
        return key;
    }


    public void setKey(String key) {
        this.key = key;
    }


    public long getNonceValidity() {
        return nonceValidity;
    }


    public void setNonceValidity(long nonceValidity) {
        this.nonceValidity = nonceValidity;
    }


    public String getOpaque() {
        return opaque;
    }


    public void setOpaque(String opaque) {
        this.opaque = opaque;
    }


    public boolean isValidateUri() {
        return validateUri;
    }


    public void setValidateUri(boolean validateUri) {
        this.validateUri = validateUri;
    }


    public String getAlgorithms() {
        StringBuilder result = new StringBuilder();
        StringUtils.join(algorithms, ',', (x) -> x.getRfcName(), result);
        return result.toString();
    }


    public void setAlgorithms(String algorithmsString) {
        String[] algorithmsArray = algorithmsString.split(",");
        List<AuthDigest> algorithms = new ArrayList<>();

        // Ignore the new setting if any of the algorithms are invalid
        for (String algorithm : algorithmsArray) {
            AuthDigest authDigest = PERMITTED_ALGORITHMS.get(algorithm);
            if (authDigest == null) {
                log.warn(sm.getString("digestAuthenticator.invalidAlgorithm", algorithmsString, algorithm));
                return;
            }
            algorithms.add(authDigest);
        }

        initAlgorithms(algorithms);
        this.algorithms = algorithms;
    }


    /*
     * Initialise algorithms, removing ones that the JRE does not support
     */
    private void initAlgorithms(List<AuthDigest> algorithms) {
        Iterator<AuthDigest> algorithmIterator = algorithms.iterator();
        while (algorithmIterator.hasNext()) {
            AuthDigest algorithm = algorithmIterator.next();
            try {
                ConcurrentMessageDigest.init(algorithm.getJavaName());
            } catch (NoSuchAlgorithmException e) {
                // In theory, a JRE can choose not to implement SHA-512/256
                log.warn(sm.getString("digestAuthenticator.unsupportedAlgorithm", algorithms, algorithm.getJavaName()),
                        e);
                algorithmIterator.remove();
            }
        }
    }


    // --------------------------------------------------------- Public Methods

    /**
     * Authenticate the user making this request, based on the specified login configuration. Return <code>true</code>
     * if any specified constraint has been satisfied, or <code>false</code> if we have created a response challenge
     * already.
     *
     * @param request  Request we are processing
     * @param response Response we are creating
     *
     * @exception IOException if an input/output error occurs
     */
    @Override
    protected boolean doAuthenticate(Request request, HttpServletResponse response) throws IOException {

        // NOTE: We don't try to reauthenticate using any existing SSO session,
        // because that will only work if the original authentication was
        // BASIC or FORM, which are less secure than the DIGEST auth-type
        // specified for this webapp
        //
        // Change to true below to allow previous FORM or BASIC authentications
        // to authenticate users for this webapp
        // TODO make this a configurable attribute (in SingleSignOn??)
        if (checkForCachedAuthentication(request, response, false)) {
            return true;
        }

        // Validate any credentials already included with this request
        Principal principal = null;
        String authorization = request.getHeader("authorization");
        DigestInfo digestInfo = new DigestInfo(getOpaque(), getNonceValidity(), getKey(), nonces, isValidateUri());
        if (authorization != null) {
            if (digestInfo.parse(request, authorization)) {
                if (digestInfo.validate(request, algorithms)) {
                    principal = digestInfo.authenticate(context.getRealm());
                }

                if (principal != null && !digestInfo.isNonceStale()) {
                    register(request, response, principal, HttpServletRequest.DIGEST_AUTH, digestInfo.getUsername(),
                            null);
                    return true;
                }
            }
        }

        // Send an "unauthorized" response and an appropriate challenge

        // Next, generate a nonce token (that is a token which is supposed
        // to be unique).
        String nonce = generateNonce(request);

        setAuthenticateHeader(request, response, nonce, principal != null && digestInfo.isNonceStale());
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
        return false;
    }


    @Override
    protected String getAuthMethod() {
        return HttpServletRequest.DIGEST_AUTH;
    }


    // ------------------------------------------------------ Protected Methods


    /**
     * Generate a unique token. The token is generated according to the following pattern. NOnceToken = Base64 (
     * NONCE_DIGEST ( client-IP ":" time-stamp ":" private-key ) ).
     *
     * @param request HTTP Servlet request
     *
     * @return The generated nonce
     */
    protected String generateNonce(Request request) {

        long currentTime = System.currentTimeMillis();

        synchronized (lastTimestampLock) {
            if (currentTime > lastTimestamp) {
                lastTimestamp = currentTime;
            } else {
                currentTime = ++lastTimestamp;
            }
        }

        String ipTimeKey = request.getRemoteAddr() + ":" + currentTime + ":" + getKey();

        // Note: The digest used to generate the nonce is independent of the the digest used for authentication.
        byte[] buffer = ConcurrentMessageDigest.digest(NONCE_DIGEST, ipTimeKey.getBytes(StandardCharsets.ISO_8859_1));
        String nonce = currentTime + ":" + HexUtils.toHexString(buffer);

        NonceInfo info = new NonceInfo(currentTime, getNonceCountWindowSize());
        synchronized (nonces) {
            nonces.put(nonce, info);
        }

        return nonce;
    }


    /**
     * Generates the WWW-Authenticate header(s) as per RFC 7616.
     *
     * @param request      HTTP Servlet request
     * @param response     HTTP Servlet response
     * @param nonce        nonce token
     * @param isNonceStale <code>true</code> to add a stale parameter
     */
    protected void setAuthenticateHeader(HttpServletRequest request, HttpServletResponse response, String nonce,
            boolean isNonceStale) {

        String realmName = getRealmName(context);

        boolean first = true;
        for (AuthDigest algorithm : algorithms) {
            StringBuilder authenticateHeader = new StringBuilder(200);
            authenticateHeader.append("Digest realm=\"");
            authenticateHeader.append(realmName);
            authenticateHeader.append("\", qop=\"");
            authenticateHeader.append(QOP);
            authenticateHeader.append("\", nonce=\"");
            authenticateHeader.append(nonce);
            authenticateHeader.append("\", opaque=\"");
            authenticateHeader.append(getOpaque());
            authenticateHeader.append("\"");
            if (isNonceStale) {
                authenticateHeader.append(", stale=true");
            }
            authenticateHeader.append(", algorithm=");
            authenticateHeader.append(algorithm.getRfcName());

            if (first) {
                response.setHeader(AUTH_HEADER_NAME, authenticateHeader.toString());
                first = false;
            } else {
                response.addHeader(AUTH_HEADER_NAME, authenticateHeader.toString());
            }
            /*
             * Note: userhash is not supported by this implementation so don't include it. The clients will use the
             * default of false.
             */
        }
    }


    @Override
    protected boolean isPreemptiveAuthPossible(Request request) {
        MessageBytes authorizationHeader = request.getCoyoteRequest().getMimeHeaders().getValue("authorization");
        return authorizationHeader != null && authorizationHeader.startsWithIgnoreCase("digest ", 0);
    }


    // ------------------------------------------------------- Lifecycle Methods

    @Override
    protected void startInternal() throws LifecycleException {
        super.startInternal();

        // Generate a random secret key
        if (getKey() == null) {
            setKey(sessionIdGenerator.generateSessionId());
        }

        // Generate the opaque string the same way
        if (getOpaque() == null) {
            setOpaque(sessionIdGenerator.generateSessionId());
        }

        /*
         * This is a FIFO cache as using an older nonce should not delay its removal from the cache in favour of more
         * recent values.
         */
        nonces = new LinkedHashMap<>() {

            private static final long serialVersionUID = 1L;
            private static final long LOG_SUPPRESS_TIME = 5 * 60 * 1000;

            private long lastLog = 0;

            @Override
            protected boolean removeEldestEntry(Map.Entry<String,NonceInfo> eldest) {
                // This is called from a sync so keep it simple
                long currentTime = System.currentTimeMillis();
                if (size() > getNonceCacheSize()) {
                    if (lastLog < currentTime && currentTime - eldest.getValue().getTimestamp() < getNonceValidity()) {
                        // Replay attack is possible
                        log.warn(sm.getString("digestAuthenticator.cacheRemove"));
                        lastLog = currentTime + LOG_SUPPRESS_TIME;
                    }
                    return true;
                }
                return false;
            }
        };

        initAlgorithms(algorithms);
        try {
            ConcurrentMessageDigest.init(NONCE_DIGEST);
        } catch (NoSuchAlgorithmException e) {
            // Not possible. NONCE_DIGEST uses an algorithm that JREs must support.
        }
    }


    public static class DigestInfo {

        private final String opaque;
        private final long nonceValidity;
        private final String key;
        private final Map<String,NonceInfo> nonces;
        private boolean validateUri = true;

        private String userName = null;
        private String method = null;
        private String uri = null;
        private String response = null;
        private String nonce = null;
        private String nc = null;
        private String cnonce = null;
        private String realmName = null;
        private String qop = null;
        private String opaqueReceived = null;

        private boolean nonceStale = false;
        private AuthDigest algorithm = null;


        public DigestInfo(String opaque, long nonceValidity, String key, Map<String,NonceInfo> nonces,
                boolean validateUri) {
            this.opaque = opaque;
            this.nonceValidity = nonceValidity;
            this.key = key;
            this.nonces = nonces;
            this.validateUri = validateUri;
        }


        public String getUsername() {
            return userName;
        }


        public boolean parse(Request request, String authorization) {
            // Validate the authorization credentials format
            if (authorization == null) {
                return false;
            }

            Map<String,String> directives;
            try {
                directives = Authorization.parseAuthorizationDigest(new StringReader(authorization));
            } catch (IOException e) {
                return false;
            }

            if (directives == null) {
                return false;
            }

            method = request.getMethod();
            userName = directives.get("username");
            realmName = directives.get("realm");
            nonce = directives.get("nonce");
            nc = directives.get("nc");
            cnonce = directives.get("cnonce");
            qop = directives.get("qop");
            uri = directives.get("uri");
            response = directives.get("response");
            opaqueReceived = directives.get("opaque");
            algorithm = PERMITTED_ALGORITHMS.get(directives.get("algorithm"));
            if (algorithm == null) {
                algorithm = FALLBACK_DIGEST;
            }

            return true;
        }

        public boolean validate(Request request, List<AuthDigest> algorithms) {
            if ((userName == null) || (realmName == null) || (nonce == null) || (uri == null) || (response == null)) {
                return false;
            }

            // Validate the URI - should match the request line sent by client
            if (validateUri) {
                String uriQuery;
                String query = request.getQueryString();
                if (query == null) {
                    uriQuery = request.getRequestURI();
                } else {
                    uriQuery = request.getRequestURI() + "?" + query;
                }
                if (!uri.equals(uriQuery)) {
                    // Some clients (older Android) use an absolute URI for
                    // DIGEST but a relative URI in the request line.
                    // request. 2.3.5 < fixed Android version <= 4.0.3
                    String host = request.getHeader("host");
                    String scheme = request.getScheme();
                    if (host != null && !uriQuery.startsWith(scheme)) {
                        StringBuilder absolute = new StringBuilder();
                        absolute.append(scheme);
                        absolute.append("://");
                        absolute.append(host);
                        absolute.append(uriQuery);
                        if (!uri.equals(absolute.toString())) {
                            return false;
                        }
                    } else {
                        return false;
                    }
                }
            }

            // Validate the Realm name
            String lcRealm = getRealmName(request.getContext());
            if (!lcRealm.equals(realmName)) {
                return false;
            }

            // Validate the opaque string
            if (!opaque.equals(opaqueReceived)) {
                return false;
            }

            // Validate nonce
            int i = nonce.indexOf(':');
            if (i < 0 || (i + 1) == nonce.length()) {
                return false;
            }
            long nonceTime;
            try {
                nonceTime = Long.parseLong(nonce.substring(0, i));
            } catch (NumberFormatException nfe) {
                return false;
            }
            String digestclientIpTimeKey = nonce.substring(i + 1);
            long currentTime = System.currentTimeMillis();
            if ((currentTime - nonceTime) > nonceValidity) {
                nonceStale = true;
                synchronized (nonces) {
                    nonces.remove(nonce);
                }
            }
            String serverIpTimeKey = request.getRemoteAddr() + ":" + nonceTime + ":" + key;
            // Note: The digest used to generate the nonce is independent of the the digest used for authentication/
            byte[] buffer =
                    ConcurrentMessageDigest.digest(NONCE_DIGEST, serverIpTimeKey.getBytes(StandardCharsets.ISO_8859_1));
            String digestServerIpTimeKey = HexUtils.toHexString(buffer);
            if (!digestServerIpTimeKey.equals(digestclientIpTimeKey)) {
                return false;
            }

            // Validate qop
            if (qop != null && !QOP.equals(qop)) {
                return false;
            }

            // Validate cnonce and nc
            // Check if presence of nc and Cnonce is consistent with presence of qop
            if (qop == null) {
                if (cnonce != null || nc != null) {
                    return false;
                }
            } else {
                if (cnonce == null || nc == null) {
                    return false;
                }
                // RFC 2617 says nc must be 8 digits long. Older Android clients
                // use 6. 2.3.5 < fixed Android version <= 4.0.3
                if (nc.length() < 6 || nc.length() > 8) {
                    return false;
                }
                long count;
                try {
                    count = Long.parseLong(nc, 16);
                } catch (NumberFormatException nfe) {
                    return false;
                }
                NonceInfo info;
                synchronized (nonces) {
                    info = nonces.get(nonce);
                }
                if (info == null) {
                    // Nonce is valid but not in cache. It must have dropped out
                    // of the cache - force a re-authentication
                    nonceStale = true;
                } else {
                    if (!info.nonceCountValid(count)) {
                        return false;
                    }
                }
            }

            // Validate algorithm is one of the algorithms configured for the authenticator
            if (!algorithms.contains(algorithm)) {
                return false;
            }

            return true;
        }

        public boolean isNonceStale() {
            return nonceStale;
        }

        public Principal authenticate(Realm realm) {
            String a2 = method + ":" + uri;

            byte[] buffer =
                    ConcurrentMessageDigest.digest(algorithm.getJavaName(), a2.getBytes(StandardCharsets.ISO_8859_1));
            String digestA2 = HexUtils.toHexString(buffer);

            return realm.authenticate(userName, response, nonce, nc, cnonce, qop, realmName, digestA2,
                    algorithm.getJavaName());
        }

    }

    public static class NonceInfo {
        private final long timestamp;
        private final boolean seen[];
        private final int offset;
        private int count = 0;

        public NonceInfo(long currentTime, int seenWindowSize) {
            this.timestamp = currentTime;
            seen = new boolean[seenWindowSize];
            offset = seenWindowSize / 2;
        }

        public synchronized boolean nonceCountValid(long nonceCount) {
            if ((count - offset) >= nonceCount || (nonceCount > count - offset + seen.length)) {
                return false;
            }
            int checkIndex = (int) ((nonceCount + offset) % seen.length);
            if (seen[checkIndex]) {
                return false;
            } else {
                seen[checkIndex] = true;
                seen[count % seen.length] = false;
                count++;
                return true;
            }
        }

        public long getTimestamp() {
            return timestamp;
        }
    }


    /**
     * This enum exists because RFC 7616 and Java use different names for some digests.
     */
    public enum AuthDigest {

        MD5("MD5", "MD5"),
        SHA_256("SHA-256", "SHA-256"),
        SHA_512_256("SHA-512/256", "SHA-512-256");

        private final String javaName;
        private final String rfcName;

        AuthDigest(String javaName, String rfcName) {
            this.javaName = javaName;
            this.rfcName = rfcName;
        }

        public String getJavaName() {
            return javaName;
        }

        public String getRfcName() {
            return rfcName;
        }
    }
}