/** * 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.jooby; import com.google.common.base.Splitter; import com.google.common.base.Strings; import com.google.common.io.BaseEncoding; import javaslang.Tuple; import javaslang.Tuple2; import javaslang.control.Try; import org.jooby.internal.CookieImpl; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.Iterator; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.function.Function; import java.util.regex.Pattern; import java.util.stream.Collectors; import static java.util.Objects.requireNonNull; /** * Creates a cookie, a small amount of information sent by a server to * a Web browser, saved by the browser, and later sent back to the server. * A cookie's value can uniquely * identify a client, so cookies are commonly used for session management. * * <p> * A cookie has a name, a single value, and optional attributes such as a comment, path and domain * qualifiers, a maximum age, and a version number. * </p> * * <p> * The server sends cookies to the browser by using the {@link Response#cookie(Cookie)} method, * which adds fields to HTTP response headers to send cookies to the browser, one at a time. The * browser is expected to support 20 cookies for each Web server, 300 cookies total, and may limit * cookie size to 4 KB each. * </p> * * <p> * The browser returns cookies to the server by adding fields to HTTP request headers. Cookies can * be retrieved from a request by using the {@link Request#cookie(String)} method. Several cookies * might have the same name but different path attributes. * </p> * * <p> * This class supports both the Version 0 (by Netscape) and Version 1 (by RFC 2109) cookie * specifications. By default, cookies are created using Version 0 to ensure the best * interoperability. * </p> * * @author edgar and various * @since 0.1.0 */ public interface Cookie { /** * Decode a cookie value using, like: <code>k=v</code>, multiple <code>k=v</code> pair are * separated by <code>&</code>. Also, <code>k</code> and <code>v</code> are decoded using * {@link URLDecoder}. */ public static final Function<String, Map<String, String>> URL_DECODER = value -> { if (value == null) { return Collections.emptyMap(); } Function<String, String> decode = v -> Try .of(() -> URLDecoder.decode(v, StandardCharsets.UTF_8.name())) .get(); return Splitter.on('&') .trimResults() .omitEmptyStrings() .splitToList(value) .stream() .map(v -> { Iterator<String> it = Splitter.on('=').trimResults().omitEmptyStrings() .split(v) .iterator(); Tuple2<String, String> t2 = Tuple .of(decode.apply(it.next()), it.hasNext() ? decode.apply(it.next()) : null); return t2; }) .filter(it -> Objects.nonNull(it._2)) .collect(Collectors.toMap(it -> it._1, it -> it._2)); }; /** * Encode a hash into cookie value, like: <code>k1=v1&...&kn=vn</code>. Also, * <code>key</code> and <code>value</code> are encoded using {@link URLEncoder}. */ public static final Function<Map<String, String>, String> URL_ENCODER = value -> { Function<String, String> encode = v -> Try .of(() -> URLEncoder.encode(v, StandardCharsets.UTF_8.name())).get(); return value.entrySet().stream() .map(e -> new StringBuilder() .append(encode.apply(e.getKey())) .append('=') .append(encode.apply(e.getValue()))) .collect(Collectors.joining("&")) .toString(); }; /** * Build a {@link Cookie}. * * @author edgar * @since 0.1.0 */ class Definition { /** Cookie's name. */ private String name; /** Cookie's value. */ private String value; /** Cookie's domain. */ private String domain; /** Cookie's path. */ private String path; /** Cookie's comment. */ private String comment; /** HttpOnly flag. */ private Boolean httpOnly; /** True, ensure that the session cookie is only transmitted via HTTPS. */ private Boolean secure; /** * By default, <code>-1</code> is returned, which indicates that the cookie will persist until * browser shutdown. */ private Integer maxAge; /** * Creates a new {@link Definition cookie's definition}. */ protected Definition() { } /** * Clone a new {@link Definition cookie's definition}. * * @param def A cookie's definition. */ public Definition(final Definition def) { this.comment = def.comment; this.domain = def.domain; this.httpOnly = def.httpOnly; this.maxAge = def.maxAge; this.name = def.name; this.path = def.path; this.secure = def.secure; this.value = def.value; } /** * Creates a new {@link Definition cookie's definition}. * * @param name Cookie's name. * @param value Cookie's value. */ public Definition(final String name, final String value) { name(name); value(value); } /** * Creates a new {@link Definition cookie's definition}. * * @param name Cookie's name. */ public Definition(final String name) { name(name); } /** * Produces a cookie from current definition. * * @return A new cookie. */ public Cookie toCookie() { return new CookieImpl(this); } @Override public String toString() { return toCookie().encode(); } /** * Set/Override the cookie's name. * * @param name A cookie's name. * @return This definition. */ public Definition name(final String name) { this.name = requireNonNull(name, "A cookie name is required."); return this; } /** * @return Cookie's name. */ public Optional<String> name() { return Optional.ofNullable(name); } /** * Set the cookie's value. * * @param value A value. * @return This definition. */ public Definition value(final String value) { this.value = requireNonNull(value, "A cookie value is required."); return this; } /** * @return Cookie's value. */ public Optional<String> value() { if (Strings.isNullOrEmpty(value)) { return Optional.empty(); } return Optional.of(value); } /** * Set the cookie's domain. * * @param domain Cookie's domain. * @return This definition. */ public Definition domain(final String domain) { this.domain = requireNonNull(domain, "A cookie domain is required."); return this; } /** * @return A cookie's domain. */ public Optional<String> domain() { return Optional.ofNullable(domain); } /** * Set the cookie's path. * * @param path Cookie's path. * @return This definition. */ public Definition path(final String path) { this.path = requireNonNull(path, "A cookie path is required."); return this; } /** * @return Get cookie's path. */ public Optional<String> path() { return Optional.ofNullable(path); } /** * Set cookie's comment. * * @param comment A cookie's comment. * @return This definition. */ public Definition comment(final String comment) { this.comment = requireNonNull(comment, "A cookie comment is required."); return this; } /** * @return Cookie's comment. */ public Optional<String> comment() { return Optional.ofNullable(comment); } /** * Set HttpOnly flag. * * @param httpOnly True, for HTTP Only. * @return This definition. */ public Definition httpOnly(final boolean httpOnly) { this.httpOnly = httpOnly; return this; } /** * @return HTTP only flag. */ public Optional<Boolean> httpOnly() { return Optional.ofNullable(httpOnly); } /** * True, ensure that the session cookie is only transmitted via HTTPS. * * @param secure True, ensure that the session cookie is only transmitted via HTTPS. * @return This definition. */ public Definition secure(final boolean secure) { this.secure = secure; return this; } /** * @return True, ensure that the session cookie is only transmitted via HTTPS. */ public Optional<Boolean> secure() { return Optional.ofNullable(secure); } /** * Sets the maximum age in seconds for this Cookie. * * <p> * A positive value indicates that the cookie will expire after that many seconds have passed. * Note that the value is the <i>maximum</i> age when the cookie will expire, not the cookie's * current age. * </p> * * <p> * A negative value means that the cookie is not stored persistently and will be deleted when * the Web browser exits. A zero value causes the cookie to be deleted. * </p> * * @param maxAge an integer specifying the maximum age of the cookie in seconds; if negative, * means the cookie is not stored; if zero, deletes the cookie. * @return This definition. */ public Definition maxAge(final int maxAge) { this.maxAge = maxAge; return this; } /** * Gets the maximum age in seconds for this Cookie. * * <p> * A positive value indicates that the cookie will expire after that many seconds have passed. * Note that the value is the <i>maximum</i> age when the cookie will expire, not the cookie's * current age. * </p> * * <p> * A negative value means that the cookie is not stored persistently and will be deleted when * the Web browser exits. A zero value causes the cookie to be deleted. * </p> * * @return Cookie's max age in seconds. */ public Optional<Integer> maxAge() { return Optional.ofNullable(maxAge); } } /** * Sign cookies using a HMAC algorithm plus SHA-256 hash. * Usage: * * <pre> * String signed = Signature.sign("hello", "mysecretkey"); * ... * // is it valid? * assertEquals(signed, Signature.unsign(signed, "mysecretkey"); * </pre> * * @author edgar * @since 0.1.0 */ public class Signature { /** Remove trailing '='. */ private static final Pattern EQ = Pattern.compile("=+$"); /** Algorithm name. */ public static final String HMAC_SHA256 = "HmacSHA256"; /** Signature separator. */ private static final String SEP = "|"; /** * Sign a value using a secret key. A value and secret key are required. Sign is done with * {@link #HMAC_SHA256}. * Signed value looks like: * * <pre> * [signed value] '|' [raw value] * </pre> * * @param value A value to sign. * @param secret A secret key. * @return A signed value. */ public static String sign(final String value, final String secret) { requireNonNull(value, "A value is required."); requireNonNull(secret, "A secret is required."); try { Mac mac = Mac.getInstance(HMAC_SHA256); mac.init(new SecretKeySpec(secret.getBytes(), HMAC_SHA256)); byte[] bytes = mac.doFinal(value.getBytes()); return EQ.matcher(BaseEncoding.base64().encode(bytes)).replaceAll("") + SEP + value; } catch (Exception ex) { throw new IllegalArgumentException("Can't sing value", ex); } } /** * Un-sign a value, previously signed with {@link #sign(String, String)}. * Try {@link #valid(String, String)} to check for valid signed values. * * @param value A signed value. * @param secret A secret key. * @return A new signed value. */ public static String unsign(final String value, final String secret) { requireNonNull(value, "A value is required."); requireNonNull(secret, "A secret is required."); int sep = value.indexOf(SEP); if (sep <= 0) { return null; } String str = value.substring(sep + 1); String mac = sign(str, secret); return mac.equals(value) ? str : null; } /** * True, if the given signed value is valid. * * @param value A signed value. * @param secret A secret key. * @return True, if the given signed value is valid. */ public static boolean valid(final String value, final String secret) { return unsign(value, secret) != null; } } /** * @return Cookie's name. */ String name(); /** * @return Cookie's value. */ Optional<String> value(); /** * @return An optional comment. */ Optional<String> comment(); /** * @return Cookie's domain. */ Optional<String> domain(); /** * Gets the maximum age of this cookie (in seconds). * * <p> * By default, <code>-1</code> is returned, which indicates that the cookie will persist until * browser shutdown. * </p> * * @return An integer specifying the maximum age of the cookie in seconds; if negative, means * the cookie persists until browser shutdown */ int maxAge(); /** * @return Cookie's path. */ Optional<String> path(); /** * Returns <code>true</code> if the browser is sending cookies only over a secure protocol, or * <code>false</code> if the browser can send cookies using any protocol. * * @return <code>true</code> if the browser uses a secure protocol, <code>false</code> otherwise. */ boolean secure(); /** * @return True if HTTP Only. */ boolean httpOnly(); /** * @return Encode the cookie. */ String encode(); }