Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Map Shiro's AuthenticationException with Jersey's ExceptionMapper

Preface

First of all, my sincerest apologies for this question being extremely long, but I honestly have no idea on how to shorten it, since each part is kind of a special case. Admittedly, I may be blind on this since I am banging my head against the wall for a couple of days now and I am starting to get desperate.

My utmost respect and thankfulness to all of you who read through it.

The aim

I would like to be able to map Shiro's AuthenticationException and it's subclasses to JAX-RS Responses by using Jersey ExceptionMappers, set up using a Guice 3.0 Injector which creates an embedded Jetty.

The environment

  • Guice 3.0
  • Jetty 9.2.12.v20150709
  • Jersey 1.19.1
  • Shiro 1.2.4

The setup

The embedded Jetty is created using a Guice Injector

// imports omitted for brevity
public class Bootstrap {

    public static void main(String[] args) throws Exception {

      /*
       * The ShiroWebModule is passed as a class
       * since it needs a ServletContext to be initialized
       */
        Injector injector = Guice.createInjector(new ServerModule(MyShiroWebModule.class));

        Server server = injector.getInstance(Server.class);

        server.start();
        server.join();
    }
}

The ServerModule binds a Provider for the Jetty Server:

public class ServerModule extends AbstractModule {

    Class<? extends ShiroWebModule> clazz;

    public ServerModule(Class <?extends ShiroWebModule> clazz) {
        this.clazz = clazz;
    }

    @Override
    protected void configure() {
        bind(Server.class)
         .toProvider(JettyProvider.withShiroWebModule(clazz))
         .in(Singleton.class);
    }

}

The JettyProvider sets up a Jetty WebApplicationContext, registers the ServletContextListener necessary for Guice and a few things more, which I left in to make sure no "side effects" may be hidden:

public class JettyProvider implements Provider<Server>{

    @Inject
    Injector injector;

    @Inject
    @Named("server.Port")
    Integer port;

    @Inject
    @Named("server.Host")
    String host;

    private Class<? extends ShiroWebModule> clazz;

    private static Server server;

    private JettyProvider(Class<? extends ShiroWebModule> clazz){
        this.clazz = clazz;
    }

    public static JettyProvider withShiroWebModule(Class<? extends ShiroWebModule> clazz){
        return new JettyProvider(clazz);
    }

    public Server get() {       

        WebAppContext webAppContext = new WebAppContext();
        webAppContext.setContextPath("/");

        // Set during testing only
        webAppContext.setResourceBase("src/main/webapp/");
        webAppContext.setParentLoaderPriority(true);

        webAppContext.addEventListener(
          new MyServletContextListener(injector,clazz)
        );

        webAppContext.addFilter(
          GuiceFilter.class, "/*",
          EnumSet.allOf(DispatcherType.class)
        );

        webAppContext.setThrowUnavailableOnStartupException(true);

        QueuedThreadPool threadPool = new QueuedThreadPool(500, 10);

        server = new Server(threadPool);

        ServerConnector connector = new ServerConnector(server);
        connector.setHost(this.host);
        connector.setPort(this.port);

        RequestLogHandler requestLogHandler = new RequestLogHandler();
        requestLogHandler.setRequestLog(new NCSARequestLog());

        HandlerCollection handlers = new HandlerCollection(true);

        handlers.addHandler(webAppContext);
        handlers.addHandler(requestLogHandler);

        server.addConnector(connector);
        server.setStopAtShutdown(true);
        server.setHandler(handlers);
        return server;
    }

}

In MyServletContextListener, I created a child injector, which gets initialized with the JerseyServletModule:

public class MyServletContextListener extends GuiceServletContextListener {

    private ServletContext servletContext;

    private Injector injector;

    private Class<? extends ShiroWebModule> shiroModuleClass;
    private ShiroWebModule module;

    public ServletContextListener(Injector injector,
            Class<? extends ShiroWebModule> clazz) {
        this.injector = injector;
        this.shiroModuleClass = clazz;
    }

    @Override
    public void contextInitialized(ServletContextEvent servletContextEvent) {

        this.servletContext = servletContextEvent.getServletContext();
        super.contextInitialized(servletContextEvent);

    }

    @Override
    protected Injector getInjector() {
        /*
         * Since we finally have our ServletContext
         * we can now instantiate our ShiroWebModule
         */
        try {
            module = shiroModuleClass.getConstructor(ServletContext.class)
                    .newInstance(this.servletContext);
        } catch (InstantiationException | IllegalAccessException
                | IllegalArgumentException | InvocationTargetException
                | NoSuchMethodException | SecurityException e) {
            e.printStackTrace();
        }

    /*
     * Now, we create a child injector with the JerseyModule
     */
        Injector child = injector.createChildInjector(module,
                new JerseyModule());

        SecurityManager securityManager = child
                .getInstance(SecurityManager.class);
        SecurityUtils.setSecurityManager(securityManager);

        return child;
    }

}

The JerseyModule, a subclass of JerseyServletModule now put everything together:

public class JerseyModule extends JerseyServletModule {

    @Override
    protected void configureServlets() {
        bindings();
        filters();
    }

    private void bindings() {

        bind(DefaultServlet.class).asEagerSingleton();
        bind(GuiceContainer.class).asEagerSingleton();
        serve("/*").with(DefaultServlet.class);
    }

    private void filters() {
        Map<String, String> params = new HashMap<String, String>();

    // Make sure Jersey scans the package
        params.put("com.sun.jersey.config.property.packages",
                "com.example.webapp");

        params.put("com.sun.jersey.config.feature.Trace", "true");

        filter("/*").through(GuiceShiroFilter.class,params);
        filter("/*").through(GuiceContainer.class, params);

        /* 
         * Although the ExceptionHandler is already found by Jersey
         * I bound it manually to be sure
         */
        bind(ExceptionHandler.class);

        bind(MyService.class);

    }

}

The ExceptionHandler is extremely straightforward and looks like this:

@Provider
@Singleton
public class ExceptionHandler implements
        ExceptionMapper<AuthenticationException> {

    public Response toResponse(AuthenticationException exception) {
        return Response
                .status(Status.UNAUTHORIZED)
                .entity("auth exception handled")
                .build();
    }

}

The problem

Now everything works fine when I want to access a restricted resource and enter correct principal/credential combinations. But as soon as enter a non-existing user or a wrong password, I want an AuthenticationException to be thrown by Shiro and I want it to be handled by the above ExceptionHandler.

Utilizing the default AUTHC filter provided by Shiro in the beginning, I noticed that AuthenticationExceptions are silently swallowed and the user is redirected to the login page again.

So I subclassed Shiro's FormAuthenticationFilter to throw an AuthenticationException if there is one:

public class MyFormAutheticationFilter extends FormAuthenticationFilter {

    @Override
    protected boolean onLoginFailure(AuthenticationToken token,
            AuthenticationException e, ServletRequest request,
            ServletResponse response) {
        if(e != null){
            throw e;
        }
        return super.onLoginFailure(token, e, request, response);
    }
}

And I also tried it with throwing the exception e wrapped in a MappableContainerException.

Both approaches cause the same problem: Instead of the exception being handled by the defined ExceptionHandler, a javax.servlet.ServletException is thrown:

  javax.servlet.ServletException: org.apache.shiro.authc.AuthenticationException: Unknown Account!
    at org.apache.shiro.web.servlet.AdviceFilter.cleanup(AdviceFilter.java:196)
    at org.apache.shiro.web.filter.authc.AuthenticatingFilter.cleanup(AuthenticatingFilter.java:155)
    at org.apache.shiro.web.servlet.AdviceFilter.doFilterInternal(AdviceFilter.java:148)
    at org.apache.shiro.web.servlet.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:125)
    at org.apache.shiro.guice.web.SimpleFilterChain.doFilter(SimpleFilterChain.java:41)
    at org.apache.shiro.web.servlet.AbstractShiroFilter.executeChain(AbstractShiroFilter.java:449)
    at org.apache.shiro.web.servlet.AbstractShiroFilter$1.call(AbstractShiroFilter.java:365)
    at org.apache.shiro.subject.support.SubjectCallable.doCall(SubjectCallable.java:90)
    at org.apache.shiro.subject.support.SubjectCallable.call(SubjectCallable.java:83)
    at org.apache.shiro.subject.support.DelegatingSubject.execute(DelegatingSubject.java:383)
    at org.apache.shiro.web.servlet.AbstractShiroFilter.doFilterInternal(AbstractShiroFilter.java:362)
    at org.apache.shiro.web.servlet.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:125)
    at com.google.inject.servlet.FilterDefinition.doFilter(FilterDefinition.java:163)
    at com.google.inject.servlet.FilterChainInvocation.doFilter(FilterChainInvocation.java:58)
    at com.google.inject.servlet.ManagedFilterPipeline.dispatch(ManagedFilterPipeline.java:118)
    at com.google.inject.servlet.GuiceFilter.doFilter(GuiceFilter.java:113)
    at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1652)
    at org.eclipse.jetty.servlet.ServletHandler.doHandle(ServletHandler.java:585)
    at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:143)
    at org.eclipse.jetty.security.SecurityHandler.handle(SecurityHandler.java:577)
    at org.eclipse.jetty.server.session.SessionHandler.doHandle(SessionHandler.java:223)
    at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1127)
    at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:515)
    at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:185)
    at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1061)
    at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:141)
    at org.eclipse.jetty.server.handler.HandlerCollection.handle(HandlerCollection.java:110)
    at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:97)
    at org.eclipse.jetty.server.Server.handle(Server.java:499)
    at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:310)
    at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:257)
    at org.eclipse.jetty.io.AbstractConnection$2.run(AbstractConnection.java:540)
    at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:635)
    at org.eclipse.jetty.util.thread.QueuedThreadPool$3.run(QueuedThreadPool.java:555)
    at java.lang.Thread.run(Thread.java:744)
Caused by: org.apache.shiro.authc.AuthenticationException: Unknown Account!
    at com.example.webapp.security.MyAuthorizingRealm.doGetAuthenticationInfo(MyAuthorizingRealm.java:27)
    at org.apache.shiro.realm.AuthenticatingRealm.getAuthenticationInfo(AuthenticatingRealm.java:568)
    at org.apache.shiro.authc.pam.ModularRealmAuthenticator.doSingleRealmAuthentication(ModularRealmAuthenticator.java:180)
    at org.apache.shiro.authc.pam.ModularRealmAuthenticator.doAuthenticate(ModularRealmAuthenticator.java:267)
    at org.apache.shiro.authc.AbstractAuthenticator.authenticate(AbstractAuthenticator.java:198)
    at org.apache.shiro.mgt.AuthenticatingSecurityManager.authenticate(AuthenticatingSecurityManager.java:106)
    at org.apache.shiro.mgt.DefaultSecurityManager.login(DefaultSecurityManager.java:270)
    at org.apache.shiro.subject.support.DelegatingSubject.login(DelegatingSubject.java:256)
    at org.apache.shiro.web.filter.authc.AuthenticatingFilter.executeLogin(AuthenticatingFilter.java:53)
    at org.apache.shiro.web.filter.authc.FormAuthenticationFilter.onAccessDenied(FormAuthenticationFilter.java:154)
    at org.apache.shiro.web.filter.AccessControlFilter.onAccessDenied(AccessControlFilter.java:133)
    at org.apache.shiro.web.filter.AccessControlFilter.onPreHandle(AccessControlFilter.java:162)
    at org.apache.shiro.web.filter.PathMatchingFilter.isFilterChainContinued(PathMatchingFilter.java:203)
    at org.apache.shiro.web.filter.PathMatchingFilter.preHandle(PathMatchingFilter.java:178)
    at org.apache.shiro.web.servlet.AdviceFilter.doFilterInternal(AdviceFilter.java:131)
    ... 32 more

The question, after all

Given that the environment can't be changed, how can I achieve that a server instance still can be requested via Guice, while Shiro's exceptions are handled with Jersey's auto discovered ExceptionMappers?

like image 958
Markus W Mahlberg Avatar asked Aug 05 '15 15:08

Markus W Mahlberg


1 Answers

This question is much too complicated for me to reproduce on my side, but I saw a problem that I think is the answer and I'll delete this answer if I turn out to be wrong.

You do this:

@Provider
@Singleton
public class ExceptionHandler implements
        ExceptionMapper<AuthenticationException> {

Which is correct, you are supposed to bind with both of those annotations as in this question. However, what you do differently is this:

/* 
 * Although the ExceptionHandler is already found by Jersey
 * I bound it manually to be sure
 */
bind(ExceptionHandler.class);

The annotations in a class definition have lower priority than that in a module's configure() method, meaning you are erasing the annotations when you bind "it manually just to be sure". Try erasing that line of code and see if that fixes your problem. If it doesn't fix the problem, leave it deleted anyway, because I am certain that it is at least part of the problem - that statement erases those essential annotations.

like image 89
durron597 Avatar answered Sep 24 '22 14:09

durron597