/*
 * Copyright 2014-2021 the original author or authors.
 *
 * 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.session.web.http;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
import java.util.BitSet;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * The default implementation of {@link CookieSerializer}.
 *
 * @author Rob Winch
 * @author Vedran Pavic
 * @author Eddú Meléndez
 * @since 1.1
 */
public class DefaultCookieSerializer implements CookieSerializer {

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

	private static final BitSet domainValid = new BitSet(128);

	static {
		for (char c = '0'; c <= '9'; c++) {
			domainValid.set(c);
		}
		for (char c = 'a'; c <= 'z'; c++) {
			domainValid.set(c);
		}
		for (char c = 'A'; c <= 'Z'; c++) {
			domainValid.set(c);
		}
		domainValid.set('.');
		domainValid.set('-');
	}

	private Clock clock = Clock.systemUTC();

	private String cookieName = "SESSION";

	private Boolean useSecureCookie;

	private boolean useHttpOnlyCookie = true;

	private String cookiePath;

	private Integer cookieMaxAge;

	private String domainName;

	private Pattern domainNamePattern;

	private String jvmRoute;

	private boolean useBase64Encoding = true;

	private String rememberMeRequestAttribute;

	private String sameSite = "Lax";

	/*
	 * (non-Javadoc)
	 *
	 * @see org.springframework.session.web.http.CookieSerializer#readCookieValues(javax.
	 * servlet.http.HttpServletRequest)
	 */
	@Override
	public List<String> readCookieValues(HttpServletRequest request) {
		Cookie[] cookies = request.getCookies();
		List<String> matchingCookieValues = new ArrayList<>();
		if (cookies != null) {
			for (Cookie cookie : cookies) {
				if (this.cookieName.equals(cookie.getName())) {
					String sessionId = (this.useBase64Encoding ? base64Decode(cookie.getValue()) : cookie.getValue());
					if (sessionId == null) {
						continue;
					}
					if (this.jvmRoute != null && sessionId.endsWith(this.jvmRoute)) {
						sessionId = sessionId.substring(0, sessionId.length() - this.jvmRoute.length());
					}
					matchingCookieValues.add(sessionId);
				}
			}
		}
		return matchingCookieValues;
	}

	/*
	 * (non-Javadoc)
	 *
	 * @see org.springframework.session.web.http.CookieWriter#writeCookieValue(org.
	 * springframework.session.web.http.CookieWriter.CookieValue)
	 */
	@Override
	public void writeCookieValue(CookieValue cookieValue) {
		HttpServletRequest request = cookieValue.getRequest();
		HttpServletResponse response = cookieValue.getResponse();
		StringBuilder sb = new StringBuilder();
		sb.append(this.cookieName).append('=');
		String value = getValue(cookieValue);
		if (value != null && value.length() > 0) {
			validateValue(value);
			sb.append(value);
		}
		int maxAge = getMaxAge(cookieValue);
		if (maxAge > -1) {
			sb.append("; Max-Age=").append(cookieValue.getCookieMaxAge());
			ZonedDateTime expires = (maxAge != 0) ? ZonedDateTime.now(this.clock).plusSeconds(maxAge)
					: Instant.EPOCH.atZone(ZoneOffset.UTC);
			sb.append("; Expires=").append(expires.format(DateTimeFormatter.RFC_1123_DATE_TIME));
		}
		String domain = getDomainName(request);
		if (domain != null && domain.length() > 0) {
			validateDomain(domain);
			sb.append("; Domain=").append(domain);
		}
		String path = getCookiePath(request);
		if (path != null && path.length() > 0) {
			validatePath(path);
			sb.append("; Path=").append(path);
		}
		if (isSecureCookie(request)) {
			sb.append("; Secure");
		}
		if (this.useHttpOnlyCookie) {
			sb.append("; HttpOnly");
		}
		if (this.sameSite != null) {
			sb.append("; SameSite=").append(this.sameSite);
		}
		response.addHeader("Set-Cookie", sb.toString());
	}

	/**
	 * Decode the value using Base64.
	 * @param base64Value the Base64 String to decode
	 * @return the Base64 decoded value
	 * @since 1.2.2
	 */
	private String base64Decode(String base64Value) {
		try {
			byte[] decodedCookieBytes = Base64.getDecoder().decode(base64Value);
			return new String(decodedCookieBytes);
		}
		catch (Exception ex) {
			logger.debug("Unable to Base64 decode value: " + base64Value);
			return null;
		}
	}

	/**
	 * Encode the value using Base64.
	 * @param value the String to Base64 encode
	 * @return the Base64 encoded value
	 * @since 1.2.2
	 */
	private String base64Encode(String value) {
		byte[] encodedCookieBytes = Base64.getEncoder().encode(value.getBytes());
		return new String(encodedCookieBytes);
	}

	private String getValue(CookieValue cookieValue) {
		String requestedCookieValue = cookieValue.getCookieValue();
		String actualCookieValue = requestedCookieValue;
		if (this.jvmRoute != null) {
			actualCookieValue = requestedCookieValue + this.jvmRoute;
		}
		if (this.useBase64Encoding) {
			actualCookieValue = base64Encode(actualCookieValue);
		}
		return actualCookieValue;
	}

	private void validateValue(String value) {
		int start = 0;
		int end = value.length();
		if ((end > 1) && (value.charAt(0) == '"') && (value.charAt(end - 1) == '"')) {
			start = 1;
			end--;
		}
		char[] chars = value.toCharArray();
		for (int i = start; i < end; i++) {
			char c = chars[i];
			if (c < 0x21 || c == 0x22 || c == 0x2c || c == 0x3b || c == 0x5c || c == 0x7f) {
				throw new IllegalArgumentException("Invalid character in cookie value: " + c);
			}
		}
	}

	private int getMaxAge(CookieValue cookieValue) {
		int maxAge = cookieValue.getCookieMaxAge();
		if (maxAge < 0) {
			if (this.rememberMeRequestAttribute != null
					&& cookieValue.getRequest().getAttribute(this.rememberMeRequestAttribute) != null) {
				// the cookie is only written at time of session creation, so we rely on
				// session expiration rather than cookie expiration if remember me is
				// enabled
				cookieValue.setCookieMaxAge(Integer.MAX_VALUE);
			}
			else if (this.cookieMaxAge != null) {
				cookieValue.setCookieMaxAge(this.cookieMaxAge);
			}
		}
		return cookieValue.getCookieMaxAge();
	}

	private void validateDomain(String domain) {
		int i = 0;
		int cur = -1;
		int prev;
		char[] chars = domain.toCharArray();
		while (i < chars.length) {
			prev = cur;
			cur = chars[i];
			if (!domainValid.get(cur) || ((prev == '.' || prev == -1) && (cur == '.' || cur == '-'))
					|| (prev == '-' && cur == '.')) {
				throw new IllegalArgumentException("Invalid cookie domain: " + domain);
			}
			i++;
		}
		if (cur == '.' || cur == '-') {
			throw new IllegalArgumentException("Invalid cookie domain: " + domain);
		}
	}

	private void validatePath(String path) {
		for (char ch : path.toCharArray()) {
			if (ch < 0x20 || ch > 0x7E || ch == ';') {
				throw new IllegalArgumentException("Invalid cookie path: " + path);
			}
		}
	}

	void setClock(Clock clock) {
		this.clock = clock.withZone(ZoneOffset.UTC);
	}

	/**
	 * Sets if a Cookie marked as secure should be used. The default is to use the value
	 * of {@link HttpServletRequest#isSecure()}.
	 * @param useSecureCookie determines if the cookie should be marked as secure.
	 */
	public void setUseSecureCookie(boolean useSecureCookie) {
		this.useSecureCookie = useSecureCookie;
	}

	/**
	 * Sets if a Cookie marked as HTTP Only should be used. The default is true.
	 * @param useHttpOnlyCookie determines if the cookie should be marked as HTTP Only.
	 */
	public void setUseHttpOnlyCookie(boolean useHttpOnlyCookie) {
		this.useHttpOnlyCookie = useHttpOnlyCookie;
	}

	private boolean isSecureCookie(HttpServletRequest request) {
		if (this.useSecureCookie == null) {
			return request.isSecure();
		}
		return this.useSecureCookie;
	}

	/**
	 * Sets the path of the Cookie. The default is to use the context path from the
	 * {@link HttpServletRequest}.
	 * @param cookiePath the path of the Cookie. If null, the default of the context path
	 * will be used.
	 */
	public void setCookiePath(String cookiePath) {
		this.cookiePath = cookiePath;
	}

	public void setCookieName(String cookieName) {
		if (cookieName == null) {
			throw new IllegalArgumentException("cookieName cannot be null");
		}
		this.cookieName = cookieName;
	}

	/**
	 * Sets the maxAge property of the Cookie. The default is to delete the cookie when
	 * the browser is closed.
	 * @param cookieMaxAge the maxAge property of the Cookie (defined in seconds)
	 */
	public void setCookieMaxAge(int cookieMaxAge) {
		this.cookieMaxAge = cookieMaxAge;
	}

	/**
	 * Sets an explicit Domain Name. This allow the domain of "example.com" to be used
	 * when the request comes from www.example.com. This allows for sharing the cookie
	 * across subdomains. The default is to use the current domain.
	 * @param domainName the name of the domain to use. (i.e. "example.com")
	 * @throws IllegalStateException if the domainNamePattern is also set
	 */
	public void setDomainName(String domainName) {
		if (this.domainNamePattern != null) {
			throw new IllegalStateException("Cannot set both domainName and domainNamePattern");
		}
		this.domainName = domainName;
	}

	/**
	 * <p>
	 * Sets a case insensitive pattern used to extract the domain name from the
	 * {@link HttpServletRequest#getServerName()}. The pattern should provide a single
	 * grouping that defines what the value is that should be matched. User's should be
	 * careful not to output malicious characters like new lines to prevent from things
	 * like <a href= "https://www.owasp.org/index.php/HTTP_Response_Splitting">HTTP
	 * Response Splitting</a>.
	 * </p>
	 *
	 * <p>
	 * If the pattern does not match, then no domain will be set. This is useful to ensure
	 * the domain is not set during development when localhost might be used.
	 * </p>
	 * <p>
	 * An example value might be "^.+?\\.(\\w+\\.[a-z]+)$". For the given input, it would
	 * provide the following explicit domain (null means no domain name is set):
	 * </p>
	 *
	 * <ul>
	 * <li>example.com - null</li>
	 * <li>child.sub.example.com - example.com</li>
	 * <li>localhost - null</li>
	 * <li>127.0.1.1 - null</li>
	 * </ul>
	 * @param domainNamePattern the case insensitive pattern to extract the domain name
	 * with
	 * @throws IllegalStateException if the domainName is also set
	 */
	public void setDomainNamePattern(String domainNamePattern) {
		if (this.domainName != null) {
			throw new IllegalStateException("Cannot set both domainName and domainNamePattern");
		}
		this.domainNamePattern = Pattern.compile(domainNamePattern, Pattern.CASE_INSENSITIVE);
	}

	/**
	 * <p>
	 * Used to identify which JVM to route to for session affinity. With some
	 * implementations (i.e. Redis) this provides no performance benefit. However, this
	 * can help with tracing logs of a particular user. This will ensure that the value of
	 * the cookie is formatted as
	 * </p>
	 * <code>
	 * sessionId + "." jvmRoute
	 * </code>
	 * <p>
	 * To use set a custom route on each JVM instance and setup a frontend proxy to
	 * forward all requests to the JVM based on the route.
	 * </p>
	 * @param jvmRoute the JVM Route to use (i.e. "node01jvmA", "n01ja", etc)
	 */
	public void setJvmRoute(String jvmRoute) {
		this.jvmRoute = "." + jvmRoute;
	}

	/**
	 * Set if the Base64 encoding of cookie value should be used. This is valuable in
	 * order to support <a href="https://tools.ietf.org/html/rfc6265">RFC 6265</a> which
	 * recommends using Base 64 encoding to the cookie value.
	 * @param useBase64Encoding the flag to indicate whether to use Base64 encoding
	 */
	public void setUseBase64Encoding(boolean useBase64Encoding) {
		this.useBase64Encoding = useBase64Encoding;
	}

	/**
	 * Set the request attribute name that indicates remember-me login. If specified, the
	 * cookie will be written as Integer.MAX_VALUE.
	 * @param rememberMeRequestAttribute the remember-me request attribute name
	 * @since 1.3.0
	 */
	public void setRememberMeRequestAttribute(String rememberMeRequestAttribute) {
		if (rememberMeRequestAttribute == null) {
			throw new IllegalArgumentException("rememberMeRequestAttribute cannot be null");
		}
		this.rememberMeRequestAttribute = rememberMeRequestAttribute;
	}

	/**
	 * Set the value for the {@code SameSite} cookie directive. The default value is
	 * {@code Lax}.
	 * @param sameSite the SameSite directive value
	 * @since 2.1.0
	 */
	public void setSameSite(String sameSite) {
		this.sameSite = sameSite;
	}

	private String getDomainName(HttpServletRequest request) {
		if (this.domainName != null) {
			return this.domainName;
		}
		if (this.domainNamePattern != null) {
			Matcher matcher = this.domainNamePattern.matcher(request.getServerName());
			if (matcher.matches()) {
				return matcher.group(1);
			}
		}
		return null;
	}

	private String getCookiePath(HttpServletRequest request) {
		if (this.cookiePath == null) {
			String contextPath = request.getContextPath();
			return (contextPath != null && contextPath.length() > 0) ? contextPath : "/";
		}
		return this.cookiePath;
	}

}
