diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/Cookie.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/Cookie.java index 8a3736a1b..8721b7d53 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/Cookie.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/Cookie.java @@ -35,6 +35,7 @@ public interface Cookie { * * @return 表示 Cookie 版本号的 {@code int}。 */ + @Deprecated int version(); /** @@ -43,6 +44,7 @@ public interface Cookie { * * @return 表示 Cookie 注释的 {@link String}。 */ + @Deprecated String comment(); /** @@ -79,12 +81,20 @@ public interface Cookie { /** * 判断 Cookie 是否仅允许在服务端获取。 - *

该属性并不是 Cookie 的标准,但是被浏览器支持。

+ *

其 HttpOnly 属性的格式为 {@code ;HttpOnly ...},如果存在则表示仅服务端可访问。

* - * @return 如果 Cookie 仅允许在服务端获取,返回 {@code true},否则,返回 {@code false}。 + * @return 如果 Cookie 仅允许在服务端访问,则返回 {@code true},否则返回 {@code false}。 */ boolean httpOnly(); + /** + * 获取 Cookie 的 SameSite 属性。 + *

其 SameSite 属性的格式为 {@code ;SameSite=VALUE ...},表示跨站请求策略。

+ * + * @return SameSite 值,如 {@code "Strict"}、{@code "Lax"}、{@code "None"}。 + */ + String sameSite(); + /** * {@link Cookie} 的构建器。 */ @@ -107,18 +117,30 @@ interface Builder { /** * 向当前构建器中设置 Cookie 的版本。 + *

+ * 此属性源自 RFC 2965, + * 但已在 RFC 6265 + * 中移出标准定义。现代浏览器会忽略该属性。 + *

* * @param version 表示待设置的 Cookie 版本的 {@code int}。 * @return 表示当前构建器的 {@link Builder}。 */ + @Deprecated Builder version(int version); /** * 向当前构建器中设置 Cookie 的注释。 + *

+ * 此属性源自 RFC 2965, + * 但已在 RFC 6265 + * 中移出标准定义。现代浏览器会忽略该属性。 + *

* * @param comment 表示待设置的 Cookie 注释的 {@link String}。 * @return 表示当前构建器的 {@link Builder}。 */ + @Deprecated Builder comment(String comment); /** @@ -161,6 +183,19 @@ interface Builder { */ Builder httpOnly(boolean httpOnly); + /** + * 向当前构建器中设置 Cookie 限制跨站请求时发送行为安全级别。 + *

+ * 该属性定义于 + * RFC 6265bis 草案第 4.1.2.7 节,用于控制跨站请求时是否发送 Cookie。 + * 尽管该规范尚处于草案阶段,但已被主流浏览器(如 Chrome、Firefox、Safari、Edge)广泛支持。 + *

+ * + * @param sameSite SameSite 值,如 "Strict", "Lax", "None"。 + * @return 表示当前构建器的 {@link Builder}。 + */ + Builder sameSite(String sameSite); + /** * 构建对象。 * diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/support/DefaultHttpClassicClientRequest.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/support/DefaultHttpClassicClientRequest.java index db2062f17..242a834ac 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/support/DefaultHttpClassicClientRequest.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/client/support/DefaultHttpClassicClientRequest.java @@ -155,7 +155,7 @@ protected void commit() { if (this.isCommitted()) { return; } - this.headers().set(COOKIE, this.cookies().toString()); + this.headers().set(COOKIE, this.cookies().toRequestHeaderValue()); super.commit(); } diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/ConfigurableCookieCollection.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/ConfigurableCookieCollection.java index 87cb3012a..0fcf37f14 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/ConfigurableCookieCollection.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/ConfigurableCookieCollection.java @@ -31,15 +31,4 @@ public interface ConfigurableCookieCollection extends CookieCollection { static ConfigurableCookieCollection create() { return new DefaultCookieCollection(); } - - /** - * 根据指定的消息头创建一个可读可写的 Cookie 集合。 - * - * @param headerValue 表示指定消息头的 {@link HeaderValue}。 - * @return 表示创建出来的可读可写的 Cookie 集合的 {@link ConfigurableCookieCollection}。 - * @throws IllegalArgumentException 当 {@code headerValue} 为 {@code null} 时。 - */ - static ConfigurableCookieCollection create(HeaderValue headerValue) { - return new DefaultCookieCollection(headerValue); - } } diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/CookieCollection.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/CookieCollection.java index 5f6eae616..23b07c51b 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/CookieCollection.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/header/CookieCollection.java @@ -17,9 +17,10 @@ * @author 季聿阶 * @since 2022-07-06 */ -public interface CookieCollection extends HeaderValue { +public interface CookieCollection { /** * 获取指定名字的 {@link Cookie}。 + *

如果存在多个同名 Cookie,返回第一个匹配的 Cookie。

* * @param name 表示 Cookie 名字的 {@link Optional}{@code <}{@link String}{@code >}。 * @return 表示指定名字的 {@link Cookie}。 @@ -27,7 +28,15 @@ public interface CookieCollection extends HeaderValue { Optional get(String name); /** - * 获取所有的 {@link Cookie}。 + * 根据名字查找所有匹配的 {@link Cookie}。 + * + * @param name 表示 Cookie 名字的 {@link String}。 + * @return 返回所有匹配名字的 {@link Cookie} 列表。 + */ + List all(String name); + + /** + * 获取集合中所有的 {@link Cookie}。 * * @return 表示所有 {@link Cookie} 列表的 {@link List}{@code <}{@link Cookie}{@code >}。 */ @@ -39,4 +48,20 @@ public interface CookieCollection extends HeaderValue { * @return 表示所有 {@link Cookie} 的数量的 {@code int}。 */ int size(); + + /** + * 将集合转换为 HTTP 请求头中 Cookie 形式的字符串。 + *

格式为 {@code name1=value1; name2=value2; ...}。

+ * + * @return 表示请求头的字符串。 + */ + String toRequestHeaderValue(); + + /** + * 将集合转换为 HTTP 响应头形式的字符串列表。 + *

每个 Cookie 对应一个 {@code Set-Cookie: ...} 头。

+ * + * @return 表示响应头列表的 {@link List}{@code <}{@link String}{@code >}。 + */ + List toResponseHeadersValues(); } diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/server/support/DefaultHttpClassicServerResponse.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/server/support/DefaultHttpClassicServerResponse.java index 48687b102..ef57de702 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/server/support/DefaultHttpClassicServerResponse.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/server/support/DefaultHttpClassicServerResponse.java @@ -10,7 +10,7 @@ import static modelengine.fit.http.protocol.MessageHeaderNames.CONNECTION; import static modelengine.fit.http.protocol.MessageHeaderNames.CONTENT_DISPOSITION; import static modelengine.fit.http.protocol.MessageHeaderNames.CONTENT_LENGTH; -import static modelengine.fit.http.protocol.MessageHeaderNames.COOKIE; +import static modelengine.fit.http.protocol.MessageHeaderNames.SET_COOKIE; import static modelengine.fit.http.protocol.MessageHeaderNames.TRANSFER_ENCODING; import static modelengine.fit.http.protocol.MessageHeaderValues.CHUNKED; import static modelengine.fit.http.protocol.MessageHeaderValues.KEEP_ALIVE; @@ -255,7 +255,7 @@ protected void commit() { if (this.isCommitted()) { return; } - this.headers().set(COOKIE, this.cookies().toString()); + this.headers().set(SET_COOKIE, this.cookies().toResponseHeadersValues()); if (this.entity != null) { this.setContentTypeByEntity(this.headers(), this.entity); if (this.entity instanceof FileEntity) { diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicRequest.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicRequest.java index a7d0c0fc4..ccfc79ba3 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicRequest.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicRequest.java @@ -6,6 +6,7 @@ package modelengine.fit.http.support; +import static modelengine.fit.http.protocol.MessageHeaderNames.COOKIE; import static modelengine.fit.http.protocol.MessageHeaderNames.HOST; import static modelengine.fitframework.inspection.Validation.notNull; @@ -16,6 +17,7 @@ import modelengine.fit.http.protocol.MessageHeaders; import modelengine.fit.http.protocol.QueryCollection; import modelengine.fit.http.protocol.RequestLine; +import modelengine.fit.http.util.HttpUtils; /** * 表示 {@link HttpClassicRequest} 的抽象实现类。 @@ -24,6 +26,8 @@ * @since 2022-11-23 */ public abstract class AbstractHttpClassicRequest extends AbstractHttpMessage implements HttpClassicRequest { + private static final String COOKIE_DELIMITER = ";"; + private final RequestLine startLine; private final MessageHeaders headers; @@ -38,6 +42,8 @@ public AbstractHttpClassicRequest(HttpResource httpResource, RequestLine startLi super(httpResource, startLine, headers); this.startLine = notNull(startLine, "The request line cannot be null."); this.headers = notNull(headers, "The message headers cannot be null."); + String actualCookie = String.join(COOKIE_DELIMITER, this.headers.all(COOKIE)); + HttpUtils.parseCookies(actualCookie).forEach(this.cookies()::add); } @Override diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicResponse.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicResponse.java index 75c29ec95..7f6794a78 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicResponse.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpClassicResponse.java @@ -6,6 +6,7 @@ package modelengine.fit.http.support; +import static modelengine.fit.http.protocol.MessageHeaderNames.SET_COOKIE; import static modelengine.fitframework.inspection.Validation.notNull; import modelengine.fit.http.HttpClassicResponse; @@ -13,6 +14,9 @@ import modelengine.fit.http.protocol.MessageHeaders; import modelengine.fit.http.protocol.RequestLine; import modelengine.fit.http.protocol.StatusLine; +import modelengine.fit.http.util.HttpUtils; + +import java.util.List; /** * {@link HttpClassicResponse} 的默认实现。 @@ -33,6 +37,9 @@ public abstract class AbstractHttpClassicResponse extends AbstractHttpMessage im public AbstractHttpClassicResponse(HttpResource httpResource, StatusLine startLine, MessageHeaders headers) { super(httpResource, startLine, headers); this.startLine = notNull(startLine, "The status line cannot be null."); + notNull(headers, "The headers cannot be null."); + List actualCookies = headers.all(SET_COOKIE); + actualCookies.stream().map(HttpUtils::parseSetCookie).forEach(this.cookies()::add); } @Override diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpMessage.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpMessage.java index 631ddd488..9ab1d362b 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpMessage.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/AbstractHttpMessage.java @@ -8,7 +8,6 @@ import static modelengine.fit.http.protocol.MessageHeaderNames.CONTENT_LENGTH; import static modelengine.fit.http.protocol.MessageHeaderNames.CONTENT_TYPE; -import static modelengine.fit.http.protocol.MessageHeaderNames.COOKIE; import static modelengine.fit.http.protocol.MessageHeaderNames.TRANSFER_ENCODING; import static modelengine.fit.http.protocol.MessageHeaderValues.CHUNKED; import static modelengine.fitframework.inspection.Validation.notNull; @@ -47,8 +46,6 @@ * @since 2022-08-03 */ public abstract class AbstractHttpMessage implements HttpMessage { - private static final String COOKIE_DELIMITER = ";"; - private final ParameterCollection parameters = ParameterCollection.create().set(DefaultContentType.CHARSET, StandardCharsets.UTF_8.name()); private final HttpResource httpResource; @@ -70,8 +67,7 @@ protected AbstractHttpMessage(HttpResource httpResource, StartLine startLine, Me this.httpResource = notNull(httpResource, "The http resource cannot be null."); this.startLine = notNull(startLine, "The start line cannot be null."); this.headers = notNull(headers, "The message headers cannot be null."); - String actualCookie = String.join(COOKIE_DELIMITER, this.headers.all(COOKIE)); - this.cookies = ConfigurableCookieCollection.create(HttpUtils.parseHeaderValue(actualCookie)); + this.cookies = ConfigurableCookieCollection.create(); } @Override diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/DefaultCookieCollection.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/DefaultCookieCollection.java index 1df0f9fd6..dee4b65da 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/DefaultCookieCollection.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/support/DefaultCookieCollection.java @@ -6,18 +6,22 @@ package modelengine.fit.http.support; -import static modelengine.fitframework.inspection.Validation.notNull; +import static modelengine.fit.http.util.HttpUtils.COOKIES_FORMAT_SEPARATOR; +import static modelengine.fit.http.util.HttpUtils.COOKIE_PAIR_SEPARATOR; import modelengine.fit.http.Cookie; import modelengine.fit.http.header.ConfigurableCookieCollection; import modelengine.fit.http.header.CookieCollection; -import modelengine.fit.http.header.HeaderValue; -import modelengine.fit.http.header.support.DefaultHeaderValue; -import modelengine.fit.http.header.support.DefaultParameterCollection; +import modelengine.fit.http.util.HttpUtils; +import modelengine.fitframework.util.CollectionUtils; import modelengine.fitframework.util.StringUtils; +import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; @@ -27,49 +31,56 @@ * @author 季聿阶 * @since 2022-07-06 */ -public class DefaultCookieCollection extends DefaultHeaderValue implements ConfigurableCookieCollection { - /** - * 初始化 {@link DefaultCookieCollection} 的新实例。 - */ - public DefaultCookieCollection() { - super(StringUtils.EMPTY, new DefaultParameterCollection()); - } +public class DefaultCookieCollection implements ConfigurableCookieCollection { + private final Map> store = new LinkedHashMap<>(); - /** - * 使用指定的消息头初始化 {@link DefaultCookieCollection} 的新实例。 - * - * @param headerValue 表示消息头的 {@link HeaderValue}。 - * @throws IllegalArgumentException 当 {@code headerValue} 为 {@code null} 时。 - */ - public DefaultCookieCollection(HeaderValue headerValue) { - super(notNull(headerValue, "The header value cannot be null.").value(), headerValue.parameters()); + @Override + public Optional get(String name) { + List cookies = this.store.get(name); + if (CollectionUtils.isEmpty(cookies)) { + return Optional.empty(); + } + return Optional.of(cookies.get(0)); } @Override - public Optional get(String name) { - return this.parameters().get(name).map(value -> Cookie.builder().name(name).value(value).build()); + public List all(String name) { + return this.store.getOrDefault(name, Collections.emptyList()); } @Override public List all() { - return Collections.unmodifiableList(this.parameters() - .keys() - .stream() - .map(this::get) - .filter(Optional::isPresent) - .map(Optional::get) - .collect(Collectors.toList())); + return this.store.values().stream().flatMap(List::stream).collect(Collectors.toList()); } @Override public int size() { - return this.parameters().size(); + return this.store.values().stream().mapToInt(List::size).sum(); } @Override public void add(Cookie cookie) { - if (cookie != null) { - this.parameters().set(cookie.name(), cookie.value()); + if (cookie == null || StringUtils.isBlank(cookie.name())) { + return; + } + if (HttpUtils.isInvalidCookiePair(cookie.name(), cookie.value())) { + throw new IllegalArgumentException("Invalid cookie: name or value is not allowed."); } + this.store.computeIfAbsent(cookie.name(), k -> new ArrayList<>()); + List list = this.store.get(cookie.name()); + list.removeIf(c -> Objects.equals(c.path(), cookie.path()) && Objects.equals(c.domain(), cookie.domain())); + list.add(cookie); + } + + @Override + public String toRequestHeaderValue() { + return all().stream() + .map(c -> c.name() + COOKIE_PAIR_SEPARATOR + c.value()) + .collect(Collectors.joining(COOKIES_FORMAT_SEPARATOR)); + } + + @Override + public List toResponseHeadersValues() { + return all().stream().map(HttpUtils::formatSetCookie).collect(Collectors.toList()); } } diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/util/HttpUtils.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/util/HttpUtils.java index 43fd215d3..4aa4df42e 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/util/HttpUtils.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/main/java/modelengine/fit/http/util/HttpUtils.java @@ -6,8 +6,16 @@ package modelengine.fit.http.util; +import static modelengine.fit.http.protocol.CookieAttributeNames.DOMAIN; +import static modelengine.fit.http.protocol.CookieAttributeNames.EXPIRES; +import static modelengine.fit.http.protocol.CookieAttributeNames.HTTP_ONLY; +import static modelengine.fit.http.protocol.CookieAttributeNames.MAX_AGE; +import static modelengine.fit.http.protocol.CookieAttributeNames.PATH; +import static modelengine.fit.http.protocol.CookieAttributeNames.SAME_SITE; +import static modelengine.fit.http.protocol.CookieAttributeNames.SECURE; import static modelengine.fitframework.inspection.Validation.notNull; +import modelengine.fit.http.Cookie; import modelengine.fit.http.header.HeaderValue; import modelengine.fit.http.header.ParameterCollection; import modelengine.fit.http.header.support.DefaultHeaderValue; @@ -21,9 +29,17 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.time.Duration; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; /** * Http 协议相关的工具类。 @@ -33,8 +49,204 @@ * @since 2022-07-22 */ public class HttpUtils { - private static final char STRING_VALUE_SURROUNDED = '\"'; + private static final String COOKIES_PARSE_SEPARATOR = ";"; + public static final String COOKIES_FORMAT_SEPARATOR = COOKIES_PARSE_SEPARATOR + " "; + public static final String COOKIE_PAIR_SEPARATOR = "="; + + private static final Pattern TOKEN_PATTERN = Pattern.compile("^[!#$%&'*+\\-.^_`|~0-9a-zA-Z]+$"); + + private static final String PATH_KEY = PATH.toLowerCase(Locale.ROOT); + private static final String DOMAIN_KEY = DOMAIN.toLowerCase(Locale.ROOT); + private static final String MAX_AGE_KEY = MAX_AGE.toLowerCase(Locale.ROOT); + private static final String EXPIRES_KEY = EXPIRES.toLowerCase(Locale.ROOT); + private static final String SECURE_KEY = SECURE.toLowerCase(Locale.ROOT); + private static final String HTTP_ONLY_KEY = HTTP_ONLY.toLowerCase(Locale.ROOT); + private static final String SAME_SITE_KEY = SAME_SITE.toLowerCase(Locale.ROOT); + + /** + * 将给定的 {@link Cookie} 对象格式化为符合 HTTP 协议的 {@code Set-Cookie} 头部字符串。 + *

生成结果遵循 RFC 6265 规范,如果 cookie 对象为空,则返回空字符串

+ * + * @param cookie 表示待格式化的 {@link Cookie} 对象。 + * @return 表示生成的 {@code Set-Cookie} 头部字符串的 {@link String}。 + */ + public static String formatSetCookie(Cookie cookie) { + if (cookie == null || StringUtils.isBlank(cookie.name())) { + return StringUtils.EMPTY; + } + + StringBuilder sb = new StringBuilder().append(cookie.name()) + .append(COOKIE_PAIR_SEPARATOR) + .append(cookie.value() != null ? cookie.value() : StringUtils.EMPTY); + + if (StringUtils.isNotBlank(cookie.path())) { + sb.append(COOKIES_FORMAT_SEPARATOR).append(PATH).append(COOKIE_PAIR_SEPARATOR).append(cookie.path()); + } + if (StringUtils.isNotBlank(cookie.domain())) { + sb.append(COOKIES_FORMAT_SEPARATOR).append(DOMAIN).append(COOKIE_PAIR_SEPARATOR).append(cookie.domain()); + } + if (cookie.maxAge() >= 0) { + sb.append(COOKIES_FORMAT_SEPARATOR).append(MAX_AGE).append(COOKIE_PAIR_SEPARATOR).append(cookie.maxAge()); + } + if (cookie.secure()) { + sb.append(COOKIES_FORMAT_SEPARATOR).append(SECURE); + } + if (cookie.httpOnly()) { + sb.append(COOKIES_FORMAT_SEPARATOR).append(HTTP_ONLY); + } + if (StringUtils.isNotBlank(cookie.sameSite())) { + sb.append(COOKIES_FORMAT_SEPARATOR) + .append(SAME_SITE) + .append(COOKIE_PAIR_SEPARATOR) + .append(cookie.sameSite()); + } + return sb.toString(); + } + + /** + * 从消息头 Set-Cookie 的字符串值中解析 Cookie 的值以及属性。 + *

若包含 Expires 属性,则会自动换算为 Max-Age。

+ * + * @param rawCookie 表示待解析的 Set-Cookie 字符串值的 {@link String}。 + * @return 表示解析后的 {@link Cookie}。 + */ + public static Cookie parseSetCookie(String rawCookie) { + if (StringUtils.isBlank(rawCookie)) { + return Cookie.builder().build(); + } + + var parts = rawCookie.split(COOKIES_PARSE_SEPARATOR); + var builder = parseCookieNameValue(parts[0]); + if (builder == null) { + return Cookie.builder().build(); + } + + parseCookieAttributes(parts, builder); + return builder.build(); + } + + private static int safeParseInt(String val) { + try { + return Integer.parseInt(val); + } catch (NumberFormatException e) { + return -1; + } + } + + private static int convertExpiresToMaxAge(String expiresString) { + if (StringUtils.isBlank(expiresString)) { + return -1; + } + try { + ZonedDateTime expires = + ZonedDateTime.parse(expiresString, DateTimeFormatter.RFC_1123_DATE_TIME.withLocale(Locale.US)); + long seconds = Duration.between(ZonedDateTime.now(ZoneOffset.UTC), expires).getSeconds(); + if (seconds <= 0) { + return 0; + } + if (seconds > Integer.MAX_VALUE) { + return Integer.MAX_VALUE; + } + return (int) seconds; + } catch (DateTimeParseException e) { + return -1; + } + } + + /** + * 从 Cookie 头部字符串解析多个 Cookie。 + *

示例:{@code "a=1; b=2; c=3"} → List[Cookie(a=1), Cookie(b=2), Cookie(c=3)]

+ * + * @param rawCookie 表示原始 Cookie 头的字符串的 {@link String}(例如 "a=1; b=2; c=3")。 + * @return 表示解析得到的 Cookie 列表的 {@link List}{@code <}{@link Cookie}{@code >}。 + */ + public static List parseCookies(String rawCookie) { + if (StringUtils.isBlank(rawCookie)) { + return Collections.emptyList(); + } + + List cookies = new ArrayList<>(); + for (String part : rawCookie.split(COOKIES_PARSE_SEPARATOR)) { + Cookie.Builder builder = parseCookieNameValue(part.trim()); + if (builder != null) { + cookies.add(builder.build()); + } + } + return cookies; + } + + private static Cookie.Builder parseCookieNameValue(String part) { + String trimmed = part.trim(); + if (trimmed.isEmpty()) { + return null; + } + + int eqIndex = trimmed.indexOf(COOKIE_PAIR_SEPARATOR); + if (eqIndex <= 0) { + return null; + } + + String name = trimmed.substring(0, eqIndex).trim(); + String value = trimmed.substring(eqIndex + 1).trim(); + if (isValueSurrounded(value)) { + value = value.substring(1, value.length() - 1); + } + + if (isInvalidCookiePair(name, value)) { + return null; + } + return Cookie.builder().name(name).value(value); + } + + private static void parseCookieAttributes(String[] parts, Cookie.Builder builder) { + for (int i = 1; i < parts.length; i++) { + var part = parts[i].trim(); + if (part.isEmpty()) { + continue; + } + + var kv = part.split(COOKIE_PAIR_SEPARATOR, 2); + var key = kv[0].trim().toLowerCase(Locale.ROOT); + var val = kv.length > 1 ? kv[1].trim() : StringUtils.EMPTY; + + if (PATH_KEY.equals(key)) { + builder.path(val); + } else if (DOMAIN_KEY.equals(key)) { + builder.domain(val); + } else if (MAX_AGE_KEY.equals(key)) { + builder.maxAge(safeParseInt(val)); + } else if (EXPIRES_KEY.equals(key)) { + builder.maxAge(convertExpiresToMaxAge(val)); + } else if (SECURE_KEY.equals(key)) { + builder.secure(true); + } else if (HTTP_ONLY_KEY.equals(key)) { + builder.httpOnly(true); + } else if (SAME_SITE_KEY.equals(key)) { + builder.sameSite(val); + } + } + } + + /** + * 验证给定的 Cookie 名称和值是否合法。 + * + * @param name 表示 Cookie 的名称 {@link String}。 + * @param value 表示 Cookie 的值 {@link String},允许为空但不允许为 {@code null},可带双引号。 + * @return 如果 name 和 value 都合法返回 {@code true},否则返回 {@code false}。 + */ + public static boolean isInvalidCookiePair(String name, String value) { + if (StringUtils.isEmpty(name) || !TOKEN_PATTERN.matcher(name).matches()) { + return true; + } + if (value == null) { + return true; + } + if (isValueSurrounded(value)) { + value = value.substring(1, value.length() - 1); + } + return !value.isEmpty() && !TOKEN_PATTERN.matcher(value).matches(); + } /** * 从消息头的字符串值中解析消息头的值。 diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/header/ConfigurableCookieCollectionTest.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/header/ConfigurableCookieCollectionTest.java index 945cf51ea..c5219fe5c 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/header/ConfigurableCookieCollectionTest.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/header/ConfigurableCookieCollectionTest.java @@ -24,24 +24,12 @@ */ @DisplayName("测试 ConfigurableCookieCollection 类") class ConfigurableCookieCollectionTest { - @Test - @DisplayName("当构建 Cookie 集合的参数为 null 时,抛出异常") - void givenNullThenThrowException() { - assertThatThrownBy(this::buildNullCookieCollection).isInstanceOf(IllegalArgumentException.class); - } - - private void buildNullCookieCollection() { - ConfigurableCookieCollection.create(null); - } - @Test @DisplayName("返回所有的 Cookie") void shouldReturnAllCookie() { final Cookie cookie = Cookie.builder() .name("idea") .value("00ae-u98i") - .version(1) - .comment("") .domain("localhost") .maxAge(10) .path("/") @@ -53,4 +41,83 @@ void shouldReturnAllCookie() { final List cookies = cookieCollection.all(); assertThat(cookies).hasSize(1); } + + @Test + @DisplayName("添加非法 Cookie 应抛异常") + void shouldThrowExceptionForInvalidCookie() { + ConfigurableCookieCollection collection = ConfigurableCookieCollection.create(); + + Cookie invalidNameCookie = Cookie.builder().name("inva;lid").value("123").build(); + assertThatThrownBy(() -> collection.add(invalidNameCookie)).isInstanceOf(IllegalArgumentException.class); + + Cookie invalidValueCookie = Cookie.builder().name("validName").value("v@lue;").build(); + assertThatThrownBy(() -> collection.add(invalidValueCookie)).isInstanceOf(IllegalArgumentException.class); + + Cookie nullValueCookie = Cookie.builder().name("someName").value(null).build(); + assertThatThrownBy(() -> collection.add(nullValueCookie)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("允许空字符串 value") + void shouldHandleEmptyAndNullValue() { + ConfigurableCookieCollection collection = ConfigurableCookieCollection.create(); + + // 空字符串 value 是允许的 + Cookie emptyValueCookie = Cookie.builder().name("token").value("").build(); + collection.add(emptyValueCookie); + assertThat(collection.get("token")).isPresent().get().extracting(Cookie::value).isEqualTo(""); + } + + @Test + @DisplayName("同名 Cookie 不同路径可共存") + void shouldAllowMultipleCookiesWithDifferentPath() { + ConfigurableCookieCollection collection = ConfigurableCookieCollection.create(); + collection.add(Cookie.builder().name("user").value("A").path("/a").build()); + collection.add(Cookie.builder().name("user").value("B").path("/b").build()); + + List sameNameCookies = collection.all("user"); + assertThat(sameNameCookies).hasSize(2); + } + + @Test + @DisplayName("同名同 path/domain 的 Cookie 应被替换") + void shouldReplaceCookieWithSamePathAndDomain() { + ConfigurableCookieCollection collection = ConfigurableCookieCollection.create(); + + Cookie c1 = Cookie.builder().name("id").value("1").path("/").domain("a.com").build(); + Cookie c2 = Cookie.builder().name("id").value("2").path("/").domain("a.com").build(); + + collection.add(c1); + collection.add(c2); + + List cookies = collection.all("id"); + assertThat(cookies).hasSize(1); + assertThat(cookies.get(0).value()).isEqualTo("2"); + } + + @Test + @DisplayName("toRequestHeader 生成单行请求头") + void shouldGenerateRequestHeader() { + ConfigurableCookieCollection collection = ConfigurableCookieCollection.create(); + collection.add(Cookie.builder().name("a").value("1").build()); + collection.add(Cookie.builder().name("b").value("2").build()); + + String header = collection.toRequestHeaderValue(); + assertThat(header).isEqualTo("a=1; b=2"); + } + + @Test + @DisplayName("toResponseHeaders 生成多个 Set-Cookie 响应头") + void shouldGenerateMultipleResponseHeaders() { + ConfigurableCookieCollection collection = ConfigurableCookieCollection.create(); + collection.add(Cookie.builder().name("token").value("xyz").secure(true).httpOnly(true).build()); + collection.add(Cookie.builder().name("lang").value("zh-CN").sameSite("Lax").build()); + + List headers = collection.toResponseHeadersValues(); + + assertThat(headers).hasSize(2); + assertThat(headers.get(0)).contains("token=xyz"); + assertThat(headers.get(1)).contains("lang=zh-CN"); + assertThat(headers.get(1)).contains("SameSite=Lax"); + } } diff --git a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/util/HttpUtilsTest.java b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/util/HttpUtilsTest.java index f0ae44a91..3641ac7fe 100644 --- a/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/util/HttpUtilsTest.java +++ b/framework/fit/java/fit-builtin/services/fit-http-classic/definition/src/test/java/modelengine/fit/http/util/HttpUtilsTest.java @@ -9,6 +9,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowableOfType; +import modelengine.fit.http.Cookie; import modelengine.fit.http.header.HeaderValue; import modelengine.fitframework.util.StringUtils; @@ -17,6 +18,10 @@ import java.net.MalformedURLException; import java.net.URL; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; /** * 为 {@link HttpUtils} 提供单元测试。 @@ -26,6 +31,114 @@ */ @DisplayName("测试 HttpUtils 工具类") public class HttpUtilsTest { + @Test + @DisplayName("格式化完整 Set-Cookie,返回正确格式化字符串") + void givenFullCookie_thenIncludeAllAttributes() { + Cookie cookie = Cookie.builder() + .name("token") + .value("abc") + .path("/api") + .domain("example.com") + .maxAge(3600) + .secure(true) + .httpOnly(true) + .sameSite("Lax") + .build(); + + String result = HttpUtils.formatSetCookie(cookie); + assertThat(result).contains("token=abc") + .contains("Path=/api") + .contains("Domain=example.com") + .contains("Max-Age=3600") + .contains("Secure") + .contains("HttpOnly") + .contains("SameSite=Lax"); + } + + @Test + @DisplayName("格式化空的 Set-Cookie,返回空字符串") + void givenNullCookie_thenReturnEmptyString() { + assertThat(HttpUtils.formatSetCookie(null)).isEmpty(); + } + + @Test + @DisplayName("解析合法的 Set-Cookie 值,返回正确的 Cookie 对象") + void givenValidSetCookieStringThenParseSuccessfully() { + String rawCookie = "ID=ab12xy; Path=/; Domain=example.com; Max-Age=3600; Secure; SameSite=Strict"; + Cookie cookie = HttpUtils.parseSetCookie(rawCookie); + + assertThat(cookie.name()).isEqualTo("ID"); + assertThat(cookie.value()).isEqualTo("ab12xy"); + assertThat(cookie.path()).isEqualTo("/"); + assertThat(cookie.domain()).isEqualTo("example.com"); + assertThat(cookie.maxAge()).isEqualTo(3600); + assertThat(cookie.secure()).isTrue(); + assertThat(cookie.httpOnly()).isFalse(); + assertThat(cookie.sameSite()).isEqualTo("Strict"); + } + + @Test + @DisplayName("给定空的 Set-Cookie 值,返回空的 Cookie 对象") + void givenEmptySetCookieThenReturnEmptyCookie() { + Cookie cookie = HttpUtils.parseSetCookie(""); + assertThat(cookie.name()).isNull(); + assertThat(cookie.value()).isNull(); + } + + @Test + @DisplayName("解析异常或不完整的 Cookie 值时,应忽略非法项并不抛异常") + void givenMalformedCookiesThenHandleGracefully() { + String rawCookie1 = "a=\"incomplete; b=2"; + List cookies1 = HttpUtils.parseCookies(rawCookie1); + assertThat(cookies1).extracting(Cookie::name).contains("b"); + assertThat(cookies1).extracting(Cookie::name).doesNotContain("a"); + + String rawCookie2 = "x=1;; ; y=2;"; + List cookies2 = HttpUtils.parseCookies(rawCookie2); + assertThat(cookies2).hasSize(2); + assertThat(cookies2.get(0).name()).isEqualTo("x"); + assertThat(cookies2.get(1).name()).isEqualTo("y"); + + String rawCookie4 = ";;;"; + List cookies4 = HttpUtils.parseCookies(rawCookie4); + assertThat(cookies4).isEmpty(); + } + + @Test + @DisplayName("解析带 Expires 属性的 Set-Cookie,自动换算为 Max-Age") + void givenExpiresAttributeThenConvertToMaxAge() { + ZonedDateTime expiresTime = ZonedDateTime.now(ZoneOffset.UTC).plusHours(1); + String expiresStr = expiresTime.format(DateTimeFormatter.RFC_1123_DATE_TIME); + String rawCookie = "ID=xyz; Expires=" + expiresStr; + + Cookie cookie = HttpUtils.parseSetCookie(rawCookie); + assertThat(cookie.maxAge()).isBetween(3500, 3700); + } + + @Test + @DisplayName("解析多个 Cookie 头部值,返回正确的 Cookie 列表") + void givenMultipleCookiesThenReturnCookieList() { + String rawCookie = "a=1; b=2; c=3"; + List cookies = HttpUtils.parseCookies(rawCookie); + + assertThat(cookies).hasSize(3); + assertThat(cookies.get(0).name()).isEqualTo("a"); + assertThat(cookies.get(0).value()).isEqualTo("1"); + assertThat(cookies.get(1).name()).isEqualTo("b"); + assertThat(cookies.get(2).value()).isEqualTo("3"); + } + + @Test + @DisplayName("解析非法格式的 Cookie 值,自动跳过无效项") + void givenInvalidCookieStringThenSkipInvalidPairs() { + String rawCookie = "a=1; invalid; b=2"; + List cookies = HttpUtils.parseCookies(rawCookie); + + assertThat(cookies).hasSize(2); + assertThat(cookies.get(0).name()).isEqualTo("a"); + assertThat(cookies.get(1).name()).isEqualTo("b"); + } + @Test @DisplayName("给定空的值,解析消息头的值返回为空") void givenEmptyValueThenReturnHeaderValueISEmpty() { diff --git a/framework/fit/java/fit-builtin/services/fit-http-protocol/definition/src/main/java/modelengine/fit/http/protocol/CookieAttributeNames.java b/framework/fit/java/fit-builtin/services/fit-http-protocol/definition/src/main/java/modelengine/fit/http/protocol/CookieAttributeNames.java new file mode 100644 index 000000000..634bdca49 --- /dev/null +++ b/framework/fit/java/fit-builtin/services/fit-http-protocol/definition/src/main/java/modelengine/fit/http/protocol/CookieAttributeNames.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package modelengine.fit.http.protocol; + +/** + * RFC 6265 等规范中定义的 Set-Cookie 属性名称常量。 + *

RFC 6265 列出了很多属性名称的定义来源。

+ * + * @author 徐吴昊 + * @since 2025-09-24 + */ +public class CookieAttributeNames { + /** @see RFC 6265 */ + public static final String EXPIRES = "Expires"; + + /** @see RFC 6265 */ + public static final String MAX_AGE = "Max-Age"; + + /** @see RFC 6265 */ + public static final String DOMAIN = "Domain"; + + /** @see RFC 6265 */ + public static final String PATH = "Path"; + + /** @see RFC 6265 */ + public static final String SECURE = "Secure"; + + /** @see RFC 6265 */ + public static final String HTTP_ONLY = "HttpOnly"; + + /** @see RFC 6265bis */ + public static final String SAME_SITE = "SameSite"; +}