/*
 * Copyright 2004, 2005, 2006, 2009 Acegi Technology Pty Limited
 *
 * Licensed 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
 *
 *      https://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.springframework.security.web.authentication.www;

import java.io.IOException;
import java.util.Base64;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jspecify.annotations.Nullable;

import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.core.log.LogMessage;
import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.core.userdetails.UserCache;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.core.userdetails.cache.NullUserCache;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;

/**
 * Processes a HTTP request's Digest authorization headers, putting the result into the
 * <code>SecurityContextHolder</code>.
 * <p>
 * For a detailed background on what this filter is designed to process, refer to
 * <a href="https://www.ietf.org/rfc/rfc2617.txt">RFC 2617</a> (which superseded RFC 2069,
 * although this filter support clients that implement either RFC 2617 or RFC 2069).
 * <p>
 * This filter can be used to provide Digest authentication services to both remoting
 * protocol clients (such as Hessian and SOAP) as well as standard user agents (such as
 * Internet Explorer and FireFox).
 * <p>
 * This Digest implementation has been designed to avoid needing to store session state
 * between invocations. All session management information is stored in the "nonce" that
 * is sent to the client by the {@link DigestAuthenticationEntryPoint}.
 * <p>
 * If authentication is successful, the resulting
 * {@link org.springframework.security.core.Authentication Authentication} object will be
 * placed into the <code>SecurityContextHolder</code>.
 * <p>
 * If authentication fails, an
 * {@link org.springframework.security.web.AuthenticationEntryPoint
 * AuthenticationEntryPoint} implementation is called. This must always be
 * {@link DigestAuthenticationEntryPoint}, which will prompt the user to authenticate
 * again via Digest authentication.
 * <p>
 * Note there are limitations to Digest authentication, although it is a more
 * comprehensive and secure solution than Basic authentication. Please see RFC 2617
 * section 4 for a full discussion on the advantages of Digest authentication over Basic
 * authentication, including commentary on the limitations that it still imposes.
 *
 * @author Ben Alex
 * @author Luke Taylor
 * @since 1.0.0
 */
public class DigestAuthenticationFilter extends GenericFilterBean implements MessageSourceAware {

	private static final Log logger = LogFactory.getLog(DigestAuthenticationFilter.class);

	private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
		.getContextHolderStrategy();

	private AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();

	@SuppressWarnings("NullAway.Init")
	private DigestAuthenticationEntryPoint authenticationEntryPoint;

	protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();

	private UserCache userCache = new NullUserCache();

	@SuppressWarnings("NullAway.Init")
	private UserDetailsService userDetailsService;

	private boolean passwordAlreadyEncoded = false;

	private boolean createAuthenticatedToken = false;

	private SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository();

	@Override
	public void afterPropertiesSet() {
		Assert.notNull(this.userDetailsService, "A UserDetailsService is required");
		Assert.notNull(this.authenticationEntryPoint, "A DigestAuthenticationEntryPoint is required");
	}

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
	}

	private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		String header = request.getHeader("Authorization");
		if (header == null || !header.startsWith("Digest ")) {
			chain.doFilter(request, response);
			return;
		}
		logger.debug(LogMessage.format("Digest Authorization header received from user agent: %s", header));
		DigestData digestAuth = new DigestData(header);
		try {
			digestAuth.validateAndDecode(this.authenticationEntryPoint.getKey(),
					this.authenticationEntryPoint.getRealmName());
		}
		catch (BadCredentialsException ex) {
			fail(request, response, ex);
			return;
		}
		// Lookup password for presented username. N.B. DAO-provided password MUST be
		// clear text - not encoded/salted (unless this instance's passwordAlreadyEncoded
		// property is 'false')
		boolean cacheWasUsed = true;
		// username was validated as non-null in digestAuth validateAndDecode
		String username = Objects.requireNonNull(digestAuth.getUsername());
		UserDetails user = this.userCache.getUserFromCache(username);
		String serverDigestMd5;
		try {
			if (user == null) {
				cacheWasUsed = false;
				user = this.userDetailsService.loadUserByUsername(username);
				if (user == null) {
					throw new AuthenticationServiceException(
							"AuthenticationDao returned null, which is an interface contract violation");
				}
				this.userCache.putUserInCache(user);
			}
			serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod());
			// If digest is incorrect, try refreshing from backend and recomputing
			if (!serverDigestMd5.equals(digestAuth.getResponse()) && cacheWasUsed) {
				logger.debug("Digest comparison failure; trying to refresh user from DAO in case password had changed");
				user = this.userDetailsService.loadUserByUsername(username);
				this.userCache.putUserInCache(user);
				serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod());
			}
		}
		catch (UsernameNotFoundException ex) {
			String message = this.messages.getMessage("DigestAuthenticationFilter.usernameNotFound",
					new Object[] { username }, "Username {0} not found");
			fail(request, response, new BadCredentialsException(message));
			return;
		}
		// If digest is still incorrect, definitely reject authentication attempt
		if (!serverDigestMd5.equals(digestAuth.getResponse())) {
			logger.debug(LogMessage.format(
					"Expected response: '%s' but received: '%s'; is AuthenticationDao returning clear text passwords?",
					serverDigestMd5, digestAuth.getResponse()));
			String message = this.messages.getMessage("DigestAuthenticationFilter.incorrectResponse",
					"Incorrect response");
			fail(request, response, new BadCredentialsException(message));
			return;
		}
		// To get this far, the digest must have been valid
		// Check the nonce has not expired
		// We do this last so we can direct the user agent its nonce is stale
		// but the request was otherwise appearing to be valid
		if (digestAuth.isNonceExpired()) {
			String message = this.messages.getMessage("DigestAuthenticationFilter.nonceExpired",
					"Nonce has expired/timed out");
			fail(request, response, new NonceExpiredException(message));
			return;
		}
		logger.debug(LogMessage.format("Authentication success for user: '%s' with response: '%s'", username,
				digestAuth.getResponse()));
		Authentication authentication = createSuccessfulAuthentication(request, user);
		SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
		context.setAuthentication(authentication);
		this.securityContextHolderStrategy.setContext(context);
		this.securityContextRepository.saveContext(context, request, response);
		chain.doFilter(request, response);
	}

	private Authentication createSuccessfulAuthentication(HttpServletRequest request, UserDetails user) {
		UsernamePasswordAuthenticationToken authRequest = getAuthRequest(user);
		authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
		return authRequest;
	}

	private UsernamePasswordAuthenticationToken getAuthRequest(UserDetails user) {
		if (this.createAuthenticatedToken) {
			return UsernamePasswordAuthenticationToken.authenticated(user, user.getPassword(), user.getAuthorities());
		}
		return UsernamePasswordAuthenticationToken.unauthenticated(user, user.getPassword());
	}

	private void fail(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed)
			throws IOException, ServletException {
		SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
		this.securityContextHolderStrategy.setContext(context);
		logger.debug(failed);
		this.authenticationEntryPoint.commence(request, response, failed);
	}

	protected final DigestAuthenticationEntryPoint getAuthenticationEntryPoint() {
		return this.authenticationEntryPoint;
	}

	public UserCache getUserCache() {
		return this.userCache;
	}

	public @Nullable UserDetailsService getUserDetailsService() {
		return this.userDetailsService;
	}

	public void setAuthenticationDetailsSource(
			AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource) {
		Assert.notNull(authenticationDetailsSource, "AuthenticationDetailsSource required");
		this.authenticationDetailsSource = authenticationDetailsSource;
	}

	public void setAuthenticationEntryPoint(DigestAuthenticationEntryPoint authenticationEntryPoint) {
		this.authenticationEntryPoint = authenticationEntryPoint;
	}

	@Override
	public void setMessageSource(MessageSource messageSource) {
		this.messages = new MessageSourceAccessor(messageSource);
	}

	public void setPasswordAlreadyEncoded(boolean passwordAlreadyEncoded) {
		this.passwordAlreadyEncoded = passwordAlreadyEncoded;
	}

	public void setUserCache(UserCache userCache) {
		this.userCache = userCache;
	}

	public void setUserDetailsService(UserDetailsService userDetailsService) {
		this.userDetailsService = userDetailsService;
	}

	/**
	 * If you set this property, the Authentication object, which is created after the
	 * successful digest authentication will be marked as <b>authenticated</b> and filled
	 * with the authorities loaded by the UserDetailsService. It therefore will not be
	 * re-authenticated by your AuthenticationProvider. This means, that only the password
	 * of the user is checked, but not the flags like isEnabled() or
	 * isAccountNonExpired(). You will save some time by enabling this flag, as otherwise
	 * your UserDetailsService will be called twice. A more secure option would be to
	 * introduce a cache around your UserDetailsService, but if you don't use these flags,
	 * you can also safely enable this option.
	 * @param createAuthenticatedToken default is false
	 */
	public void setCreateAuthenticatedToken(boolean createAuthenticatedToken) {
		this.createAuthenticatedToken = createAuthenticatedToken;
	}

	/**
	 * Sets the {@link SecurityContextRepository} to save the {@link SecurityContext} on
	 * authentication success. The default action is not to save the
	 * {@link SecurityContext}.
	 * @param securityContextRepository the {@link SecurityContextRepository} to use.
	 * Cannot be null.
	 */
	public void setSecurityContextRepository(SecurityContextRepository securityContextRepository) {
		Assert.notNull(securityContextRepository, "securityContextRepository cannot be null");
		this.securityContextRepository = securityContextRepository;
	}

	/**
	 * Sets the {@link SecurityContextHolderStrategy} to use. The default action is to use
	 * the {@link SecurityContextHolderStrategy} stored in {@link SecurityContextHolder}.
	 *
	 * @since 5.8
	 */
	public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) {
		Assert.notNull(securityContextHolderStrategy, "securityContextHolderStrategy cannot be null");
		this.securityContextHolderStrategy = securityContextHolderStrategy;
	}

	private class DigestData {

		private final @Nullable String username;

		private final @Nullable String realm;

		private final @Nullable String nonce;

		private final @Nullable String uri;

		private final @Nullable String response;

		private final @Nullable String qop;

		private final @Nullable String nc;

		private final @Nullable String cnonce;

		private final String section212response;

		private long nonceExpiryTime;

		DigestData(String header) {
			this.section212response = header.substring(7);
			String[] headerEntries = DigestAuthUtils.splitIgnoringQuotes(this.section212response, ',');
			Map<String, String> headerMap = DigestAuthUtils.splitEachArrayElementAndCreateMap(headerEntries, "=", "\"");
			if (headerMap == null) {
				headerMap = Collections.emptyMap();
			}
			this.username = headerMap.get("username");
			this.realm = headerMap.get("realm");
			this.nonce = headerMap.get("nonce");
			this.uri = headerMap.get("uri");
			this.response = headerMap.get("response");
			this.qop = headerMap.get("qop"); // RFC 2617 extension
			this.nc = headerMap.get("nc"); // RFC 2617 extension
			this.cnonce = headerMap.get("cnonce"); // RFC 2617 extension
			logger.debug(
					LogMessage.format("Extracted username: '%s'; realm: '%s'; nonce: '%s'; uri: '%s'; response: '%s'",
							this.username, this.realm, this.nonce, this.uri, this.response));
		}

		void validateAndDecode(@Nullable String entryPointKey, @Nullable String expectedRealm)
				throws BadCredentialsException {
			// Check all required parameters were supplied (ie RFC 2069)
			if ((this.username == null) || (this.realm == null) || (this.nonce == null) || (this.uri == null)
					|| (this.response == null)) {
				throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
						"DigestAuthenticationFilter.missingMandatory", new Object[] { this.section212response },
						"Missing mandatory digest value; received header {0}"));
			}
			// Check all required parameters for an "auth" qop were supplied (ie RFC 2617)
			if ("auth".equals(this.qop)) {
				if ((this.nc == null) || (this.cnonce == null)) {
					logger.debug(LogMessage.format("extracted nc: '%s'; cnonce: '%s'", this.nc, this.cnonce));
					throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
							"DigestAuthenticationFilter.missingAuth", new Object[] { this.section212response },
							"Missing mandatory digest value; received header {0}"));
				}
			}
			// Check realm name equals what we expected
			if (!this.realm.equals(expectedRealm)) {
				throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
						"DigestAuthenticationFilter.incorrectRealm", new Object[] { this.realm, expectedRealm },
						"Response realm name '{0}' does not match system realm name of '{1}'"));
			}
			// Check nonce was Base64 encoded (as sent by DigestAuthenticationEntryPoint)
			final byte[] nonceBytes;
			try {
				nonceBytes = Base64.getDecoder().decode(this.nonce.getBytes());
			}
			catch (IllegalArgumentException ex) {
				throw new BadCredentialsException(
						DigestAuthenticationFilter.this.messages.getMessage("DigestAuthenticationFilter.nonceEncoding",
								new Object[] { this.nonce }, "Nonce is not encoded in Base64; received nonce {0}"));
			}
			// Decode nonce from Base64 format of nonce is: base64(expirationTime + ":" +
			// md5Hex(expirationTime + ":" + key))
			String nonceAsPlainText = new String(nonceBytes);
			String[] nonceTokens = StringUtils.delimitedListToStringArray(nonceAsPlainText, ":");
			if (nonceTokens.length != 2) {
				throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
						"DigestAuthenticationFilter.nonceNotTwoTokens", new Object[] { nonceAsPlainText },
						"Nonce should have yielded two tokens but was {0}"));
			}
			// Extract expiry time from nonce
			try {
				this.nonceExpiryTime = Long.valueOf(nonceTokens[0]);
			}
			catch (NumberFormatException nfe) {
				throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
						"DigestAuthenticationFilter.nonceNotNumeric", new Object[] { nonceAsPlainText },
						"Nonce token should have yielded a numeric first token, but was {0}"));
			}
			// Check signature of nonce matches this expiry time
			String expectedNonceSignature = DigestAuthUtils.md5Hex(this.nonceExpiryTime + ":" + entryPointKey);
			if (!expectedNonceSignature.equals(nonceTokens[1])) {
				throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
						"DigestAuthenticationFilter.nonceCompromised", new Object[] { nonceAsPlainText },
						"Nonce token compromised {0}"));
			}
		}

		String calculateServerDigest(@Nullable String password, String httpMethod) {
			// Compute the expected response-digest (will be in hex form). Don't catch
			// IllegalArgumentException (already checked validity)
			return DigestAuthUtils.generateDigest(DigestAuthenticationFilter.this.passwordAlreadyEncoded, this.username,
					this.realm, password, httpMethod, this.uri, this.qop, this.nonce, this.nc, this.cnonce);
		}

		boolean isNonceExpired() {
			long now = System.currentTimeMillis();
			return this.nonceExpiryTime < now;
		}

		@Nullable String getUsername() {
			return this.username;
		}

		@Nullable String getResponse() {
			return this.response;
		}

	}

}
