From 542dcf43113906bd01a05d885b160942e998c4e4 Mon Sep 17 00:00:00 2001 From: Julien Viet Date: Mon, 16 Dec 2024 11:12:35 +0100 Subject: [PATCH] Port to 4.5.x the UserContext API. Motivation: Vert.x 5 has a new UserContext API that encapsulate the API present on Routingcontext in addition of new identity related operations. This API can be back-ported to 4.5.x to ease migration to Vert.x 5 . Changes: Partial part of the UserContext API and documentation, as well as a couple of deprecations in favor of this new API. --- vertx-web/src/main/asciidoc/index.adoc | 14 + .../src/main/java/examples/WebExamples.java | 22 ++ .../java/io/vertx/ext/web/RoutingContext.java | 20 +- .../java/io/vertx/ext/web/UserContext.java | 147 +++++++++ .../ext/web/impl/RoutingContextDecorator.java | 5 + .../ext/web/impl/RoutingContextImpl.java | 24 +- .../ext/web/impl/RoutingContextWrapper.java | 5 + .../vertx/ext/web/impl/UserContextImpl.java | 219 ++++++++++++++ .../handler/BasicAuthImpersonationTest.java | 279 ++++++++++++++++++ .../resources/login/loginusers.properties | 6 +- 10 files changed, 720 insertions(+), 21 deletions(-) create mode 100644 vertx-web/src/main/java/io/vertx/ext/web/UserContext.java create mode 100644 vertx-web/src/main/java/io/vertx/ext/web/impl/UserContextImpl.java create mode 100644 vertx-web/src/test/java/io/vertx/ext/web/handler/BasicAuthImpersonationTest.java diff --git a/vertx-web/src/main/asciidoc/index.adoc b/vertx-web/src/main/asciidoc/index.adoc index e886809639..9e0aae2c31 100644 --- a/vertx-web/src/main/asciidoc/index.adoc +++ b/vertx-web/src/main/asciidoc/index.adoc @@ -1378,6 +1378,20 @@ Complex chaining is also possible, for example, building logic sequences such as {@link examples.WebExamples#exampleChainAuthHandler} ---- +=== Impersonation +When using authentication handlers, it is possible that the identity of the user changes over time. For +example, a user may require to become `admin` for a specific period of time. This can be achieved by calling: +[source,$lang] +---- +{@link examples.WebExamples#example89} +---- +The operation can be reversed by calling: +[source,$lang] +---- +{@link examples.WebExamples#example90} +---- +The impersonation does not require calling other router endpoints to avoid being exploited from the outside. + == Serving static resources Vert.x-Web comes with an out of the box handler for serving static web resources so you can write static web servers diff --git a/vertx-web/src/main/java/examples/WebExamples.java b/vertx-web/src/main/java/examples/WebExamples.java index 2c8cd9bdde..5783f6ea21 100644 --- a/vertx-web/src/main/java/examples/WebExamples.java +++ b/vertx-web/src/main/java/examples/WebExamples.java @@ -1971,4 +1971,26 @@ public void example87(Router router) { ctx.end(value); }); } + + public void example89(Router router) { + router + .route("/high/security/route/check") + .handler(ctx -> { + // if the user isn't admin, we ask the user to login again as admin + ctx + .userContext() + .loginHint("admin") + .impersonate(); + }); + } + + public void example90(Router router) { + router + .route("/high/security/route/back/to/me") + .handler(ctx -> { + ctx + .userContext() + .restore(); + }); + } } diff --git a/vertx-web/src/main/java/io/vertx/ext/web/RoutingContext.java b/vertx-web/src/main/java/io/vertx/ext/web/RoutingContext.java index fb68fdd173..5833ebe34e 100644 --- a/vertx-web/src/main/java/io/vertx/ext/web/RoutingContext.java +++ b/vertx-web/src/main/java/io/vertx/ext/web/RoutingContext.java @@ -28,6 +28,7 @@ import io.vertx.core.json.JsonObject; import io.vertx.ext.auth.User; import io.vertx.ext.web.impl.ParsableMIMEValue; +import io.vertx.ext.web.impl.UserContextImpl; import io.vertx.ext.web.impl.Utils; import java.nio.charset.Charset; @@ -407,7 +408,11 @@ default String normalisedPath() { * Get the authenticated user (if any). This will usually be injected by an auth handler if authentication if successful. * @return the user, or null if the current user is not authenticated. */ - @Nullable User user(); + default @Nullable User user() { + return userContext().get(); + } + + UserContext userContext(); /** * If the context is being routed to failure handlers after a failure has been triggered by calling @@ -551,14 +556,23 @@ default Future addEndHandler() { * Set the user. Usually used by auth handlers to inject a User. You will not normally call this method. * * @param user the user + * @deprecated this method should not be called, application authentication should rely on {@link io.vertx.ext.web.handler.AuthenticationHandler} implementations. */ - void setUser(User user); + @Deprecated + default void setUser(User user) { + ((UserContextImpl) userContext()).setUser(user); + } /** * Clear the current user object in the context. This usually is used for implementing a log out feature, since the * current user is unbounded from the routing context. + * + * @deprecated instead use {@link UserContext#logout()} */ - void clearUser(); + @Deprecated + default void clearUser() { + setUser(null); + } /** * Set the acceptable content type. Used by diff --git a/vertx-web/src/main/java/io/vertx/ext/web/UserContext.java b/vertx-web/src/main/java/io/vertx/ext/web/UserContext.java new file mode 100644 index 0000000000..0f128051bb --- /dev/null +++ b/vertx-web/src/main/java/io/vertx/ext/web/UserContext.java @@ -0,0 +1,147 @@ +package io.vertx.ext.web; + +import io.vertx.codegen.annotations.Fluent; +import io.vertx.codegen.annotations.Nullable; +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.core.AsyncResult; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.ext.auth.User; + +/** + * A web user is extended user coupled to the context and is used to perform verifications + * and actions on behalf of the user. Actions can be: + * + * + */ +@VertxGen +public interface UserContext { + + /** + * Get the authenticated user (if any). This will usually be injected by an auth handler if authentication if successful. + * + * @return the user, or null if the current user is not authenticated. + */ + @Nullable + User get(); + + default boolean authenticated() { + return get() != null; + } + + /** + * When performing a web identity operation, hint if possible to the identity provider to use the given login. + * + * @param loginHint the desired login name, for example: {@code admin}. + * @return fluent self + */ + @Fluent + UserContext loginHint(String loginHint); + + /** + * Impersonates a second identity. The user will be redirected to the same origin where this call was + * made. It is important to notice that the redirect will only allow sources originating from a HTTP GET request. + * + * @return future result of the operation. + */ + Future impersonate(); + + default void impersonate(Handler> callback) { + Future fut = impersonate(); + if (callback != null) { + fut.onComplete(callback); + } + } + + /** + * Impersonates a second identity. The user will be redirected to the given uri. It is important to + * notice that the redirect will only allow targets using an HTTP GET request. + * + * @param redirectUri the uri to redirect the user to after the authentication. + * @return future result of the operation. + */ + Future impersonate(String redirectUri); + + default void impersonate(String redirectUri, Handler> callback) { + Future fut = impersonate(redirectUri); + if (callback != null) { + fut.onComplete(callback); + } + } + + /** + * Undo a previous call to a impersonation. The user will be redirected to the same origin where this call was + * made. It is important to notice that the redirect will only allow sources originating from a HTTP GET request. + * + * @return future result of the operation. + */ + Future restore(); + + default void restore(Handler> callback) { + Future fut = restore(); + if (callback != null) { + fut.onComplete(callback); + } + } + + /** + * Undo a previous call to an impersonation. The user will be redirected to the given uri. It is important to + * notice that the redirect will only allow targets using an HTTP GET request. + * + * @param redirectUri the uri to redirect the user to after the re-authentication. + * @return future result of the operation. + */ + Future restore(String redirectUri); + + default void restore(String redirectUri, Handler> callback) { + Future fut = restore(redirectUri); + if (callback != null) { + fut.onComplete(callback); + } + } + + /** + * Logout can be called from any route handler which needs to terminate a login session. Invoking logout will remove + * the {@link io.vertx.ext.auth.User} and clear the {@link Session} (if any) in the current context. Followed by a + * redirect to the given uri. + * + * @param redirectUri the uri to redirect the user to after the logout. + * @return future result of the operation. + */ + Future logout(String redirectUri); + + default void logout(String redirectUri, Handler> callback) { + Future fut = logout(redirectUri); + if (callback != null) { + fut.onComplete(callback); + } + } + + /** + * Logout can be called from any route handler which needs to terminate a login session. Invoking logout will remove + * the {@link io.vertx.ext.auth.User} and clear the {@link Session} (if any) in the current context. Followed by a + * redirect to {@code /}. + * + * @return future result of the operation. + */ + Future logout(); + + default void logout(Handler> callback) { + Future fut = logout(); + if (callback != null) { + fut.onComplete(callback); + } + } + + /** + * Clear can be called from any route handler which needs to terminate a login session. Invoking logout will remove + * the {@link io.vertx.ext.auth.User} and clear the {@link Session} (if any) in the current context. Unlike + * {@link #logout()} no redirect will be performed. + */ + void clear(); +} diff --git a/vertx-web/src/main/java/io/vertx/ext/web/impl/RoutingContextDecorator.java b/vertx-web/src/main/java/io/vertx/ext/web/impl/RoutingContextDecorator.java index b598376fdb..4811048fa3 100644 --- a/vertx-web/src/main/java/io/vertx/ext/web/impl/RoutingContextDecorator.java +++ b/vertx-web/src/main/java/io/vertx/ext/web/impl/RoutingContextDecorator.java @@ -229,6 +229,11 @@ public User user() { return decoratedContext.user(); } + @Override + public UserContext userContext() { + return decoratedContext.userContext(); + } + @Override public Session session() { return decoratedContext.session(); diff --git a/vertx-web/src/main/java/io/vertx/ext/web/impl/RoutingContextImpl.java b/vertx-web/src/main/java/io/vertx/ext/web/impl/RoutingContextImpl.java index e324081793..3a8b9c3962 100644 --- a/vertx-web/src/main/java/io/vertx/ext/web/impl/RoutingContextImpl.java +++ b/vertx-web/src/main/java/io/vertx/ext/web/impl/RoutingContextImpl.java @@ -26,10 +26,7 @@ import io.vertx.core.http.impl.HttpUtils; import io.vertx.core.impl.ContextInternal; import io.vertx.ext.auth.User; -import io.vertx.ext.web.FileUpload; -import io.vertx.ext.web.RequestBody; -import io.vertx.ext.web.RoutingContext; -import io.vertx.ext.web.Session; +import io.vertx.ext.web.*; import io.vertx.ext.web.handler.HttpException; import io.vertx.ext.web.handler.impl.UserHolder; @@ -66,7 +63,7 @@ public class RoutingContextImpl extends RoutingContextImplBase { private final AtomicBoolean cleanup = new AtomicBoolean(false); private List fileUploads; private Session session; - private User user; + private UserContext userContext; private volatile boolean isSessionAccessed = false; private volatile boolean endHandlerCalled = false; @@ -366,18 +363,11 @@ public boolean isSessionAccessed(){ } @Override - public User user() { - return user; - } - - @Override - public void setUser(User user) { - this.user = user; - } - - @Override - public void clearUser() { - this.user = null; + public UserContext userContext() { + if (userContext == null) { + userContext = new UserContextImpl(this); + } + return userContext; } @Override diff --git a/vertx-web/src/main/java/io/vertx/ext/web/impl/RoutingContextWrapper.java b/vertx-web/src/main/java/io/vertx/ext/web/impl/RoutingContextWrapper.java index f6c7bed7d1..217d4d358b 100644 --- a/vertx-web/src/main/java/io/vertx/ext/web/impl/RoutingContextWrapper.java +++ b/vertx-web/src/main/java/io/vertx/ext/web/impl/RoutingContextWrapper.java @@ -190,6 +190,11 @@ public void clearUser() { inner.clearUser(); } + @Override + public UserContext userContext() { + return inner.userContext(); + } + @Override public User user() { return inner.user(); diff --git a/vertx-web/src/main/java/io/vertx/ext/web/impl/UserContextImpl.java b/vertx-web/src/main/java/io/vertx/ext/web/impl/UserContextImpl.java new file mode 100644 index 0000000000..498afcf86f --- /dev/null +++ b/vertx-web/src/main/java/io/vertx/ext/web/impl/UserContextImpl.java @@ -0,0 +1,219 @@ +package io.vertx.ext.web.impl; + +import io.vertx.core.Future; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.impl.logging.Logger; +import io.vertx.core.impl.logging.LoggerFactory; +import io.vertx.ext.auth.User; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.Session; +import io.vertx.ext.web.UserContext; +import io.vertx.ext.web.handler.HttpException; + +import java.util.Objects; + +public class UserContextImpl implements UserContext { + + private static final String USER_SWITCH_KEY = "__vertx.user-switch-ref"; + private static final Logger LOG = LoggerFactory.getLogger(UserContext.class); + + private final RoutingContext ctx; + private User user; + + public UserContextImpl(RoutingContext ctx) { + this.ctx = ctx; + } + + public void setUser(User user) { + this.user = user; + } + + @Override + public User get() { + return user; + } + + @Override + public UserContext loginHint(String loginHint) { + final Session session = ctx.session(); + + if (session == null) { + if (loginHint == null) { + // Fine, we don't need a session + return this; + } + // we always need a session, otherwise we can't track the state of the previous user + throw new IllegalStateException("SessionHandler not seen in the route. Sessions are required to keep the state"); + } + + if (loginHint == null) { + // we're removing the hint if present + session.remove("login_hint"); + } else { + session + .put("login_hint", loginHint); + } + + return this; + } + + @Override + public Future impersonate() { + if (!ctx.request().method().equals(HttpMethod.GET)) { + // we can't automate a redirect to a non-GET request + return Future.failedFuture(new HttpException(405, "Method not allowed")); + } + return impersonate(ctx.request().absoluteURI()); + } + + @Override + public Future impersonate(String redirectUri) { + Objects.requireNonNull(redirectUri, "redirectUri cannot be null"); + + if (user == null) { + // we need to ensure that we already had a user, otherwise we can't switch + LOG.debug("Impersonation can only occur after a complete authn flow."); + return Future.failedFuture(new HttpException(401)); + } + + final Session session = ctx.session(); + + if (session == null) { + // we always need a session, otherwise we can't track the state of the previous user + LOG.debug("SessionHandler not seen in the route. Sessions are required to keep the state"); + return Future.failedFuture(new HttpException(500)); + } + + if (session.get(USER_SWITCH_KEY) != null) { + // we always need a session, otherwise we can't track the state of the previous user + LOG.debug("Impersonation already in place"); + return Future.failedFuture(new HttpException(400)); + } + + // From now on, we're changing the state + session + // move the user out of the context (yet keep it in the session, so we can roll back + .put(USER_SWITCH_KEY, user) + // force a session id regeneration to protect against replay attacks + .regenerateId(); + + // remove the current user from the context to avoid any further access + this.user = null; + + // we should redirect the UA so this link becomes invalid + return ctx.response() + // disable all caching + .putHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate") + .putHeader("Pragma", "no-cache") + .putHeader(HttpHeaders.EXPIRES, "0") + // redirect (when there is no state, redirect to home + .putHeader(HttpHeaders.LOCATION, redirectUri) + .setStatusCode(302) + .end("Redirecting to " + redirectUri + "."); + } + + @Override + public Future restore() { + if (!ctx.request().method().equals(HttpMethod.GET)) { + // we can't automate a redirect to a non-GET request + return Future.failedFuture(new HttpException(405, "Method not allowed")); + } + return restore(ctx.request().absoluteURI()); + } + + @Override + public Future restore(String redirectUri) { + Objects.requireNonNull(redirectUri, "redirectUri cannot be null"); + + if (user == null) { + // we need to ensure that we already had a user, otherwise we can't switch + LOG.debug("Impersonation can only occur after a complete authn flow."); + return Future.failedFuture(new HttpException(401)); + } + + final Session session = ctx.session(); + + if (session == null) { + // we always need a session, otherwise we can't track the state of the previous user + LOG.debug("SessionHandler not seen in the route. Sessions are required to keep the state"); + return Future.failedFuture(new HttpException(500)); + } + + if (session.get(USER_SWITCH_KEY) == null) { + // we always need a session, otherwise we can't track the state of the previous user + LOG.debug("No previous impersonation in place"); + return Future.failedFuture(new HttpException(400)); + } + + // From now on, we're changing the state + User previousUser = session.get(USER_SWITCH_KEY); + + session + // move the user out of the context (yet keep it in the session, so we can rollback + .remove(USER_SWITCH_KEY); + // remove the previous hint + session + .remove("login_hint"); + + session + // force a session id regeneration to protect against replay attacks + .regenerateId(); + + // restore it to the context + this.user = previousUser; + + // we should redirect the UA so this link becomes invalid + return ctx.response() + // disable all caching + .putHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate") + .putHeader("Pragma", "no-cache") + .putHeader(HttpHeaders.EXPIRES, "0") + // redirect (when there is no state, redirect to home + .putHeader(HttpHeaders.LOCATION, redirectUri) + .setStatusCode(302) + .end("Redirecting to " + redirectUri + "."); + } + + @Override + public Future logout() { + return logout("/"); + } + + @Override + public Future logout(String redirectUri) { + Objects.requireNonNull(redirectUri, "redirectUri cannot be null"); + + final Session session = ctx.session(); + // clear the session + if (session != null) { + session.destroy(); + } + + // clear the user + user = null; + + // we should redirect the UA so this link becomes invalid + return ctx.response() + // disable all caching + .putHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate") + .putHeader("Pragma", "no-cache") + .putHeader(HttpHeaders.EXPIRES, "0") + // redirect (when there is no state, redirect to home + .putHeader(HttpHeaders.LOCATION, redirectUri) + .setStatusCode(302) + .end("Redirecting to " + redirectUri + "."); + } + + @Override + public void clear() { + final Session session = ctx.session(); + // clear the session + if (session != null) { + session.destroy(); + } + + // clear the user + user = null; + } +} diff --git a/vertx-web/src/test/java/io/vertx/ext/web/handler/BasicAuthImpersonationTest.java b/vertx-web/src/test/java/io/vertx/ext/web/handler/BasicAuthImpersonationTest.java new file mode 100644 index 0000000000..496ae82736 --- /dev/null +++ b/vertx-web/src/test/java/io/vertx/ext/web/handler/BasicAuthImpersonationTest.java @@ -0,0 +1,279 @@ +package io.vertx.ext.web.handler; + +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.authentication.AuthenticationProvider; +import io.vertx.ext.auth.authorization.AuthorizationProvider; +import io.vertx.ext.auth.authorization.RoleBasedAuthorization; +import io.vertx.ext.auth.properties.PropertyFileAuthentication; +import io.vertx.ext.auth.properties.PropertyFileAuthorization; +import io.vertx.ext.web.WebTestBase; +import io.vertx.ext.web.sstore.SessionStore; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicReference; + +public class BasicAuthImpersonationTest extends WebTestBase { + AuthenticationProvider authn; + AuthorizationProvider authz; + private static final String USER_SWITCH_KEY = "__vertx.user-switch-ref"; + + @Override + public void setUp() throws Exception { + super.setUp(); + authn = PropertyFileAuthentication.create(vertx, "login/loginusers.properties"); + authz = PropertyFileAuthorization.create(vertx, "login/loginusers.properties"); + } + + @Test + public void testSwitchUser() throws Exception { + ///////////////////////// + // SETUP + ///////////////////////// + // keep state + router.route() + .handler(SessionHandler.create(SessionStore.create(vertx))); + // switch users setup + // there are 2 routes for testing purposes + router.route("/user-switch/impersonate") + // this is a high precedence handler + .handler(ctx -> { + ctx.userContext() + .loginHint(ctx.request().getParam("login_hint")) + .impersonate(ctx.request().getParam("redirect_uri")) + .onFailure(err -> { + if (err instanceof HttpException) { + ctx.fail(err); + } else { + ctx.fail(500); + } + }); + }); + router.route("/user-switch/undo") + // this is a high precedence handler + .handler(ctx -> { + ctx.userContext() + .loginHint(ctx.request().getParam("login_hint")) + .restore(ctx.request().getParam("redirect_uri")) + .onFailure(err -> { + if (err instanceof HttpException) { + ctx.fail(err); + } else { + ctx.fail(500); + } + }); + }); + // protect everything under /protected + router.route("/protected/*") + .handler(BasicAuthHandler.create(authn)); + final AtomicReference userRef = new AtomicReference<>(); + // mount 1st handler under the protected zone (regular user only can read) + router + .route("/protected/base") + .handler(AuthorizationHandler.create(RoleBasedAuthorization.create("read")).addAuthorizationProvider(authz)) + .handler(rc -> { + assertNotNull(rc.user()); + userRef.set(rc.user()); + rc.end("OK"); + }); + // mount 2nd handler under the protected zone (admin user can write) + router + .route("/protected/admin") + .handler(AuthorizationHandler.create(RoleBasedAuthorization.create("write")).addAuthorizationProvider(authz)) + .handler(rc -> { + assertNotNull(rc.user()); + // assert that the old and new users are not the same + User oldUser = userRef.get(); + assertNotNull(oldUser); + User newUser = rc.user(); + assertFalse(oldUser.equals(newUser)); + // also the old user should be in the session + User prevUser = rc.session().get(USER_SWITCH_KEY); + assertNotNull(prevUser); + assertEquals(prevUser, oldUser); + rc.response().end("Welcome to the 2nd protected resource!"); + }); + ///////////////////////// + // TEST + ///////////////////////// + // flow: + // 1. user not authenticated + // 2. app starts a redirect to the IdP + // 3. IdP calls back, user gets to the desired endpoint + final AtomicReference sessionRef = new AtomicReference<>(); + // 1. user isn't authenticated (no Authorization header, no Session cookie) + // Expectation: + // * A redirect to the IdP, as we're mocking, we need to extract the state of the redirect URL so we can fake the + // callback to the app + // * We also need to have a session cookie otherwise we lose all the context and cannot have multiple identities + testRequest(HttpMethod.GET, "/protected/base", null, resp -> { + // in this case we should get a WWW-Authenticate + String redirectURL = resp.getHeader("WWW-Authenticate"); + assertNotNull(redirectURL); + // there's no session yet + String setCookie = resp.headers().get("set-cookie"); + assertNull(setCookie); + }, 401, "Unauthorized", null); + // 3. fake the redirect from the IdP. This happens with a success authn validation, we need to pass the right state + // Expectations: + // * A new session cookie is returned, as the session id is regenerated to prevent replay attacks or privilege + // escalation bugs. Old session assumed an un authenticated user, this one is for the authenticated one + // * A final redirect happens to avoid caching the callback URL at the user-agent, so the browser will show + // the desired original URL + testRequest( + HttpMethod.GET, + "/protected/base", + req -> { + req.putHeader(HttpHeaders.AUTHORIZATION, "Basic cmVndWxhcjpyZWd1bGFy"); + }, resp -> { + // session upgrade (secure against replay attacks) + String setCookie = resp.headers().get("set-cookie"); + assertNotNull(setCookie); + sessionRef.set(setCookie.substring(0, setCookie.indexOf(';'))); + }, 200, "OK", null); + // 4. Confirm that we can get the secured resource + testRequest( + HttpMethod.GET, + "/protected/base", + req -> { + req.putHeader(HttpHeaders.COOKIE, sessionRef.get()); + }, resp -> { + }, 200, "OK", "OK"); + ////////////////////////////// + // TEST SWITCHING IDENTITIES + ///////////////////////////// + // test we can't get the admin resource (we're still base user) + testRequest( + HttpMethod.GET, + "/protected/admin", + req -> { + req.putHeader(HttpHeaders.COOKIE, sessionRef.get()); + }, resp -> { + }, 403, "Forbidden", null); + // verify that the switch isn't possible for non authn requests + // Expectations: + // * Given that there is no cookie and no authorization header, no user will be present in the request, forcing + // an Unauthorized response + testRequest( + HttpMethod.GET, + "/user-switch/impersonate?redirect_uri=/protected/admin&login_hint=admin", + req -> { + }, resp -> { + }, 401, "Unauthorized", null); + // start the switch + // flow: + // 1. call the switch user endpoint + // 2. a new Oauth2 auth flow starts like before + // 3. In the end there should be a new user object and the previous one shall be in the session + // User is authenticated (there is a session and a User) and a redirect to the IdP should happen + // Expectations: + // * A redirect to the IdP should happen. (maybe there's a way to hint the desired user? This doesn't do it) + testRequest( + HttpMethod.GET, + "/user-switch/impersonate?redirect_uri=/protected/admin&login_hint=admin", + req -> { + req.putHeader(HttpHeaders.COOKIE, sessionRef.get()); + }, resp -> { + // in this case we should get a redirect, and the session id must change + // session upgrade (secure against replay attacks) + String setCookie = resp.headers().get("set-cookie"); + assertNotNull(setCookie); + // the session must change + assertFalse(setCookie.substring(0, setCookie.indexOf(';')).equals(sessionRef.get())); + sessionRef.set(setCookie.substring(0, setCookie.indexOf(';'))); + String destination = resp.getHeader(HttpHeaders.LOCATION); + assertNotNull(destination); + }, 302, "Found", null); + // verify that the switch isn't possible for non authn requests + // Expectations: + // * Given that there is no cookie and no authorization header, no user will be present in the request, forcing + // a redirect to the IdP response + testRequest( + HttpMethod.GET, + "/protected/admin", + req -> { + }, resp -> { + }, 401, "Unauthorized", null); + // verify that the switch is possible for authn requests + // Expectations: + // * Given that there is no cookie and no authorization header, no user will be present in the request, forcing + // a redirect to the IdP response + testRequest( + HttpMethod.GET, + "/protected/admin", + req -> { + req.putHeader(HttpHeaders.COOKIE, sessionRef.get()); + }, resp -> { + // in this case we should get a WWW-Authenticate + String redirectURL = resp.getHeader("WWW-Authenticate"); + assertNotNull(redirectURL); + // there's no session yet + String setCookie = resp.headers().get("set-cookie"); + assertNull(setCookie); + }, 401, "Unauthorized", null); + // user is authenticated, it now escalates the permissions by re-doing the auth flow to upgrade the user + // Expectations: + // * fake the IdP callback with the right state + // * like before ensure that the session id changes (base user -> admin user) + // * final redirect to the desired target resource, to avoid user-agents to cache the callback url + testRequest( + HttpMethod.GET, + "/protected/admin", + req -> { + req.putHeader(HttpHeaders.COOKIE, sessionRef.get()); + req.putHeader(HttpHeaders.AUTHORIZATION, "Basic YWRtaW46YWRtaW4="); + }, resp -> { + // session upgrade (secure against replay attacks) + String setCookie = resp.headers().get("set-cookie"); + assertNotNull(setCookie); + sessionRef.set(setCookie.substring(0, setCookie.indexOf(';'))); + }, 200, "OK", null); + //////////////////////////////////////// + // TEST GET RESOURCE WITH NEW IDENTITY + //////////////////////////////////////// + // final call to verify that the desired escalated user can get the final resource + testRequest( + HttpMethod.GET, + "/protected/admin", + req -> { + req.putHeader(HttpHeaders.COOKIE, sessionRef.get()); + }, resp -> { + }, 200, "OK", "Welcome to the 2nd protected resource!"); + //////////////////////////////////////// + // UNDO IMPERSONATION + //////////////////////////////////////// + testRequest( + HttpMethod.GET, + "/user-switch/undo?redirect_uri=/protected/base", + req -> { + req.putHeader(HttpHeaders.COOKIE, sessionRef.get()); + }, resp -> { + // in this case we should get a redirect, and the session id must change + // session upgrade (secure against replay attacks) + String setCookie = resp.headers().get("set-cookie"); + assertNotNull(setCookie); + // the session must change + assertFalse(setCookie.substring(0, setCookie.indexOf(';')).equals(sessionRef.get())); + sessionRef.set(setCookie.substring(0, setCookie.indexOf(';'))); + String destination = resp.getHeader(HttpHeaders.LOCATION); + assertNotNull(destination); + }, 302, "Found", null); + // final call to verify that the desired de-escalated user can get the final resource + testRequest( + HttpMethod.GET, + "/protected/base", + req -> { + req.putHeader(HttpHeaders.COOKIE, sessionRef.get()); + }, resp -> { + }, 200, "OK", "OK"); + // final call to verify that the desired de-escalated user cannot get the admin resource + testRequest( + HttpMethod.GET, + "/protected/admin", + req -> { + req.putHeader(HttpHeaders.COOKIE, sessionRef.get()); + }, resp -> { + }, 403, "Forbidden", null); + } +} diff --git a/vertx-web/src/test/resources/login/loginusers.properties b/vertx-web/src/test/resources/login/loginusers.properties index a66aa06cbd..10459cdd8f 100644 --- a/vertx-web/src/test/resources/login/loginusers.properties +++ b/vertx-web/src/test/resources/login/loginusers.properties @@ -1,4 +1,8 @@ user.tim = delicious:sausages,morris_dancer,developer user.bob = socks,developer role.morris_dancer=dance,bang_sticks -role.developer=do_actual_work \ No newline at end of file +role.developer=do_actual_work +user.regular=regular,read +user.admin=admin,read,write +role.read=read_files +role.write=write_files