/*
* Copyright 2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package ratpack.pac4j;
import com.google.common.collect.ImmutableList;
import org.pac4j.core.authorization.Authorizer;
import org.pac4j.core.client.Client;
import org.pac4j.core.client.Clients;
import org.pac4j.core.client.DirectClient;
import org.pac4j.core.context.WebContext;
import org.pac4j.core.credentials.Credentials;
import org.pac4j.core.exception.RequiresHttpAction;
import org.pac4j.core.exception.TechnicalException;
import org.pac4j.core.profile.UserProfile;
import ratpack.exec.Blocking;
import ratpack.exec.Downstream;
import ratpack.exec.Operation;
import ratpack.exec.Promise;
import ratpack.func.Block;
import ratpack.handling.Chain;
import ratpack.handling.Context;
import ratpack.handling.Handler;
import ratpack.handling.UserId;
import ratpack.http.Request;
import ratpack.pac4j.internal.Pac4jAuthenticator;
import ratpack.pac4j.internal.Pac4jSessionKeys;
import ratpack.pac4j.internal.RatpackWebContext;
import ratpack.path.PathBinding;
import ratpack.registry.Registry;
import ratpack.session.Session;
import ratpack.util.Types;
import java.util.List;
import java.util.Optional;
import static com.google.common.collect.Iterables.all;
import static java.util.Arrays.asList;
/**
* Provides integration with the <a href="http://www.pac4j.org">Pac4j library</a> for authentication and authorization.
* <p>
* Pac4j support many different authentication providers, such as external sources like GitHub, Twitter, Facebook etc., as well
* as proprietary local authentication sources.
* <p>
* The {@link #authenticator(Client[])} method provides a handler that implements the authentication process,
* and is required in all apps wanting to use authentication.
* <p>
* The {@link #requireAuth(Class, Authorizer...)} method provides a handler that acts like a filter, ensuring that the user is authenticated for all requests.
* This can be used for requiring authentication for all requests starting with a particular request path for example.
* <p>
* The {@link #userProfile(Context)}, {@link #login(Context, Class)} and {@link #logout(Context)} methods provide programmatic authentication mechanisms.
*/
public class RatpackPac4j {
/**
* The default path to the authenticator, {@value}, used by {@link #authenticator(Client[])}.
*/
public static final String DEFAULT_AUTHENTICATOR_PATH = "authenticator";
private RatpackPac4j() {
}
/**
* Calls {@link #authenticator(String, Client[])} with {@link #DEFAULT_AUTHENTICATOR_PATH}.
*
* @param clients the supported auth clients
* @return a handler
*/
public static Handler authenticator(Client<?, ?>... clients) {
return authenticator(DEFAULT_AUTHENTICATOR_PATH, clients);
}
/**
* Creates a handler that implements authentication when the request path matches, and makes a Pac4j {@link Clients} available to downstream handlers otherwise.
* <p>
* This methods performs the same function as {@link #authenticator(String, ClientsProvider)},
* but is more convenient to use when the {@link Client} instances do not depend on the request environment.
*
* @param path the path to bind the authenticator to (relative to the current request path binding)
* @param clients the supported authentication clients
* @return a handler
*/
public static Handler authenticator(String path, Client<?, ?>... clients) {
ImmutableList<Client<?, ?>> clientList = ImmutableList.copyOf(clients);
return authenticator(path, ctx -> clientList);
}
/**
* Provides the set of Pac4j {@link Client clients}.
*
* @see #authenticator(String, ClientsProvider)
* @since 1.1
*/
public interface ClientsProvider {
Iterable<? extends Client<?, ?>> get(Context ctx);
}
/**
* Creates a handler that implements authentication when the request path matches, and makes a Pac4j {@link Clients} available to downstream handlers otherwise.
* <p>
* This handler <b>MUST</b> be <b>BEFORE</b> any code in the handler pipeline that tries to identify the user, such as a {@link #requireAuth} handler in the pipeline.
* It should be added to the handler chain via the {@link Chain#all(Handler)}.
* That is, it should not be added with {@link Chain#get(Handler)} or any method that filters based on request method.
* It is common for this handler to be one of the first handlers in the pipeline.
* <p>
* This handler performs two different functions, based on whether the given path matches the {@link PathBinding#getPastBinding()} component of the current path binding.
* If the path matches, the handler will attempt authentication, which may involve redirecting to an external auth provider, which may then redirect back to this handler.
* If authentication is successful, the {@link UserProfile} of the authenticated user will be placed into the session.
* The user will then be redirected back to the URL that initiated the authentication.
* <p>
* If the path does not match, the handler will push an instance of {@link Clients} into the context registry and pass control downstream.
* The {@link Clients} instance will be retrieved downstream by any {@link #requireAuth(Class, Authorizer...)} handler (or use of {@link #login(Context, Class)}.
*
* @param path the path to bind the authenticator to (relative to the current request path binding)
* @param clientsProvider the provider of authentication clients
* @return a handler
*/
public static Handler authenticator(String path, ClientsProvider clientsProvider) {
return new Pac4jAuthenticator(path, clientsProvider);
}
/**
* An authentication and authorization “filter”.
* <p>
* This handler can be used to ensure that a user profile is available for all downstream handlers.
* If there is no user profile present in the session (i.e. user not logged in), authentication will be initiated based on the given client type (i.e. redirect to the {@link #authenticator(Client[])} handler).
* If there is a {@link UserProfile} present in the session, this handler will push the user profile into the context registry before delegating downstream.
* If there is a {@link UserProfile} present in the context registry, this handler will simply delegate downstream.
* <p>
* If there is a {@link UserProfile}, <b>each</b> of the given authorizers will be tested in turn and all must return true.
* If so, control will flow to the next handler.
* Otherwise, a {@code 403} {@link Context#clientError(int) client error} will be issued.
* <p>
* This handler requires a {@link Clients} instance available in the context registry.
* As such, this handler should be downstream of the {@link #authenticator(Client[])} handler.
*
* <pre class="java">{@code
* import org.pac4j.core.profile.UserProfile;
* import org.pac4j.http.client.indirect.IndirectBasicAuthClient;
* import org.pac4j.http.credentials.authenticator.test.SimpleTestUsernamePasswordAuthenticator;
* import ratpack.guice.Guice;
* import ratpack.pac4j.RatpackPac4j;
* import ratpack.session.SessionModule;
* import ratpack.test.embed.EmbeddedApp;
*
* import static org.junit.Assert.assertEquals;
*
* public class Example {
* public static void main(String... args) throws Exception {
* EmbeddedApp.of(s -> s
* .registry(Guice.registry(b -> b.module(SessionModule.class)))
* .handlers(c -> c
* .all(RatpackPac4j.authenticator(new IndirectBasicAuthClient(new SimpleTestUsernamePasswordAuthenticator())))
* .get("logout", ctx -> RatpackPac4j.logout(ctx).then(() -> ctx.render("logged out")))
* .prefix("require-authn", a -> a
* .all(RatpackPac4j.requireAuth(IndirectBasicAuthClient.class))
* .get(ctx -> ctx.render("Hello " + ctx.get(UserProfile.class).getId()))
* )
* .prefix("require-authz", a -> a
* .all(RatpackPac4j.requireAuth(IndirectBasicAuthClient.class, (ctx, profile) -> { return "special-user".equals(profile.getId()); }))
* .get(ctx -> ctx.render("Hello " + ctx.get(UserProfile.class).getId()))
* )
* .get(ctx -> ctx.render("no auth required"))
* )
* ).test(httpClient -> {
* httpClient.requestSpec(r -> r.redirects(1));
* assertEquals("no auth required", httpClient.getText());
*
* assertEquals(401, httpClient.get("require-authn").getStatusCode());
* assertEquals("Hello user", httpClient.requestSpec(r -> r.basicAuth("user", "user")).getText("require-authn"));
*
* assertEquals(403, httpClient.get("require-authz").getStatusCode());
*
* assertEquals("logged out", httpClient.getText("logout"));
* httpClient.resetRequest();
*
* assertEquals(401, httpClient.get("require-authz").getStatusCode());
* assertEquals("Hello special-user", httpClient.requestSpec(r -> r.basicAuth("special-user", "special-user")).getText("require-authz"));
* });
* }
* }
* }</pre>
*
* @param clientType the client type to use to authenticate with if required
* @param authorizers the authorizers to check authorizations
* @return a handler
*/
@SafeVarargs
@SuppressWarnings("varargs")
public static <C extends Credentials, U extends UserProfile> Handler requireAuth(Class<? extends Client<C, U>> clientType, Authorizer<? super U>... authorizers) {
List<Authorizer<? super U>> authorizerList = asList(authorizers);
return ctx -> RatpackPac4j.login(ctx, clientType).then(userProfile -> {
if (authorizerList.isEmpty()) {
ctx.next(Registry.single(userProfile));
} else {
RatpackWebContext.from(ctx, false).then(webContext -> {
if (all(authorizerList, a -> a == null || a.isAuthorized(webContext, userProfile))) {
ctx.next(Registry.single(userProfile));
} else {
ctx.clientError(403);
}
});
}
});
}
/**
* Logs the user in by redirecting to the authenticator, or provides the user profile if already logged in.
* <p>
* This method can be used to programmatically initiate a log in, if required.
* If the user is already logged in, the user profile will be provided via the returned promise.
* If the user is not already logged in, the promise will not be fulfilled and the user will be redirected to the authenticator.
* As such, like {@link #requireAuth(Class, Authorizer...)}, this can only be used downstream of the {@link #authenticator(Client[])} handler.
*
* <pre class="java">{@code
* import org.pac4j.http.client.indirect.IndirectBasicAuthClient;
* import org.pac4j.http.credentials.authenticator.test.SimpleTestUsernamePasswordAuthenticator;
* import ratpack.guice.Guice;
* import ratpack.http.client.ReceivedResponse;
* import ratpack.pac4j.RatpackPac4j;
* import ratpack.session.SessionModule;
* import ratpack.test.embed.EmbeddedApp;
*
* import java.util.Optional;
*
* import static org.junit.Assert.assertEquals;
*
* public class Example {
* public static void main(String... args) throws Exception {
* EmbeddedApp.of(s -> s
* .registry(Guice.registry(b -> b.module(SessionModule.class)))
* .handlers(c -> c
* .all(RatpackPac4j.authenticator(new IndirectBasicAuthClient(new SimpleTestUsernamePasswordAuthenticator())))
* .get("auth", ctx -> RatpackPac4j.login(ctx, IndirectBasicAuthClient.class).then(p -> ctx.redirect("/")))
* .get(ctx ->
* RatpackPac4j.userProfile(ctx)
* .route(Optional::isPresent, p -> ctx.render("Hello " + p.get().getId()))
* .then(p -> ctx.render("not authenticated"))
* )
* )
* ).test(httpClient -> {
* // user is not authenticated
* assertEquals("not authenticated", httpClient.getText());
*
* // authenticate…
* ReceivedResponse response = httpClient.requestSpec(r -> r.basicAuth("user", "user")).get("auth");
*
* // authenticated (redirected to /)
* assertEquals("Hello user", response.getBody().getText());
* });
* }
* }
* }</pre>
*
* @param ctx the handling context
* @param clientType the client type to authenticate with
* @return a promise for the user profile, fulfilled if logged in
*/
public static <C extends Credentials, U extends UserProfile> Promise<U> login(Context ctx, Class<? extends Client<C, U>> clientType) {
if (isDirect(clientType)) {
return userProfile(ctx)
.flatMap(p -> {
if (p.isPresent()) {
Optional<U> cast = Types.cast(p);
return Promise.value(cast);
} else {
return performDirectAuthentication(ctx, clientType);
}
})
.route(p -> !p.isPresent(), p -> ctx.clientError(401))
.map(Optional::get);
} else {
return userProfile(ctx)
.route(p -> !p.isPresent(), p -> initiateAuthentication(ctx, clientType))
.map(Optional::get)
.map(Types::<U>cast);
}
}
/**
* Obtains the logged in user's profile, if the user is logged in.
* <p>
* The promised optional will be empty if the user is not authenticated.
* <p>
* This method should be used if the user <i>may</i> have been authenticated.
* That is, when the the need for the profile is not downstream of an {@link #requireAuth(Class, Authorizer...)} handler,
* as the auth handler puts the profile into the context registry for easy retrieval.
* <p>
* This method returns a promise as it will attempt to load the profile from the session if it
* isn't already in the context registry.
*
* <pre class="java">{@code
* import io.netty.handler.codec.http.HttpHeaderNames;
* import org.pac4j.core.profile.UserProfile;
* import org.pac4j.http.client.indirect.IndirectBasicAuthClient;
* import org.pac4j.http.credentials.authenticator.test.SimpleTestUsernamePasswordAuthenticator;
* import ratpack.guice.Guice;
* import ratpack.http.client.ReceivedResponse;
* import ratpack.pac4j.RatpackPac4j;
* import ratpack.session.SessionModule;
* import ratpack.test.embed.EmbeddedApp;
*
* import java.util.Optional;
*
* import static org.junit.Assert.assertEquals;
* import static org.junit.Assert.assertTrue;
*
* public class Example {
* public static void main(String... args) throws Exception {
* EmbeddedApp.of(s -> s
* .registry(Guice.registry(b -> b.module(SessionModule.class)))
* .handlers(c -> c
* .all(RatpackPac4j.authenticator(new IndirectBasicAuthClient(new SimpleTestUsernamePasswordAuthenticator())))
* .prefix("auth", a -> a
* .all(RatpackPac4j.requireAuth(IndirectBasicAuthClient.class))
* .get(ctx -> {
* ctx.render("Hello " + ctx.get(UserProfile.class).getId());
* })
* )
* .get("no-auth", ctx -> {
* RatpackPac4j.userProfile(ctx)
* .route(Optional::isPresent, p -> ctx.render("Hello " + p.get().getId()))
* .then(p -> ctx.render("not authenticated"));
* })
* )
* ).test(httpClient -> {
* // User is not authenticated
* assertEquals("not authenticated", httpClient.getText("no-auth"));
*
* // Authenticate…
* ReceivedResponse response = httpClient.requestSpec(r -> r.redirects(0)).get("auth");
* assertEquals(302, response.getStatusCode());
* String redirectTo = response.getHeaders().get(HttpHeaderNames.LOCATION);
* assertEquals(401, httpClient.get(redirectTo).getStatusCode());
* response = httpClient.requestSpec(r -> r
* .basicAuth("user", "user")
* .redirects(0)
* ).post(redirectTo);
* assertEquals(302, response.getStatusCode());
* redirectTo = response.getHeaders().get(HttpHeaderNames.LOCATION);
* assertTrue(redirectTo.endsWith("/auth"));
* assertEquals("Hello user", httpClient.getText(redirectTo));
*
* // User is now authenticated
* assertEquals("Hello user", httpClient.getText("no-auth"));
* });
* }
* }
* }</pre>
*
* @param ctx the handling context
* @return a promise for the user profile
* @see #userProfile(Context, Class)
*/
public static Promise<Optional<UserProfile>> userProfile(Context ctx) {
return userProfile(ctx, UserProfile.class);
}
/**
* Obtains the logged in user's profile, of the given type, if the user is logged in.
* <p>
* The promised optional will be empty if the user is not authenticated.
* If there exists a {@link UserProfile} for the current user but it is not compatible with the requested type,
* the returned promise will be a failure with a {@link ClassCastException}.
* <p>
* This method should be used if the user <i>may</i> have been authenticated.
* That is, when the the need for the profile is not downstream of an {@link #requireAuth(Class, Authorizer...)} handler,
* as the auth handler puts the profile into the context registry for easy retrieval.
* <p>
* This method returns a promise as it will attempt to load the profile from the session if it
* isn't already in the context registry.
*
* @param ctx the handling context
* @param type the type of the user profile
* @param <T> the type of the user profile
* @return a promise for the user profile
* @see #userProfile(Context)
*/
public static <T extends UserProfile> Promise<Optional<T>> userProfile(Context ctx, Class<T> type) {
return Promise.async(f ->
toProfile(type, f, ctx.maybeGet(UserProfile.class), () ->
ctx.get(Session.class)
.get(Pac4jSessionKeys.USER_PROFILE)
.then(p -> {
if (p.isPresent()) {
ctx.getRequest().add(UserId.class, UserId.of(p.get().getId()));
}
toProfile(type, f, p, () -> f.success(Optional.<T>empty()));
})
)
);
}
/**
* Logs out the current user, removing their profile from the session.
* <p>
* The returned operation simply removes the profile from the session, regardless of whether it's actually there or not.
*
* <pre class="java">{@code
* import org.pac4j.http.client.indirect.IndirectBasicAuthClient;
* import org.pac4j.http.credentials.authenticator.test.SimpleTestUsernamePasswordAuthenticator;
* import ratpack.guice.Guice;
* import ratpack.http.client.ReceivedResponse;
* import ratpack.pac4j.RatpackPac4j;
* import ratpack.session.SessionModule;
* import ratpack.test.embed.EmbeddedApp;
*
* import java.util.Optional;
*
* import static org.junit.Assert.assertEquals;
*
* public class Example {
* public static void main(String... args) throws Exception {
* EmbeddedApp.of(s -> s
* .registry(Guice.registry(b -> b.module(SessionModule.class)))
* .handlers(c -> c
* .all(RatpackPac4j.authenticator(new IndirectBasicAuthClient(new SimpleTestUsernamePasswordAuthenticator())))
* .get("auth", ctx -> RatpackPac4j.login(ctx, IndirectBasicAuthClient.class).then(p -> ctx.redirect("/")))
* .get(ctx ->
* RatpackPac4j.userProfile(ctx)
* .route(Optional::isPresent, p -> ctx.render("Hello " + p.get().getId()))
* .then(p -> ctx.render("not authenticated"))
* )
* .get("logout", ctx ->
* RatpackPac4j.logout(ctx).then(() -> ctx.redirect("/"))
* )
* )
* ).test(httpClient -> {
* // user is not authenticated
* assertEquals("not authenticated", httpClient.getText());
*
* // authenticate…
* ReceivedResponse response = httpClient.requestSpec(r -> r.basicAuth("user", "user")).get("auth");
*
* // authenticated (redirected to /)
* assertEquals("Hello user", response.getBody().getText());
*
* // logout (redirected to /)
* assertEquals("not authenticated", httpClient.getText("logout"));
* });
* }
* }
* }</pre>
*
* @param ctx the handling context
* @return the logout operation
*/
public static Operation logout(Context ctx) {
return ctx.get(Session.class)
.remove(Pac4jSessionKeys.USER_PROFILE);
}
/**
* Adapts a Ratpack {@link Context} to a Pac4j {@link WebContext}.
* <p>
* The returned WebContext does not have access to the request body.
* {@link WebContext#getRequestParameters()} and associated methods will not include any
* form parameters if the request was a form.
*
* @param ctx a Ratpack context
* @return a Pac4j web context
* @since 1.4
*/
public static Promise<WebContext> webContext(Context ctx) {
return Types.cast(RatpackWebContext.from(ctx, false));
}
private static <T extends UserProfile> void toProfile(Class<T> type, Downstream<? super Optional<T>> downstream, Optional<UserProfile> userProfileOptional, Block onEmpty) throws Exception {
if (userProfileOptional.isPresent()) {
final UserProfile userProfile = userProfileOptional.get();
if (type.isInstance(userProfile)) {
downstream.success(Optional.of(type.cast(userProfile)));
} else {
downstream.error(new ClassCastException("UserProfile is of type " + userProfile.getClass() + ", and is not compatible with " + type));
}
} else {
onEmpty.execute();
}
}
private static void initiateAuthentication(Context ctx, Class<? extends Client<?, ?>> clientType) {
Request request = ctx.getRequest();
Clients clients = ctx.get(Clients.class);
Client<?, ?> client = clients.findClient(clientType);
RatpackWebContext.from(ctx, false).then(webContext -> {
webContext.getSession().set(Pac4jSessionKeys.REQUESTED_URL, request.getUri());
try {
client.redirect(webContext, true);
} catch (Exception e) {
if (e instanceof RequiresHttpAction) {
webContext.sendResponse((RequiresHttpAction) e);
return;
} else {
ctx.error(new TechnicalException("Failed to redirect", e));
}
}
webContext.sendResponse();
});
}
private static <C extends Credentials, U extends UserProfile> Promise<Optional<U>> performDirectAuthentication(Context ctx, Class<? extends Client<C, U>> clientType) {
return RatpackWebContext.from(ctx, false).flatMap(webContext ->
Blocking.get(() -> {
Clients clients = ctx.get(Clients.class);
Client<C, U> client = clients.findClient(clientType);
return userProfileFromCredentials(client, webContext);
})
);
}
private static <C extends Credentials, U extends UserProfile> Optional<U> userProfileFromCredentials(Client<C, U> client, RatpackWebContext webContext) throws RequiresHttpAction {
C credentials = client.getCredentials(webContext);
U userProfile = client.getUserProfile(credentials, webContext);
return Optional.ofNullable(userProfile);
}
private static boolean isDirect(Class<? extends Client<?, ?>> clientType) {
return DirectClient.class.isAssignableFrom(clientType);
}
}