From 99283c68588b6420f910e257339e4266559a0a81 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Tue, 21 Oct 2025 17:45:22 +0200 Subject: [PATCH 1/5] * Implemented `SecurityHandler.PathMethodMapped`. * Added security documentation. Signed-off-by: Simone Bordet --- .../programming/security/SecurityDocs.java | 140 ++++++++++ .../pages/security/index.adoc | 156 +++++++++++ .../programming-guide/pages/server/http.adoc | 5 + .../jetty/security/SecurityHandler.java | 225 ++++++++++++--- .../jetty/security/PathMethodMappedTest.java | 256 ++++++++++++++++++ .../src/test/resources/test-realm.properties | 4 + .../org/eclipse/jetty/server/Request.java | 4 +- 7 files changed, 751 insertions(+), 39 deletions(-) create mode 100644 documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/SecurityDocs.java create mode 100644 documentation/jetty/modules/programming-guide/pages/security/index.adoc create mode 100644 jetty-core/jetty-security/src/test/java/org/eclipse/jetty/security/PathMethodMappedTest.java create mode 100644 jetty-core/jetty-security/src/test/resources/test-realm.properties diff --git a/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/SecurityDocs.java b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/SecurityDocs.java new file mode 100644 index 000000000000..7fcb9162686a --- /dev/null +++ b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/SecurityDocs.java @@ -0,0 +1,140 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.docs.programming.security; + +import java.security.Principal; + +import org.eclipse.jetty.security.Constraint; +import org.eclipse.jetty.security.HashLoginService; +import org.eclipse.jetty.security.SecurityHandler; +import org.eclipse.jetty.security.authentication.BasicAuthenticator; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.resource.ResourceFactory; + +import static java.lang.System.Logger.Level.INFO; + +@SuppressWarnings("unused") +public class SecurityDocs +{ + public void pathMapped() throws Exception + { + // tag::pathMapped[] + Server server = new Server(); + + // The ContextHandler for the application. + ContextHandler contextHandler = new ContextHandler("/app"); + + // HashLoginService maps users, passwords and roles + // from the realm.properties file in the class-path. + HashLoginService loginService = new HashLoginService(); + loginService.setConfig(ResourceFactory.of(contextHandler).newClassLoaderResource("realm.properties")); + + // Use Basic authentication, which requires a secure transport. + BasicAuthenticator authenticator = new BasicAuthenticator(); + authenticator.setLoginService(loginService); + + // The SecurityHandler.PathMapped maps URI paths to constraints. + SecurityHandler.PathMapped securityHandler = new SecurityHandler.PathMapped(); + // Require that all requests use a secure transport. + securityHandler.put("/*", Constraint.SECURE_TRANSPORT); + // URI paths that start with /admin/ can only be accessed by users with the "admin" role. + securityHandler.put("/admin/*", Constraint.from("admin")); + securityHandler.setAuthenticator(authenticator); + securityHandler.setLoginService(loginService); + + server.setHandler(contextHandler); + contextHandler.setHandler(securityHandler); + securityHandler.setHandler(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + // Retrieve the authenticated user for this request. + Principal principal = Request.getAuthenticationState(request).getUserPrincipal(); + System.getLogger("app").log(INFO, "Current user is: {0}", principal); + + callback.succeeded(); + return true; + } + }); + + server.start(); + // end::pathMapped[] + } + + public void pathMethodMapped() throws Exception + { + // tag::pathMethodMapped[] + class AppHandler extends Handler.Abstract + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + // Retrieve the authenticated user for this request. + Principal principal = Request.getAuthenticationState(request).getUserPrincipal(); + System.getLogger("app").log(INFO, "Current user is: {0}", principal); + + callback.succeeded(); + return true; + } + } + + Server server = new Server(); + + ServerConnector connector = new ServerConnector(server); + connector.setPort(37023); + server.addConnector(connector); + + // The ContextHandler for the application. + ContextHandler contextHandler = new ContextHandler("/app"); + + // HashLoginService maps users, passwords and roles + // from the realm.properties file in the class-path. + HashLoginService loginService = new HashLoginService(); + loginService.setConfig(ResourceFactory.of(contextHandler).newClassLoaderResource("realm.properties")); + + // Use Basic authentication, which requires a secure transport. + BasicAuthenticator authenticator = new BasicAuthenticator(); + authenticator.setLoginService(loginService); + + // The SecurityHandler.PathMapped maps URI paths to constraints. + SecurityHandler.PathMethodMapped securityHandler = new SecurityHandler.PathMethodMapped(); + // Unless otherwise specified, access to resources is forbidden and requires secure transport. + securityHandler.put("/*", "*", Constraint.combine(Constraint.FORBIDDEN, Constraint.SECURE_TRANSPORT)); + // GET /data/* is allowed only to users with the "read" role. + securityHandler.put("/data/*", "GET", Constraint.from("read")); + // PUT /data/* is allowed only to users with the "write" role. + securityHandler.put("/data/*", "PUT", Constraint.from("write")); + securityHandler.setAuthenticator(authenticator); + securityHandler.setLoginService(loginService); + + server.setHandler(contextHandler); + contextHandler.setHandler(securityHandler); + securityHandler.setHandler(new AppHandler()); + + server.start(); + // end::pathMethodMapped[] + } + + public static void main(String[] args) throws Exception + { + new SecurityDocs().pathMethodMapped(); + } +} diff --git a/documentation/jetty/modules/programming-guide/pages/security/index.adoc b/documentation/jetty/modules/programming-guide/pages/security/index.adoc new file mode 100644 index 000000000000..93582c1b28ce --- /dev/null +++ b/documentation/jetty/modules/programming-guide/pages/security/index.adoc @@ -0,0 +1,156 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + += Security + +Web application security restricts access to resources within the web application, or imposes security requirements on the transport with which these resources are delivered to clients. + +The processing of the request may be: + +* _forbidden_; the request is immediately responded with a `403` status code. +* _allowed_; the request processing is allowed to continue to the application, which produces a response. +* _challenged_; the request may be allowed, but the credentials are missing, so the request is responded with a `401` status code. + +Jetty implements web application security with 3 components: + +* A subclass of `org.eclipse.jetty.security.SecurityHandler`, see <> for the implementations available out-of-the-box. +* An implementation of `org.eclipse.jetty.security.Authenticator`, see <> for the implementations available out-of-the-box. +* An implementation of `org.eclipse.jetty.security.LoginService`, see <> for the implementations available out-of-the-box. + +These components interact in this way: + +[plantuml] +---- +skinparam backgroundColor transparent +skinparam monochrome true +skinparam shadowing false + +participant SecurityHandler +participant Authenticator +participant LoginService + +SecurityHandler -> SecurityHandler : getConstraint() +SecurityHandler -> Authenticator : validateRequest() +Authenticator -> LoginService : login() +LoginService -> Authenticator : roles +Authenticator -> SecurityHandler : AuthenticationState +---- + +1. The `SecurityHandler` subclass returns a `Constraint` based on the subclass-specific logic, for example based on the request path, the request method, etc. +2. The `Constraint` is used to check if the request is trivially allowed, or trivially forbidden, or whether it requires a secure transport, and if so immediately handled with no further actions. +Otherwise, the `Constraint` declares what requirements about authorization, transport and roles are necessary for the request to be allowed. +3. The `SecurityHandler` calls `Authenticator.validateRequest(\...)` that performs implementation-specific logic to retrieve the authentication credentials, for example from HTTP request headers such as `Authorization`. +4. The `Authenticator` calls `LoginService.login(\...)` to verify the credentials and, if the verification is successful, obtain information about the roles associated with these credentials. +5. The `Authenticator` builds an `AuthenticationState` with the results of the call to `LoginService.login(\...)`, and returns it to the `SecurityHandler`. +6. The `SecurityHandler`, based on the received `AuthenticationState`, either allows the request to be processed by its child `Handler`, or sends an appropriate response to the client, for example a `401` challenge response or a `403` forbidden response. + +[[security-configuration]] +== Configuring Security + +`SecurityHandler` is typically configured as a child of the `ContextHandler` that represents the web application. + +A simple example uses `SecurityHandler.PathMapped` along with `BasicAuthenticator` and `HashLoginService`: + +[,java,indent=0] +---- +include::code:example$src/main/java/org/eclipse/jetty/docs/programming/security/SecurityDocs.java[tags=pathMapped] +---- + +The `Handler` tree structure looks like the following: + +[,screen] +---- +Server +└── ContextHandler /app + └── SecurityHandler + └── AppHandler +---- + +The `realm.properties` file used by `HashLoginService` is the following: + +.realm.properties +---- +# Format: :, +carol:password +david:password,admin +---- + +The behavior of the example above is the following: + +* A non-secure request using the `http` scheme is replied with a `403` response, since secure transport is required. +* A request for `/app/foo` is replied with a `200` response, and the application `Handler` would see a `null` request principal. +* A request for `/app/admin/bar` without `Authorization` header is replied with a `401` response, since authentication is required. +* A request for `/app/admin/bar` with an `Authorization` header containing invalid or unknown credentials is replied with a `401` response, since authentication is required. +* A request for `/app/admin/bar` with an `Authorization` header containing valid credentials with a role that is not `admin` is replied with a `401` response, since authentication is required. +* A request for `/app/admin/bar` with an `Authorization` header containing valid credentials for user `david`, that has `admin` role, is replied with a `200` response, and the application `Handler` would see a request principal for user `david`. + +A more complex example uses `SecurityHandler.PathMethodMapped` to allow users with the `read` role to read resources, and users with the `write` role to write resources: + +[,java,indent=0] +---- +include::code:example$src/main/java/org/eclipse/jetty/docs/programming/security/SecurityDocs.java[tags=pathMethodMapped] +---- + +.realm.properties +---- +# Format: :, +bob:password,read +alice:password,read,write +---- + +In the example above, access to any resource is by default forbidden, and requires secure transport. + +However, access to `/data/*` resources using the HTTP method `GET` is granted but only to users with `read` role. +Both `bob` and `alice` are granted access to these resources, but only if they use the `GET` method. + +Similarly, access to `/data/*` resources using the HTTP method `PUT` is granted but only to users with `write` role. +Only `alice` is granted access to these resources, but only if she uses the `PUT` method. + +[[security-handler]] +== `SecurityHandler` Implementations + +Jetty provides the following `SecurityHandler` implementations: + +* `SecurityHandler.PathMapped`, that allows you to configure constraints based on the request path. +* `SecurityHandler.PathMethodMapped`, that allows you to configure constraints based on the request path and the request HTTP method. + +[[security-authenticator]] +== `Authenticator` Implementations + +Jetty provides the following `Authenticator` implementations: + +* `BasicAuthenticator`, that implements link:https://datatracker.ietf.org/doc/html/rfc7617[Basic authentication]. +* `DigestAuthentication`, that implements link:https://datatracker.ietf.org/doc/html/rfc7616[Digest authentication]. +* `FormAuthenticator`, that implements HTML form authentication using the link:https://jakarta.ee/specifications/servlet/6.1/jakarta-servlet-spec-6.1#form-based-authentication[`j_security_check` mechanism]. +* `SslClientCertAuthenticator`, that implements authentication based on client certificates. +* `SPNEGOAuthenticator`, that implements link:https://datatracker.ietf.org/doc/html/rfc4178[SPNEGO authentication]. +* `EthereumAuthenticator`, that implements link:https://eips.ethereum.org/EIPS/eip-4361[Sign-In with Ethereum authentication] (see also xref:security/siwe-support.adoc[this dedicated section]). +* `OpenIdAuthenticator`, that implements link:https://openid.net/specs/openid-connect-core-1_0-final.html[OpenID Connect Core 1.0 authentication] (see also xref:security/openid-support.adoc[this dedicated section]). +// * `JaspiAuthenticator` TODO: Jakarta EE only, should we have a separate section? +* `MultiAuthenticator`, that supports multiple authentication mechanisms for the same web application. + +[[security-login-service]] +== `LoginService` Implementations + +Jetty provides the following `LoginService` implementations: + +* `HashLoginService`, that verifies credentials retrieved from a `+*.properties+` file. +* `JDBCLoginService`, that verifies credentials retrieved from a RDBMS using the JDBC APIs. +* `DataSourceLoginService`, that verifies credentials retrieved from a RDBMS using the `DataSource` APIs. +* `JAASLoginService`, that verifies credentials retrieved using the JAAS APIs. +* `AnyUserLoginService`, that does not verify credentials, but can delegate to another `LoginService` to provide roles for authenticated users. + +Some `Authenticator` implementation require a specific `LoginService`, so the following implementation are also available: + +* `SPNEGOLoginService`, used in conjunction with `SPNEGOAuthenticator`. +* `OpenIdLoginService`, used in conjunction with `OpenIdAuthenticator`. diff --git a/documentation/jetty/modules/programming-guide/pages/server/http.adoc b/documentation/jetty/modules/programming-guide/pages/server/http.adoc index b304a90d1d31..d61fe3010479 100644 --- a/documentation/jetty/modules/programming-guide/pages/server/http.adoc +++ b/documentation/jetty/modules/programming-guide/pages/server/http.adoc @@ -1583,6 +1583,11 @@ The ``Handler``s children of `PathMappingsHandler` may extend < getKnownRoles() return Collections.emptySet(); } + private static int compareMappedResources(MappedResource mr1, MappedResource mr2) + { + PathSpecGroup g1 = mr1.getPathSpec().getGroup(); + PathSpecGroup g2 = mr2.getPathSpec().getGroup(); + int l1 = mr1.getPathSpec().getSpecLength(); + int l2 = mr2.getPathSpec().getSpecLength(); + if (g1.equals(g2)) + return Integer.compare(l1, l2); + return Integer.compare(pathSpecGroupOrder(g1), pathSpecGroupOrder(g2)); + } + + private static int pathSpecGroupOrder(PathSpecGroup group) + { + return switch (group) + { + case EXACT -> 5; + case ROOT -> 4; + case SUFFIX_GLOB -> 3; + case MIDDLE_GLOB -> 2; + case PREFIX_GLOB -> 1; + case DEFAULT -> 0; + }; + } + public class NotChecked implements Principal { @Override @@ -671,25 +696,23 @@ public SecurityHandler getSecurityHandler() } } - // TODO consider method mapping version - /** *

A concrete implementation of {@link SecurityHandler} that uses a {@link PathMappings} to * match request to a list of {@link Constraint}s, which are applied in the order of * least significant to most significant. *

* An example of using this class is: - *

-     *     SecurityHandler.PathMapped handler = new SecurityHandler.PathMapped();
-     *     handler.put("/*", Constraint.combine(Constraint.FORBIDDEN, Constraint.SECURE_TRANSPORT);
-     *     handler.put("", Constraint.ALLOWED);
-     *     handler.put("/login", Constraint.ALLOWED);
-     *     handler.put("*.png", Constraint.ANY_TRANSPORT);
-     *     handler.put("/admin/*", Constraint.from("admin", "operator"));
-     *     handler.put("/admin/super/*", Constraint.from("operator"));
-     *     handler.put("/user/*", Constraint.ANY_USER);
-     *     handler.put("*.xml", Constraint.FORBIDDEN);
-     * 
+ *
{@code
+     * SecurityHandler.PathMapped handler = new SecurityHandler.PathMapped();
+     * handler.put("/*", Constraint.combine(Constraint.FORBIDDEN, Constraint.SECURE_TRANSPORT));
+     * handler.put("", Constraint.ALLOWED);
+     * handler.put("/login", Constraint.ALLOWED);
+     * handler.put("*.png", Constraint.ANY_TRANSPORT);
+     * handler.put("/admin/*", Constraint.from("admin", "operator"));
+     * handler.put("/admin/super/*", Constraint.from("operator"));
+     * handler.put("/user/*", Constraint.ANY_USER);
+     * handler.put("*.xml", Constraint.FORBIDDEN);
+     * }
*

* When {@link #getConstraint(String, Request)} is called, any matching * constraints are sorted into least to most significant with @@ -698,14 +721,14 @@ public SecurityHandler getSecurityHandler() * For example: *

*
    - *
  • {@code "/admin/index.html"} matches {@code "/*"} and {@code "/admin/*"}, resulting in a - * constraint of {@link Authorization#SPECIFIC_ROLE} and {@link Transport#SECURE}.
  • - *
  • {@code "/admin/logo.png"} matches {@code "/*"}, {@code "/admin/*"} and {@code "*.png"}, resulting in a - * constraint of {@link Authorization#SPECIFIC_ROLE} and {@link Transport#ANY}.
  • - *
  • {@code "/admin/config.xml"} matches {@code "/*"}, {@code "/admin/*"} and {@code "*.xml"}, resulting in a - * constraint of {@link Authorization#FORBIDDEN} and {@link Transport#SECURE}.
  • - *
  • {@code "/admin/super/index.html"} matches {@code "/*"}, {@code "/admin/*"} and {@code "/admin/super/*"}, resulting in a - * constraint of {@link Authorization#SPECIFIC_ROLE} and {@link Transport#SECURE}.
  • + *
  • {@code "/admin/index.html"} matches {@code "/*"} and {@code "/admin/*"}, resulting in a + * constraint of {@link Authorization#SPECIFIC_ROLE} and {@link Transport#SECURE}.
  • + *
  • {@code "/admin/logo.png"} matches {@code "/*"}, {@code "/admin/*"} and {@code "*.png"}, resulting in a + * constraint of {@link Authorization#SPECIFIC_ROLE} and {@link Transport#ANY}.
  • + *
  • {@code "/admin/config.xml"} matches {@code "/*"}, {@code "/admin/*"} and {@code "*.xml"}, resulting in a + * constraint of {@link Authorization#FORBIDDEN} and {@link Transport#SECURE}.
  • + *
  • {@code "/admin/super/index.html"} matches {@code "/*"}, {@code "/admin/*"} and {@code "/admin/super/*"}, resulting in a + * constraint of {@link Authorization#SPECIFIC_ROLE} and {@link Transport#SECURE}.
  • *
*/ public static class PathMapped extends SecurityHandler implements Comparator @@ -798,15 +821,7 @@ public int compare(PathSpec ps1, PathSpec ps2) int compare(MappedResource c1, MappedResource c2) { - PathSpecGroup g1 = c1.getPathSpec().getGroup(); - PathSpecGroup g2 = c2.getPathSpec().getGroup(); - int l1 = c1.getPathSpec().getSpecLength(); - int l2 = c2.getPathSpec().getSpecLength(); - - if (g1.equals(g2)) - return Integer.compare(l1, l2); - - return Integer.compare(pathSpecGroupPrecedence(g1), pathSpecGroupPrecedence(g2)); + return compareMappedResources(c1, c2); } /** @@ -825,15 +840,149 @@ int compare(MappedResource c1, MappedResource c2) */ protected int pathSpecGroupPrecedence(PathSpecGroup group) { - return switch (group) + return pathSpecGroupOrder(group); + } + + @Override + protected Set getKnownRoles() + { + return _knownRoles; + } + } + + /** + *

A concrete implementation of {@link SecurityHandler} that uses a {@link PathMappings} + * to match request paths to a map of an HTTP method to a {@link Constraint}.

+ *

Request path matches are sorted from the least significant to the most significant, + * and the associated constraints are combined in order.

+ *

For example:

+ *
{@code
+     * SecurityHandler.PathMethodMapped handler = new SecurityHandler.PathMethodMapped();
+     * handler.put(PathSpec.from("/*"), "*", Constraint.combine(Constraint.FORBIDDEN, Constraint.SECURE_TRANSPORT));
+     * handler.put(PathSpec.from("/releases/*"), "GET", Constraint.from("read"));
+     * handler.put(PathSpec.from("/releases/*"), "PUT", Constraint.from("write"));
+     * }
+ *

For these request paths:

+ *
    + *
  • {@code /foo} matches {@code /*}; + * any HTTP method results in a constraint with {@link Authorization#FORBIDDEN} and {@link Transport#SECURE}
  • + *
  • {@code /releases/jetty-12.1.0.tar.gz} matches {@code /releases/*}; + * method {@code GET} results in a constraint with {@link Authorization#SPECIFIC_ROLE} with role {@code read} + * and {@link Transport#SECURE}; + * method {@code PUT} results in a constraint with {@link Authorization#SPECIFIC_ROLE} with role {@code write} + * and {@link Transport#SECURE}; + * any other HTTP method results in a constraint with {@link Authorization#FORBIDDEN} and {@link Transport#SECURE}
  • + *
+ */ + public static class PathMethodMapped extends SecurityHandler + { + private static final String ALL_METHODS = "*"; + + private final PathMappings> _constraints = new PathMappings<>(); + private final Set _knownRoles = new HashSet<>(); + + public PathMethodMapped() + { + this(null); + } + + public PathMethodMapped(Handler handler) + { + super(handler); + } + + /** + *

Associates the given {@link Constraint} to the given path and HTTP method.

+ * + * @param pathSpec the {@link PathSpec} associated to the given constraint + * @param method the HTTP method associated to the given constraint, or {@code null} + * to indicate all HTTP methods + * @param constraint the constraint to associate + * @return the previous constraint associated with the given path and HTTP method, + * or {@code null} is there was no association + */ + public Constraint put(String pathSpec, String method, Constraint constraint) + { + return put(PathSpec.from(pathSpec), method, constraint); + } + + /** + *

Associates the given {@link Constraint} to the given path and HTTP method.

+ + * @param pathSpec the {@link PathSpec} associated to the given constraint + * @param method the HTTP method associated to the given constraint, or {@code null} + * to indicate all HTTP methods + * @param constraint the constraint to associate + * @return the previous constraint associated with the given path and HTTP method, + * or {@code null} is there was no association + */ + public Constraint put(PathSpec pathSpec, String method, Constraint constraint) + { + Objects.requireNonNull(pathSpec); + if (method == null) + method = ALL_METHODS; + Objects.requireNonNull(constraint); + Map methodConstraints = _constraints.computeIfAbsent(pathSpec, k -> new HashMap<>()); + Constraint result = methodConstraints.put(method, constraint); + if (result != null) + recomputeKnownRoles(); + else + _knownRoles.addAll(constraint.getRoles()); + return result; + } + + /** + *

Associates the given {@link Constraint} to the given path and HTTP methods.

+ * + * @param pathSpec the {@link PathSpec} associated to the given constraint + * @param methods the list of HTTP methods associated to the given constraint + * @param constraint the constraint to associate + */ + public void put(PathSpec pathSpec, List methods, Constraint constraint) + { + if (methods.isEmpty() || methods.contains(ALL_METHODS)) + throw new IllegalArgumentException("Invalid method list"); + methods.forEach(method -> put(pathSpec, method, constraint)); + } + + @Override + protected Constraint getConstraint(String pathInContext, Request request) + { + List>> matches = _constraints.getMatches(pathInContext); + + if (matches == null || matches.isEmpty()) + return Constraint.ALLOWED; + + // Sort from least specific to most specific to combine constraints properly. + matches.sort(SecurityHandler::compareMappedResources); + + String method = request.getMethod(); + + Constraint result = null; + for (MappedResource> match : matches) + { + Map methodConstraints = match.getResource(); + + Constraint constraint = methodConstraints.get(method); + if (constraint == null) + constraint = methodConstraints.get(ALL_METHODS); + + result = Constraint.combine(result, constraint); + } + + return result != null ? result : Constraint.FORBIDDEN; + } + + private void recomputeKnownRoles() + { + _knownRoles.clear(); + for (Map m : _constraints.values()) { - case EXACT -> 5; - case ROOT -> 4; - case SUFFIX_GLOB -> 3; - case MIDDLE_GLOB -> 2; - case PREFIX_GLOB -> 1; - case DEFAULT -> 0; - }; + for (Constraint c : m.values()) + { + _knownRoles.addAll(c.getRoles()); + } + } } @Override diff --git a/jetty-core/jetty-security/src/test/java/org/eclipse/jetty/security/PathMethodMappedTest.java b/jetty-core/jetty-security/src/test/java/org/eclipse/jetty/security/PathMethodMappedTest.java new file mode 100644 index 000000000000..f88a905275c5 --- /dev/null +++ b/jetty-core/jetty-security/src/test/java/org/eclipse/jetty/security/PathMethodMappedTest.java @@ -0,0 +1,256 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.security; + +import java.nio.file.Path; +import java.util.function.Consumer; + +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpTester; +import org.eclipse.jetty.security.authentication.BasicAuthenticator; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.LocalConnector; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.toolchain.test.MavenPaths; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.component.LifeCycle; +import org.eclipse.jetty.util.resource.ResourceFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class PathMethodMappedTest +{ + private Server server; + private LocalConnector connector; + + private void start(Consumer configurator) throws Exception + { + server = new Server(); + connector = new LocalConnector(server); + server.addConnector(connector); + + SecurityHandler.PathMethodMapped securityHandler = new SecurityHandler.PathMethodMapped(); + configurator.accept(securityHandler); + + Path realm = MavenPaths.findTestResourceFile("test-realm.properties"); + HashLoginService loginService = new HashLoginService("Test", ResourceFactory.of(server).newResource(realm)); + BasicAuthenticator authenticator = new BasicAuthenticator(); + authenticator.setLoginService(loginService); + securityHandler.setAuthenticator(authenticator); + securityHandler.setLoginService(loginService); + + ContextHandler contextHandler = new ContextHandler(securityHandler); + server.setHandler(contextHandler); + server.start(); + } + + @AfterEach + public void dispose() + { + LifeCycle.stop(server); + } + + @Test + public void testNoMappings() throws Exception + { + start(s -> + { + s.setHandler(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + callback.succeeded(); + return true; + } + }); + }); + + HttpTester.Request request = HttpTester.newRequest(); + request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("test", "password")); + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request.generate())); + + // No matches, request is allowed. + assertEquals(HttpStatus.OK_200, response.getStatus()); + } + + @Test + public void testAllPathsAllMethodsForbidden() throws Exception + { + start(s -> + { + s.put("/*", "*", Constraint.FORBIDDEN); + s.setHandler(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + callback.succeeded(); + return true; + } + }); + }); + + HttpTester.Request request = HttpTester.newRequest(); + request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("test", "password")); + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request.generate())); + + // All requests are forbidden. + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + } + + @Test + public void testAllPathsOnlyGETAllowed() throws Exception + { + start(s -> + { + s.put("/*", "*", Constraint.FORBIDDEN); + s.put("/*", "GET", Constraint.from("read")); + s.setHandler(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + Request.AuthenticationState state = Request.getAuthenticationState(request); + assertNotNull(state); + assertEquals("reader", state.getUserPrincipal().getName()); + callback.succeeded(); + return true; + } + }); + }); + + // User "test" does not have roles, so forbidden. + HttpTester.Request request = HttpTester.newRequest(); + request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("test", "password")); + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request.generate())); + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + + // User "reader" has role "read", so it can only perform GET requests. + request = HttpTester.newRequest(); + request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("reader", "password")); + response = HttpTester.parseResponse(connector.getResponse(request.generate())); + assertEquals(HttpStatus.OK_200, response.getStatus()); + + request = HttpTester.newRequest(); + request.setMethod("PUT"); + request.put(HttpHeader.CONTENT_LENGTH, 0); + request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("reader", "password")); + response = HttpTester.parseResponse(connector.getResponse(request.generate())); + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + } + + @Test + public void testAllPathsOnlyPUTAllowed() throws Exception + { + start(s -> + { + s.put("/*", "*", Constraint.FORBIDDEN); + s.put("/*", "PUT", Constraint.from("write")); + s.setHandler(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + Request.AuthenticationState state = Request.getAuthenticationState(request); + assertNotNull(state); + assertEquals("writer", state.getUserPrincipal().getName()); + callback.succeeded(); + return true; + } + }); + }); + + // User "test" does not have roles, so forbidden. + HttpTester.Request request = HttpTester.newRequest(); + request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("test", "password")); + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request.generate())); + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + + // User "reader" has role "read", so it can only perform GET requests. + request = HttpTester.newRequest(); + request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("reader", "password")); + response = HttpTester.parseResponse(connector.getResponse(request.generate())); + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + + request = HttpTester.newRequest(); + request.setMethod("PUT"); + request.put(HttpHeader.CONTENT_LENGTH, 0); + request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("reader", "password")); + response = HttpTester.parseResponse(connector.getResponse(request.generate())); + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + + // User "writer" has role "write", so it can only perform PUT requests. + request = HttpTester.newRequest(); + request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("writer", "password")); + response = HttpTester.parseResponse(connector.getResponse(request.generate())); + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + + request = HttpTester.newRequest(); + request.setMethod("PUT"); + request.put(HttpHeader.CONTENT_LENGTH, 0); + request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("writer", "password")); + response = HttpTester.parseResponse(connector.getResponse(request.generate())); + assertEquals(HttpStatus.OK_200, response.getStatus()); + } + + @Test + public void allPathsGETAndPUTAllowed() throws Exception + { + start(s -> + { + s.put("/*", "*", Constraint.FORBIDDEN); + s.put("/*", "GET", Constraint.from("read")); + s.put("/*", "PUT", Constraint.from("write")); + s.setHandler(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + Request.AuthenticationState state = Request.getAuthenticationState(request); + assertNotNull(state); + assertEquals("admin", state.getUserPrincipal().getName()); + callback.succeeded(); + return true; + } + }); + }); + + // User "test" does not have roles, so forbidden. + HttpTester.Request request = HttpTester.newRequest(); + request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("test", "password")); + HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request.generate())); + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + + // User "admin" has both read and write roles. + request = HttpTester.newRequest(); + request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("admin", "password")); + response = HttpTester.parseResponse(connector.getResponse(request.generate())); + assertEquals(HttpStatus.OK_200, response.getStatus()); + + request = HttpTester.newRequest(); + request.setMethod("PUT"); + request.put(HttpHeader.CONTENT_LENGTH, 0); + request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("admin", "password")); + response = HttpTester.parseResponse(connector.getResponse(request.generate())); + assertEquals(HttpStatus.OK_200, response.getStatus()); + } +} diff --git a/jetty-core/jetty-security/src/test/resources/test-realm.properties b/jetty-core/jetty-security/src/test/resources/test-realm.properties new file mode 100644 index 000000000000..19f8f39cdc16 --- /dev/null +++ b/jetty-core/jetty-security/src/test/resources/test-realm.properties @@ -0,0 +1,4 @@ +test:password +reader:password,read +writer:password,write +admin:password,read,write diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java index 40d31eab1e67..819576117561 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java @@ -1134,7 +1134,7 @@ static AuthenticationState getAuthenticationState(Request request) { if (request.getAttribute(AuthenticationState.class.getName()) instanceof AuthenticationState authenticationState) return authenticationState; - return null; + return AuthenticationState.NONE; } /** @@ -1152,6 +1152,8 @@ static void setAuthenticationState(Request request, AuthenticationState state) */ interface AuthenticationState { + AuthenticationState NONE = new AuthenticationState() {}; + /** * @return The authenticated user {@link Principal}, or null if the Authentication is in a non-authenticated state. */ From ec83096a0160c4990974d857c864e7b321aa39e5 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Fri, 24 Oct 2025 10:15:57 +0200 Subject: [PATCH 2/5] Updates from review. Signed-off-by: Simone Bordet --- .../programming/security/SecurityDocs.java | 140 +++++-- .../pages/security/index.adoc | 108 +++++- jetty-core/jetty-security/pom.xml | 1 + .../jetty/security/SecurityHandler.java | 25 +- .../jetty/security/PathMethodMappedTest.java | 360 ++++++++++++++---- .../src/test/resources/keystore.p12 | Bin 0 -> 2774 bytes .../jetty/server/CustomRequestLog.java | 3 +- .../internal/CompletedUpgradeRequest.java | 3 +- 8 files changed, 517 insertions(+), 123 deletions(-) create mode 100644 jetty-core/jetty-security/src/test/resources/keystore.p12 diff --git a/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/SecurityDocs.java b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/SecurityDocs.java index 7fcb9162686a..43c6310d526e 100644 --- a/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/SecurityDocs.java +++ b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/security/SecurityDocs.java @@ -15,6 +15,9 @@ import java.security.Principal; +import org.eclipse.jetty.ee11.servlet.security.ConstraintMapping; +import org.eclipse.jetty.ee11.servlet.security.ConstraintSecurityHandler; +import org.eclipse.jetty.ee11.webapp.WebAppContext; import org.eclipse.jetty.security.Constraint; import org.eclipse.jetty.security.HashLoginService; import org.eclipse.jetty.security.SecurityHandler; @@ -23,7 +26,6 @@ import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.server.handler.ContextHandler; import org.eclipse.jetty.util.Callback; import org.eclipse.jetty.util.resource.ResourceFactory; @@ -36,6 +38,20 @@ public class SecurityDocs public void pathMapped() throws Exception { // tag::pathMapped[] + class AppHandler extends Handler.Abstract + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + // Retrieve the authenticated user for this request. + Principal principal = Request.getAuthenticationState(request).getUserPrincipal(); + System.getLogger("app").log(INFO, "Current user is: {0}", principal); + + callback.succeeded(); + return true; + } + } + Server server = new Server(); // The ContextHandler for the application. @@ -52,28 +68,19 @@ public void pathMapped() throws Exception // The SecurityHandler.PathMapped maps URI paths to constraints. SecurityHandler.PathMapped securityHandler = new SecurityHandler.PathMapped(); + securityHandler.setAuthenticator(authenticator); + securityHandler.setLoginService(loginService); + + // Configure constraints. // Require that all requests use a secure transport. securityHandler.put("/*", Constraint.SECURE_TRANSPORT); // URI paths that start with /admin/ can only be accessed by users with the "admin" role. securityHandler.put("/admin/*", Constraint.from("admin")); - securityHandler.setAuthenticator(authenticator); - securityHandler.setLoginService(loginService); + // Link the Handlers. server.setHandler(contextHandler); contextHandler.setHandler(securityHandler); - securityHandler.setHandler(new Handler.Abstract() - { - @Override - public boolean handle(Request request, Response response, Callback callback) - { - // Retrieve the authenticated user for this request. - Principal principal = Request.getAuthenticationState(request).getUserPrincipal(); - System.getLogger("app").log(INFO, "Current user is: {0}", principal); - - callback.succeeded(); - return true; - } - }); + securityHandler.setHandler(new AppHandler()); server.start(); // end::pathMapped[] @@ -98,10 +105,6 @@ public boolean handle(Request request, Response response, Callback callback) Server server = new Server(); - ServerConnector connector = new ServerConnector(server); - connector.setPort(37023); - server.addConnector(connector); - // The ContextHandler for the application. ContextHandler contextHandler = new ContextHandler("/app"); @@ -116,15 +119,18 @@ public boolean handle(Request request, Response response, Callback callback) // The SecurityHandler.PathMapped maps URI paths to constraints. SecurityHandler.PathMethodMapped securityHandler = new SecurityHandler.PathMethodMapped(); + securityHandler.setAuthenticator(authenticator); + securityHandler.setLoginService(loginService); + + // Configure constraints. // Unless otherwise specified, access to resources is forbidden and requires secure transport. securityHandler.put("/*", "*", Constraint.combine(Constraint.FORBIDDEN, Constraint.SECURE_TRANSPORT)); // GET /data/* is allowed only to users with the "read" role. securityHandler.put("/data/*", "GET", Constraint.from("read")); // PUT /data/* is allowed only to users with the "write" role. securityHandler.put("/data/*", "PUT", Constraint.from("write")); - securityHandler.setAuthenticator(authenticator); - securityHandler.setLoginService(loginService); + // Link the Handlers. server.setHandler(contextHandler); contextHandler.setHandler(securityHandler); securityHandler.setHandler(new AppHandler()); @@ -133,8 +139,94 @@ public boolean handle(Request request, Response response, Callback callback) // end::pathMethodMapped[] } - public static void main(String[] args) throws Exception + public void jakartaPathMapped() throws Exception { - new SecurityDocs().pathMethodMapped(); + // tag::jakartaPathMapped[] + Server server = new Server(); + + WebAppContext webApp = new WebAppContext(); + webApp.setContextPath("/app"); + webApp.setWar("/path/to/app.war"); + + // HashLoginService maps users, passwords and roles + // from the realm.properties file in the server class-path. + HashLoginService loginService = new HashLoginService(); + loginService.setConfig(ResourceFactory.of(webApp).newClassLoaderResource("realm.properties")); + + // Use Basic authentication, which requires a secure transport. + BasicAuthenticator authenticator = new BasicAuthenticator(); + authenticator.setLoginService(loginService); + + ConstraintSecurityHandler securityHandler = new ConstraintSecurityHandler(); + securityHandler.setAuthenticator(authenticator); + securityHandler.setLoginService(loginService); + + // Configure constraints. + ConstraintMapping constraintMapping = new ConstraintMapping(); + constraintMapping.setPathSpec("/*"); + constraintMapping.setConstraint(Constraint.SECURE_TRANSPORT); + securityHandler.addConstraintMapping(constraintMapping); + constraintMapping = new ConstraintMapping(); + constraintMapping.setPathSpec("/admin/*"); + constraintMapping.setConstraint(Constraint.from("admin")); + securityHandler.addConstraintMapping(constraintMapping); + + // Link the Handlers. + server.setHandler(webApp); + // Note the specific call to setSecurityHandler(). + webApp.setSecurityHandler(securityHandler); + + server.start(); + // end::jakartaPathMapped[] + } + + public void jakartaPathMethodMapped() throws Exception + { + // tag::jakartaPathMethodMapped[] + Server server = new Server(); + + WebAppContext webApp = new WebAppContext(); + webApp.setContextPath("/app"); + webApp.setWar("/path/to/app.war"); + + // HashLoginService maps users, passwords and roles + // from the realm.properties file in the server class-path. + HashLoginService loginService = new HashLoginService(); + loginService.setConfig(ResourceFactory.of(webApp).newClassLoaderResource("realm.properties")); + + // Use Basic authentication, which requires a secure transport. + BasicAuthenticator authenticator = new BasicAuthenticator(); + authenticator.setLoginService(loginService); + + ConstraintSecurityHandler securityHandler = new ConstraintSecurityHandler(); + securityHandler.setAuthenticator(authenticator); + securityHandler.setLoginService(loginService); + + // Configure constraints. + // Forbid access for uncovered HTTP methods. + securityHandler.setDenyUncoveredHttpMethods(true); + // No HTTP method specified, therefore applies to all methods. + ConstraintMapping constraintMapping = new ConstraintMapping(); + constraintMapping.setPathSpec("/*"); + constraintMapping.setConstraint(Constraint.combine(Constraint.FORBIDDEN, Constraint.SECURE_TRANSPORT)); + securityHandler.addConstraintMapping(constraintMapping); + // GET /data/* is allowed only to users with the "read" role. + constraintMapping = new ConstraintMapping(); + constraintMapping.setPathSpec("/data/*"); + constraintMapping.setMethod("GET"); + constraintMapping.setConstraint(Constraint.combine(Constraint.SECURE_TRANSPORT, Constraint.from("read"))); + // PUT /data/* is allowed only to users with the "write" role. + constraintMapping = new ConstraintMapping(); + constraintMapping.setPathSpec("/data/*"); + constraintMapping.setMethod("PUT"); + constraintMapping.setConstraint(Constraint.combine(Constraint.SECURE_TRANSPORT, Constraint.from("write"))); + + // Link the Handlers. + server.setHandler(webApp); + // Note the specific call to setSecurityHandler(). + webApp.setSecurityHandler(securityHandler); + + server.start(); + // end::jakartaPathMethodMapped[] } } diff --git a/documentation/jetty/modules/programming-guide/pages/security/index.adoc b/documentation/jetty/modules/programming-guide/pages/security/index.adoc index 93582c1b28ce..e129d864f51b 100644 --- a/documentation/jetty/modules/programming-guide/pages/security/index.adoc +++ b/documentation/jetty/modules/programming-guide/pages/security/index.adoc @@ -19,7 +19,7 @@ The processing of the request may be: * _forbidden_; the request is immediately responded with a `403` status code. * _allowed_; the request processing is allowed to continue to the application, which produces a response. -* _challenged_; the request may be allowed, but the credentials are missing, so the request is responded with a `401` status code. +* _challenged_; the request may be allowed, but the credentials are missing (or wrong), so the request is responded with a `401` status code. Jetty implements web application security with 3 components: @@ -47,15 +47,18 @@ Authenticator -> SecurityHandler : AuthenticationState ---- 1. The `SecurityHandler` subclass returns a `Constraint` based on the subclass-specific logic, for example based on the request path, the request method, etc. -2. The `Constraint` is used to check if the request is trivially allowed, or trivially forbidden, or whether it requires a secure transport, and if so immediately handled with no further actions. +2. The `Constraint` is used to check if the request is trivially allowed, or trivially forbidden, or whether it requires a secure transport, and if so immediately handled by `SecurityHandler` with no further actions. Otherwise, the `Constraint` declares what requirements about authorization, transport and roles are necessary for the request to be allowed. 3. The `SecurityHandler` calls `Authenticator.validateRequest(\...)` that performs implementation-specific logic to retrieve the authentication credentials, for example from HTTP request headers such as `Authorization`. 4. The `Authenticator` calls `LoginService.login(\...)` to verify the credentials and, if the verification is successful, obtain information about the roles associated with these credentials. 5. The `Authenticator` builds an `AuthenticationState` with the results of the call to `LoginService.login(\...)`, and returns it to the `SecurityHandler`. 6. The `SecurityHandler`, based on the received `AuthenticationState`, either allows the request to be processed by its child `Handler`, or sends an appropriate response to the client, for example a `401` challenge response or a `403` forbidden response. -[[security-configuration]] -== Configuring Security +[[security-core-configuration]] +== Configuring Jetty Core Security + +This section is about configuring security for Jetty Core web applications. +To configure security for Jakarta EE web applications, see <>. `SecurityHandler` is typically configured as a child of the `ContextHandler` that represents the web application. @@ -87,14 +90,21 @@ david:password,admin The behavior of the example above is the following: +* For any request, _all_ the request path matches are collected and processed in order from the least significant to the most significant, combining their constraint. * A non-secure request using the `http` scheme is replied with a `403` response, since secure transport is required. -* A request for `/app/foo` is replied with a `200` response, and the application `Handler` would see a `null` request principal. -* A request for `/app/admin/bar` without `Authorization` header is replied with a `401` response, since authentication is required. +* A request for `/app/foo` matches the constraint mapped to `+/*+`, and is replied with a `200` response; `AppHandler` would see a `null` request principal. +* A request for `/app/admin/bar` without `Authorization` header matches both the constraints mapped to `+/*+` and `+/admin/*+`, and is replied with a `401` response, since authentication is required. * A request for `/app/admin/bar` with an `Authorization` header containing invalid or unknown credentials is replied with a `401` response, since authentication is required. * A request for `/app/admin/bar` with an `Authorization` header containing valid credentials with a role that is not `admin` is replied with a `401` response, since authentication is required. -* A request for `/app/admin/bar` with an `Authorization` header containing valid credentials for user `david`, that has `admin` role, is replied with a `200` response, and the application `Handler` would see a request principal for user `david`. +* A request for `/app/admin/bar` with an `Authorization` header containing valid credentials for user `david`, that has `admin` role, is replied with a `200` response, and `AppHandler` would see a request principal for user `david`. + +[TIP] +==== +It is good practice to have a default constraint for all paths (that is, for path `+/*+`), even if it is `Constraint.ALLOWED`. +In this way it is clear what is the intended behavior for paths that do not match more specific constraint configurations. +==== -A more complex example uses `SecurityHandler.PathMethodMapped` to allow users with the `read` role to read resources, and users with the `write` role to write resources: +The more complex example below uses `SecurityHandler.PathMethodMapped` to allow users with the `read` role to read resources, and users with the `write` role to write resources: [,java,indent=0] ---- @@ -116,6 +126,85 @@ Both `bob` and `alice` are granted access to these resources, but only if they u Similarly, access to `/data/*` resources using the HTTP method `PUT` is granted but only to users with `write` role. Only `alice` is granted access to these resources, but only if she uses the `PUT` method. +[TIP] +==== +It is good practice to have a default constraint for all paths (that is, for path `+/*+`), and for all HTTP methods (that is, for method `+*+`), even if it is `Constraint.ALLOWED`. +In this way it is clear what is the intended behavior for paths and HTTP methods that do not match more specific constraint configurations. +==== + +[IMPORTANT] +==== +The behavior of `SecurityHandler` implementations for the Jetty Core API is to combine all the constraints that match. +This is different from the behavior of the `SecurityHandler` implementations for the Jakarta EE APIs, that only use the constraint of the most specific match. +==== + +[[security-jakarta-configuration]] +== Configuring Jakarta EE Security + +This section is about configuring security for Jakarta EE web applications. +To configure security for Jetty Core web applications, see <>. + +To configure Jakarta EE web application security you must use the `SecurityHandler` subclass `org.eclipse.jetty.{ee-current}.servlet.security.ConstraintSecurityHandler`. + +Below you can find a simple example that sets up a `ConstraintSecurityHandler` to secure your Jakarta EE web application: + +[,java,indent=0] +---- +include::code:example$src/main/java/org/eclipse/jetty/docs/programming/security/SecurityDocs.java[tags=jakartaPathMapped] +---- + +The `Handler` tree structure looks like the following: + +[,screen] +---- +Server +└── WebAppContext /app + └── ConstraintSecurityHandler + └── ServletHandler + └── AppServlet (defined in app.war) +---- + +The `realm.properties` file used by `HashLoginService` is the following: + +.realm.properties +---- +# Format: :, +carol:password +david:password,admin +---- + +Note how the constraints are configured using `org.eclipse.jetty.ee11.servlet.security.ConstraintMapping`, that allows to configure the path, the HTTP method and the `Constraint`. + +The more complex example below uses `ConstraintSecurityHandler` to allow users with the `read` role to read resources, and users with the `write` role to write resources: + +[,java,indent=0] +---- +include::code:example$src/main/java/org/eclipse/jetty/docs/programming/security/SecurityDocs.java[tags=jakartaPathMethodMapped] +---- + +.realm.properties +---- +# Format: :, +bob:password,read +alice:password,read,write +---- + +In the example above, access to any resource is by default forbidden, and requires secure transport. + +However, access to `/data/*` resources using the HTTP method `GET` is granted but only to users with `read` role. +Both `bob` and `alice` are granted access to these resources, but only if they use the `GET` method. + +Similarly, access to `/data/*` resources using the HTTP method `PUT` is granted but only to users with `write` role. +Only `alice` is granted access to these resources, but only if she uses the `PUT` method. + +Note also that `ConstraintSecurityHandler.denyUncoveredHttpMethods=true` to make sure that requests that match `+/data/*+` but are neither `GET` nor `PUT` are denied. + +[IMPORTANT] +==== +The behavior of `SecurityHandler` implementations for the Jakarta EE API is to only use the constraint of the most specific match. +This is different from the behavior of the `SecurityHandler` implementations for the Jetty Core APIs, that combine all the constraints that match. +==== + [[security-handler]] == `SecurityHandler` Implementations @@ -123,6 +212,7 @@ Jetty provides the following `SecurityHandler` implementations: * `SecurityHandler.PathMapped`, that allows you to configure constraints based on the request path. * `SecurityHandler.PathMethodMapped`, that allows you to configure constraints based on the request path and the request HTTP method. +* `ConstraintSecurityHandler`, that implements the Jakarta EE web application security constraints. [[security-authenticator]] == `Authenticator` Implementations @@ -136,8 +226,8 @@ Jetty provides the following `Authenticator` implementations: * `SPNEGOAuthenticator`, that implements link:https://datatracker.ietf.org/doc/html/rfc4178[SPNEGO authentication]. * `EthereumAuthenticator`, that implements link:https://eips.ethereum.org/EIPS/eip-4361[Sign-In with Ethereum authentication] (see also xref:security/siwe-support.adoc[this dedicated section]). * `OpenIdAuthenticator`, that implements link:https://openid.net/specs/openid-connect-core-1_0-final.html[OpenID Connect Core 1.0 authentication] (see also xref:security/openid-support.adoc[this dedicated section]). -// * `JaspiAuthenticator` TODO: Jakarta EE only, should we have a separate section? * `MultiAuthenticator`, that supports multiple authentication mechanisms for the same web application. +* `JaspiAuthenticator`, for the link:https://jakarta.ee/specifications/authentication/[Jakarta Authentication API]. [[security-login-service]] == `LoginService` Implementations diff --git a/jetty-core/jetty-security/pom.xml b/jetty-core/jetty-security/pom.xml index b47a437b74dd..4e96c8572dbd 100644 --- a/jetty-core/jetty-security/pom.xml +++ b/jetty-core/jetty-security/pom.xml @@ -27,6 +27,7 @@ org.slf4j slf4j-api + org.apache.directory.api api-asn1-api diff --git a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/SecurityHandler.java b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/SecurityHandler.java index 708458e7673e..f23fc193a673 100644 --- a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/SecurityHandler.java +++ b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/SecurityHandler.java @@ -730,6 +730,9 @@ public SecurityHandler getSecurityHandler() *
  • {@code "/admin/super/index.html"} matches {@code "/*"}, {@code "/admin/*"} and {@code "/admin/super/*"}, resulting in a * constraint of {@link Authorization#SPECIFIC_ROLE} and {@link Transport#SECURE}.
  • * + *

    If a request path is uncovered, that is there is no match for the request path, then the constraint is + * assumed to be {@link Constraint#ALLOWED}.

    + *

    It is therefore good practice to always explicitly configure a constraint for path {@code /*}.

    */ public static class PathMapped extends SecurityHandler implements Comparator { @@ -853,6 +856,7 @@ protected Set getKnownRoles() /** *

    A concrete implementation of {@link SecurityHandler} that uses a {@link PathMappings} * to match request paths to a map of an HTTP method to a {@link Constraint}.

    + *

    The token {@code *} is used to indicate all HTTP methods.

    *

    Request path matches are sorted from the least significant to the most significant, * and the associated constraints are combined in order.

    *

    For example:

    @@ -873,6 +877,12 @@ protected Set getKnownRoles() * and {@link Transport#SECURE}; * any other HTTP method results in a constraint with {@link Authorization#FORBIDDEN} and {@link Transport#SECURE} * + *

    If a request path is uncovered, that is there is no match for the request path, then the constraint is + * assumed to be {@link Constraint#ALLOWED}.

    + *

    If an HTTP method is uncovered, that is there is no match for the request URI, or no match for the HTTP method, + * then the constraint is assumed to be {@link Constraint#ALLOWED}.

    + *

    It is therefore good practice to always explicitly configure a constraint for path {@code /*} and HTTP method + * {@code *}.

    */ public static class PathMethodMapped extends SecurityHandler { @@ -954,7 +964,8 @@ protected Constraint getConstraint(String pathInContext, Request request) return Constraint.ALLOWED; // Sort from least specific to most specific to combine constraints properly. - matches.sort(SecurityHandler::compareMappedResources); + if (matches.size() > 1) + matches.sort(SecurityHandler::compareMappedResources); String method = request.getMethod(); @@ -963,14 +974,18 @@ protected Constraint getConstraint(String pathInContext, Request request) { Map methodConstraints = match.getResource(); - Constraint constraint = methodConstraints.get(method); - if (constraint == null) - constraint = methodConstraints.get(ALL_METHODS); + // A constraint for all HTTP methods may be used to establish + // defaults such as Constraint.SECURE_TRANSPORT, so always + // combine it with the Constraint for the specific HTTP method. + Constraint allMethodsConstraint = methodConstraints.get(ALL_METHODS); + Constraint specificMethodConstraint = methodConstraints.get(method); + Constraint constraint = Constraint.combine(allMethodsConstraint, specificMethodConstraint); + // Combine the constraints from all URI matches. result = Constraint.combine(result, constraint); } - return result != null ? result : Constraint.FORBIDDEN; + return result; } private void recomputeKnownRoles() diff --git a/jetty-core/jetty-security/src/test/java/org/eclipse/jetty/security/PathMethodMappedTest.java b/jetty-core/jetty-security/src/test/java/org/eclipse/jetty/security/PathMethodMappedTest.java index f88a905275c5..332870d69c6a 100644 --- a/jetty-core/jetty-security/src/test/java/org/eclipse/jetty/security/PathMethodMappedTest.java +++ b/jetty-core/jetty-security/src/test/java/org/eclipse/jetty/security/PathMethodMappedTest.java @@ -13,39 +13,60 @@ package org.eclipse.jetty.security; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.channels.SocketChannel; import java.nio.file.Path; import java.util.function.Consumer; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.http.HttpTester; import org.eclipse.jetty.security.authentication.BasicAuthenticator; import org.eclipse.jetty.server.Handler; -import org.eclipse.jetty.server.LocalConnector; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Response; import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.server.handler.ContextHandler; import org.eclipse.jetty.toolchain.test.MavenPaths; import org.eclipse.jetty.util.Callback; import org.eclipse.jetty.util.component.LifeCycle; import org.eclipse.jetty.util.resource.ResourceFactory; +import org.eclipse.jetty.util.ssl.SslContextFactory; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.startsWith; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; public class PathMethodMappedTest { private Server server; - private LocalConnector connector; + private ServerConnector connector; + private ServerConnector tlsConnector; private void start(Consumer configurator) throws Exception { server = new Server(); - connector = new LocalConnector(server); + HttpConfiguration httpConfig = new HttpConfiguration(); + connector = new ServerConnector(server, 1, 1, new HttpConnectionFactory(httpConfig)); server.addConnector(connector); + SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); + sslContextFactory.setKeyStorePath(MavenPaths.findTestResourceFile("keystore.p12")); + sslContextFactory.setKeyStorePassword("storepwd"); + tlsConnector = new ServerConnector(server, 1, 1, sslContextFactory); + server.addConnector(tlsConnector); SecurityHandler.PathMethodMapped securityHandler = new SecurityHandler.PathMethodMapped(); configurator.accept(securityHandler); @@ -60,6 +81,8 @@ private void start(Consumer configurator) thro ContextHandler contextHandler = new ContextHandler(securityHandler); server.setHandler(contextHandler); server.start(); + + httpConfig.setSecurePort(tlsConnector.getLocalPort()); } @AfterEach @@ -84,12 +107,53 @@ public boolean handle(Request request, Response response, Callback callback) }); }); - HttpTester.Request request = HttpTester.newRequest(); - request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("test", "password")); - HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request.generate())); + try (SocketChannel client = SocketChannel.open(new InetSocketAddress("localhost", connector.getLocalPort()))) + { + client.write(UTF_8.encode(""" + GET / HTTP/1.1 + Host: localhost + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("test", "password"))) + ); + + HttpTester.Response response = HttpTester.parseResponse(client); + // No path matches, request is allowed. + assertEquals(HttpStatus.OK_200, response.getStatus()); + } + } + + @Test + public void testAllPathsOneMethodMappingRequestWithOtherMethodAllowed() throws Exception + { + start(s -> + { + s.put("/*", "TRACE", Constraint.FORBIDDEN); + s.setHandler(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + callback.succeeded(); + return true; + } + }); + }); + + try (SocketChannel client = SocketChannel.open(new InetSocketAddress("localhost", connector.getLocalPort()))) + { + client.write(UTF_8.encode(""" + GET / HTTP/1.1 + Host: localhost + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("test", "password"))) + ); - // No matches, request is allowed. - assertEquals(HttpStatus.OK_200, response.getStatus()); + HttpTester.Response response = HttpTester.parseResponse(client); + // No method matches, request is allowed. + assertEquals(HttpStatus.OK_200, response.getStatus()); + } } @Test @@ -109,12 +173,20 @@ public boolean handle(Request request, Response response, Callback callback) }); }); - HttpTester.Request request = HttpTester.newRequest(); - request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("test", "password")); - HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request.generate())); + try (SocketChannel client = SocketChannel.open(new InetSocketAddress("localhost", connector.getLocalPort()))) + { + client.write(UTF_8.encode(""" + GET / HTTP/1.1 + Host: localhost + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("test", "password"))) + ); - // All requests are forbidden. - assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + HttpTester.Response response = HttpTester.parseResponse(client); + // All requests are forbidden. + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + } } @Test @@ -138,24 +210,120 @@ public boolean handle(Request request, Response response, Callback callback) }); }); - // User "test" does not have roles, so forbidden. - HttpTester.Request request = HttpTester.newRequest(); - request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("test", "password")); - HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request.generate())); - assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); - - // User "reader" has role "read", so it can only perform GET requests. - request = HttpTester.newRequest(); - request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("reader", "password")); - response = HttpTester.parseResponse(connector.getResponse(request.generate())); - assertEquals(HttpStatus.OK_200, response.getStatus()); - - request = HttpTester.newRequest(); - request.setMethod("PUT"); - request.put(HttpHeader.CONTENT_LENGTH, 0); - request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("reader", "password")); - response = HttpTester.parseResponse(connector.getResponse(request.generate())); - assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + try (SocketChannel client = SocketChannel.open(new InetSocketAddress("localhost", connector.getLocalPort()))) + { + client.write(UTF_8.encode(""" + GET / HTTP/1.1 + Host: localhost + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("test", "password"))) + ); + + HttpTester.Response response = HttpTester.parseResponse(client); + // User "test" does not have roles, so forbidden. + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + + client.write(UTF_8.encode(""" + GET / HTTP/1.1 + Host: localhost + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("reader", "password"))) + ); + + response = HttpTester.parseResponse(client); + // User "reader" has role "read", so it can only perform GET requests. + assertEquals(HttpStatus.OK_200, response.getStatus()); + + client.write(UTF_8.encode(""" + PUT /file.txt HTTP/1.1 + Host: localhost + Content-Length: 0 + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("reader", "password"))) + ); + + response = HttpTester.parseResponse(client); + // Method PUT is forbidden. + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + } + } + + @Test + public void testAllPathsOnlyGETAllowedSecureTransport() throws Exception + { + start(s -> + { + s.put("/*", "*", Constraint.combine(Constraint.FORBIDDEN, Constraint.SECURE_TRANSPORT)); + s.put("/*", "GET", Constraint.from("read")); + s.setHandler(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + assertTrue(request.isSecure()); + Request.AuthenticationState state = Request.getAuthenticationState(request); + assertNotNull(state); + assertEquals("reader", state.getUserPrincipal().getName()); + callback.succeeded(); + return true; + } + }); + }); + + try (SocketChannel client = SocketChannel.open(new InetSocketAddress("localhost", connector.getLocalPort()))) + { + client.write(UTF_8.encode(""" + GET / HTTP/1.1 + Host: localhost + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("test", "password"))) + ); + + HttpTester.Response response = HttpTester.parseResponse(client); + // Clear text, redirect to secure. + assertTrue(HttpStatus.isRedirection(response.getStatus())); + String location = response.get(HttpHeader.LOCATION); + assertNotNull(location); + assertThat(location, startsWith("https://")); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, SslContextFactory.TRUST_ALL_CERTS, null); + SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + try (Socket secureClient = sslSocketFactory.createSocket("localhost", tlsConnector.getLocalPort())) + { + String request = """ + GET / HTTP/1.1 + Host: localhost + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("test", "password")); + OutputStream output = secureClient.getOutputStream(); + output.write(request.getBytes(UTF_8)); + output.flush(); + + InputStream input = secureClient.getInputStream(); + response = HttpTester.parseResponse(input); + // Unauthorized user. + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + + request = """ + GET / HTTP/1.1 + Host: localhost + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("reader", "password")); + output.write(request.getBytes(UTF_8)); + output.flush(); + + response = HttpTester.parseResponse(input); + // Authorized user. + assertEquals(HttpStatus.OK_200, response.getStatus()); + } + } } @Test @@ -179,41 +347,62 @@ public boolean handle(Request request, Response response, Callback callback) }); }); - // User "test" does not have roles, so forbidden. - HttpTester.Request request = HttpTester.newRequest(); - request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("test", "password")); - HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request.generate())); - assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); - - // User "reader" has role "read", so it can only perform GET requests. - request = HttpTester.newRequest(); - request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("reader", "password")); - response = HttpTester.parseResponse(connector.getResponse(request.generate())); - assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); - - request = HttpTester.newRequest(); - request.setMethod("PUT"); - request.put(HttpHeader.CONTENT_LENGTH, 0); - request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("reader", "password")); - response = HttpTester.parseResponse(connector.getResponse(request.generate())); - assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); - - // User "writer" has role "write", so it can only perform PUT requests. - request = HttpTester.newRequest(); - request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("writer", "password")); - response = HttpTester.parseResponse(connector.getResponse(request.generate())); - assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); - - request = HttpTester.newRequest(); - request.setMethod("PUT"); - request.put(HttpHeader.CONTENT_LENGTH, 0); - request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("writer", "password")); - response = HttpTester.parseResponse(connector.getResponse(request.generate())); - assertEquals(HttpStatus.OK_200, response.getStatus()); + try (SocketChannel client = SocketChannel.open(new InetSocketAddress("localhost", connector.getLocalPort()))) + { + client.write(UTF_8.encode(""" + GET / HTTP/1.1 + Host: localhost + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("test", "password"))) + ); + + HttpTester.Response response = HttpTester.parseResponse(client); + // GET not allowed. + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + + client.write(UTF_8.encode(""" + PUT / HTTP/1.1 + Host: localhost + Content-Length: 0 + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("reader", "password"))) + ); + + response = HttpTester.parseResponse(client); + // PUT from user with wrong role, forbidden. + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + + client.write(UTF_8.encode(""" + PUT / HTTP/1.1 + Host: localhost + Content-Length: 0 + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("writer", "password"))) + ); + + response = HttpTester.parseResponse(client); + // PUT from user with right role, allowed. + assertEquals(HttpStatus.OK_200, response.getStatus()); + + client.write(UTF_8.encode(""" + GET / HTTP/1.1 + Host: localhost + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("writer", "password"))) + ); + + response = HttpTester.parseResponse(client); + // GET from writer user, not allowed. + assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); + } } @Test - public void allPathsGETAndPUTAllowed() throws Exception + public void testAllPathsGETAndPUTAllowed() throws Exception { start(s -> { @@ -234,23 +423,32 @@ public boolean handle(Request request, Response response, Callback callback) }); }); - // User "test" does not have roles, so forbidden. - HttpTester.Request request = HttpTester.newRequest(); - request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("test", "password")); - HttpTester.Response response = HttpTester.parseResponse(connector.getResponse(request.generate())); - assertEquals(HttpStatus.FORBIDDEN_403, response.getStatus()); - - // User "admin" has both read and write roles. - request = HttpTester.newRequest(); - request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("admin", "password")); - response = HttpTester.parseResponse(connector.getResponse(request.generate())); - assertEquals(HttpStatus.OK_200, response.getStatus()); - - request = HttpTester.newRequest(); - request.setMethod("PUT"); - request.put(HttpHeader.CONTENT_LENGTH, 0); - request.put(HttpHeader.AUTHORIZATION, BasicAuthenticator.authorization("admin", "password")); - response = HttpTester.parseResponse(connector.getResponse(request.generate())); - assertEquals(HttpStatus.OK_200, response.getStatus()); + try (SocketChannel client = SocketChannel.open(new InetSocketAddress("localhost", connector.getLocalPort()))) + { + client.write(UTF_8.encode(""" + GET / HTTP/1.1 + Host: localhost + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("admin", "password"))) + ); + + HttpTester.Response response = HttpTester.parseResponse(client); + // User "admin" has both read and write roles, allowed. + assertEquals(HttpStatus.OK_200, response.getStatus()); + + client.write(UTF_8.encode(""" + PUT / HTTP/1.1 + Host: localhost + Content-Length: 0 + Authorization: %s + + """.formatted(BasicAuthenticator.authorization("admin", "password"))) + ); + + response = HttpTester.parseResponse(client); + // User "admin" has both read and write roles, allowed. + assertEquals(HttpStatus.OK_200, response.getStatus()); + } } } diff --git a/jetty-core/jetty-security/src/test/resources/keystore.p12 b/jetty-core/jetty-security/src/test/resources/keystore.p12 new file mode 100644 index 0000000000000000000000000000000000000000..d96d0667bd6619914779294596679f38f320be7e GIT binary patch literal 2774 zcma);X*d*$8pmhGjLE*tU^HVZ9qJ@AmSjnmWUQI8mT1y&Fj}U@QDGL0B}-!)lq?B3 zB-v^T*%fz;tOsKW84-g*$H?tI_dcEHe!BO=`@GNl`@jG9|w>S>rUc$2GhcpnI)B?|(oqa{W6{QDwE3<5x_ zh>BfgV1Wb?Fz^sq7I5)Idt`U9X~CYmhczbI&%tysSK4*PBMl!g>)i7;P*`kk1oIE^#{lRU|q&5S|+tm6UW{b+`I=m zS0gO*EGmeW)*mr5u$^<~Fcw+C;{JyX&z z>_%g6o?93L!|fH1%n}C`voRqj8T^XasMQIX-@S$=V#v(W(3uORcJ$=lR`(`@mr;o! z>H^V3L+>alBIgvLDy2P`k$$3#a#3^DNEohM8Xqvt;v~4{g-Rnq4A*=_`&W}v^8U)` z(=c{xuZjEb*S8)Mrdokc)+7ZzaaGl@b>$S1x^HNYEIqb>ZaoAJE~2hq4f9eu@%ig` zg4#nymxu+>E?>v-S0E)9+1#Y5#Hc^Crvi_(mnrxAzPUlT?67db*43nQo7NOpmej)` z8%yDyia&MizihVNHopx56y>;^%=--9bDJBKOAYD*W~7IEu%`?sa!GraYDvp33SKXb zX@0K^fl(9hY8h~ZLa=3gcx^Y*S7IE=H_fH6+LN#_!N<&--scYS5C2KzIarGo>-Vj@ z9a`xd#l>l8%u%B_7V&Ffq4dG)XHh}p&%W8^rbE-h2FVF@TKeeL1=<~il{Yr<*J(2% zQU7Rr)@u%VBoNeYjUubLkF_LMLV;;9ZY72tvhpjmJ&G`+U}=~Yplho~*s54$Mh#Z`RdAjbx;b^Xl%;hR&0n)COWF^>*bxr| z4S!I@!#UxcG3RRyrHi&5I#=YfI=r9^8v(xY>GMXB$lbY>y&jZbc;j(lI1i0Fv+eBc zC(Lxa<``F~^W2??;iAWyyMQ~f?%7X*m1ax5<`u-e<=-p&KR7EPbZ_-U-6pWAi(AXS zHvsZ5YY>+Fh?9x$V!H>vEYOflj-kRH-SUHESe|Ui# znGiWVGn#p(X6>V9#*HV^QM5O9&~JF#4L%rLpZ9RgtOMFIsas9BjEHj@JyhfJF2m2q zDP4lPRvy%f-o+7yYX^*R{40Ra!5lV@#yofuaaBc|W{uI8cw@tcUsKU}*48*iOmRiL zwrQeNI*$RhTRTnsPC7?!TIHF1g6;5a_q_~Iik}|U3FqUC7Q(g%oc*rCenZ(Qe^s2a zp{{%oGS$JFCN*Yx79lL5EO*D;wx;J(Vz8-c8phVphuS%DSHgnl$*S7;-mGMxJ*bzD=G-T?`+=^{w31!(UKqHT>hDZhMs50N6kEK?LLUQZcFZps!kpm$|>1$YO`->KA$o4e1VD?w^tAKA;Bss=1x2% zJY~v<6_ETHV~$q-8JA*kjHIeKAOvs=5DXvyZlN{)mJ~2>u)J$v2tgfl^q7IMp0SaE zv9W;xnhvh|xdau>r-KWBpobtJ;74QqtpNX5NayyPe1R(9uTjJ>LUc;SJ8vvu^ZyBH zEr*ue^CNB+MyDeoLTEdQN(ZZh*%g0Aymheq66dvq zHi5C6yBfvsm6KRhT+?hV_35y*_?2r198Vn|Y!`Y!w^WLobvIT&Uuep}i(zp2x3vR-itQ> zjjJubCy0WZuSD0{4yF&8g)u!Zs=i8(*xNfdP%I4i&aCg4(g}(`+38jAMn*8a3td^e zfx9E$(@62oZswW<-pXnWTp}^dm5Ky6dM%=tt95hb?Y1ZqQ|V~>{>c)eU$$iPB1a~| z$hj-gY<~FeQnaKXV?7bP@jAbA&*Og-A)O2wfIR-7%b$1Q2 zT=svLo%(H~P0-meds)NC$hQfnm@SfdI)3MEf%D{a6vG{;NyEl5P#&|+jhsyH8)#GgBV`cM1wp~|&zrC!rm;t&Rz zr%ry6*@uUe^A0!1y5v+9PRF|3dNj~4QvNA|8mvXqd}#AryHxM5kU|*M`HIV8g=log zTD;E@lKA3Z25Ma%&2P8FY9y+8kb4r}0H$gwvBE-MeX+k}o=rc=MqX8FMLI5BC6QNO zq>fGx(3VJ*SG^6--GTvh^%`YqHC8Y`{TlAS@P;}z zpfCd&<28yR{s_wz9%bxw_a?>ar)p#Eu^;rU>=1TsnNY@8fGMS#v}ZD8H>!IJrCl5gBSSSz6C zjJ!Zeo<_;eWFEYsOA5Hol}$=UdFCyu9M)+#=BTZKc0yy&Qa^t^5D)?YtJft>xWfHG z-hKY7$9o^2+FC~UmaiUtJK4YFp9x}b7lPrRba~h>K7~nmwxyu4U$%tQ4cYovMEw&X C)Bn@} literal 0 HcmV?d00001 diff --git a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/CustomRequestLog.java b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/CustomRequestLog.java index a6e7f50aa902..d95b23f0b56c 100644 --- a/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/CustomRequestLog.java +++ b/jetty-core/jetty-server/src/main/java/org/eclipse/jetty/server/CustomRequestLog.java @@ -1236,8 +1236,7 @@ private static void logLatency(StringBuilder b, Request request, TimeUnit unit) @SuppressWarnings("unused") private static void logRequestAuthentication(StringBuilder b, Request request, Response response, boolean quoted) { - Request.AuthenticationState authenticationState = Request.getAuthenticationState(request); - Principal userPrincipal = authenticationState == null ? null : authenticationState.getUserPrincipal(); + Principal userPrincipal = Request.getAuthenticationState(request).getUserPrincipal(); append(b, userPrincipal == null ? null : userPrincipal.getName(), quoted); } diff --git a/jetty-core/jetty-websocket/jetty-websocket-jetty-server/src/main/java/org/eclipse/jetty/websocket/server/internal/CompletedUpgradeRequest.java b/jetty-core/jetty-websocket/jetty-websocket-jetty-server/src/main/java/org/eclipse/jetty/websocket/server/internal/CompletedUpgradeRequest.java index 7516aa675ed6..91b6b7ad6e50 100644 --- a/jetty-core/jetty-websocket/jetty-websocket-jetty-server/src/main/java/org/eclipse/jetty/websocket/server/internal/CompletedUpgradeRequest.java +++ b/jetty-core/jetty-websocket/jetty-websocket-jetty-server/src/main/java/org/eclipse/jetty/websocket/server/internal/CompletedUpgradeRequest.java @@ -77,8 +77,7 @@ public CompletedUpgradeRequest(ServerUpgradeRequest request) _extensions = Collections.unmodifiableList(extensions); // Get the user principal from the request's authentication state. - Request.AuthenticationState authState = Request.getAuthenticationState(request); - _userPrincipal = authState != null ? authState.getUserPrincipal() : null; + _userPrincipal = Request.getAuthenticationState(request).getUserPrincipal(); } @Override From 3dc9baec6b2c41afe642184d1d5121e283a1c353 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Fri, 24 Oct 2025 11:21:49 +0200 Subject: [PATCH 3/5] Updated javadocs. Signed-off-by: Simone Bordet --- .../main/java/org/eclipse/jetty/security/SecurityHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/SecurityHandler.java b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/SecurityHandler.java index f23fc193a673..a6de61bd05e6 100644 --- a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/SecurityHandler.java +++ b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/SecurityHandler.java @@ -870,7 +870,7 @@ protected Set getKnownRoles() *
      *
    • {@code /foo} matches {@code /*}; * any HTTP method results in a constraint with {@link Authorization#FORBIDDEN} and {@link Transport#SECURE}
    • - *
    • {@code /releases/jetty-12.1.0.tar.gz} matches {@code /releases/*}; + *
    • {@code /releases/jetty-12.1.0.tar.gz} matches both {@code /*} and {@code /releases/*}; * method {@code GET} results in a constraint with {@link Authorization#SPECIFIC_ROLE} with role {@code read} * and {@link Transport#SECURE}; * method {@code PUT} results in a constraint with {@link Authorization#SPECIFIC_ROLE} with role {@code write} From 6c9e13f53d13ebbf1a32cf520ab4014a548ae2b3 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Fri, 24 Oct 2025 18:12:45 +0200 Subject: [PATCH 4/5] Updated security documentation. Signed-off-by: Simone Bordet --- .../programming-guide/pages/server/http.adoc | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/documentation/jetty/modules/programming-guide/pages/server/http.adoc b/documentation/jetty/modules/programming-guide/pages/server/http.adoc index d61fe3010479..1cfef3c2e583 100644 --- a/documentation/jetty/modules/programming-guide/pages/server/http.adoc +++ b/documentation/jetty/modules/programming-guide/pages/server/http.adoc @@ -1586,7 +1586,7 @@ For example, a `LoginHandler` that extends `ConditionalHandler` may be configure [[handler-use-security]] ==== SecurityHandler -TODO +`SecurityHandler` allows to configure authentication and authorization for your web applications, and it is discussed in details in xref:security/index.adoc[this section]. [[handler-use-servlet]] === Servlet API Handlers @@ -2141,8 +2141,20 @@ The advanced configuration of Jetty's HTTP session support is discussed in more [[security]] === Securing HTTP Server Applications -// TODO: ConstraintSecurityHandler and Authenticators and LoginServices -TODO +Web applications may need to restrict access to resources to authenticated requests. + +The Jetty support for web application security is provided by the artifact at the following Maven coordinates: + +[,xml,subs=attributes+] +---- + + org.eclipse.jetty + jetty-security + {jetty-version} + +---- + +Web application security is primarily provided by <>, and it is covered in details in xref:security/index.adoc[this section]. [[application]] === Writing HTTP Server Applications From c884e301ca014bdde0dae25859f73242fc23507d Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Fri, 24 Oct 2025 18:22:09 +0200 Subject: [PATCH 5/5] Javadocs. Signed-off-by: Simone Bordet --- .../jetty/security/SecurityHandler.java | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/SecurityHandler.java b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/SecurityHandler.java index a6de61bd05e6..b4fa359a01b8 100644 --- a/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/SecurityHandler.java +++ b/jetty-core/jetty-security/src/main/java/org/eclipse/jetty/security/SecurityHandler.java @@ -749,11 +749,27 @@ public PathMapped(Handler handler) super(handler); } + /** + *

      Associates the specified request path pattern with the specified {@link Constraint}.

      + * + * @param pathSpec the request path pattern to match + * @param constraint the associated {@link Constraint} + * @return the previous {@link Constraint} associated with the request path pattern, + * or {@code null} if there was no previous association + */ public Constraint put(String pathSpec, Constraint constraint) { return put(PathSpec.from(pathSpec), constraint); } + /** + *

      Associates the specified request path pattern with the specified {@link Constraint}.

      + * + * @param pathSpec the request path pattern to match + * @param constraint the associated {@link Constraint} + * @return the previous {@link Constraint} associated with the request path pattern, + * or {@code null} if there was no previous association + */ public Constraint put(PathSpec pathSpec, Constraint constraint) { Set roles = constraint.getRoles(); @@ -902,10 +918,10 @@ public PathMethodMapped(Handler handler) } /** - *

      Associates the given {@link Constraint} to the given path and HTTP method.

      + *

      Associates the given {@link Constraint} to the given request path patten and HTTP method.

      * * @param pathSpec the {@link PathSpec} associated to the given constraint - * @param method the HTTP method associated to the given constraint, or {@code null} + * @param method the HTTP method associated to the given constraint, or {@code null} or {@code *} * to indicate all HTTP methods * @param constraint the constraint to associate * @return the previous constraint associated with the given path and HTTP method, @@ -917,10 +933,10 @@ public Constraint put(String pathSpec, String method, Constraint constraint) } /** - *

      Associates the given {@link Constraint} to the given path and HTTP method.

      + *

      Associates the given {@link Constraint} to the given request path pattern and HTTP method.

      * @param pathSpec the {@link PathSpec} associated to the given constraint - * @param method the HTTP method associated to the given constraint, or {@code null} + * @param method the HTTP method associated to the given constraint, or {@code null} or {@code *} * to indicate all HTTP methods * @param constraint the constraint to associate * @return the previous constraint associated with the given path and HTTP method, @@ -942,7 +958,7 @@ public Constraint put(PathSpec pathSpec, String method, Constraint constraint) } /** - *

      Associates the given {@link Constraint} to the given path and HTTP methods.

      + *

      Associates the given {@link Constraint} to the given request path pattern and HTTP methods.

      * * @param pathSpec the {@link PathSpec} associated to the given constraint * @param methods the list of HTTP methods associated to the given constraint @@ -950,7 +966,7 @@ public Constraint put(PathSpec pathSpec, String method, Constraint constraint) */ public void put(PathSpec pathSpec, List methods, Constraint constraint) { - if (methods.isEmpty() || methods.contains(ALL_METHODS)) + if (methods.isEmpty() || methods.contains(ALL_METHODS) || methods.contains(null)) throw new IllegalArgumentException("Invalid method list"); methods.forEach(method -> put(pathSpec, method, constraint)); }