Everyone wants all sorts of integrations for Tapestry with other frameworks, but sometimes rolling your own is actually easier. Let's start with securing access to pages, a subject that still keeps coming up on the mailing list. I thought I'd show a little bit about how I tackle this problem generally.
People have been asking for a single definitive solution for handling security ... but I don't see any single solution satisfying even the majority of projects. Why? Because there are simply too many variables. For example, are you using LDAP, OpenAuth or some ad-hoc user registry (in your database)? Are pages accessible by default, or in-accessible by default? Are you using role-based security? How do you represent roles then? Creating a single solution that's pluggable enough for all these possibilities seems like an insurmountable challenge ... but perhaps we can come up with a toolkit so that you can assemble your own custom solution (more on that later).
One approach to security could be to define a base class, ProtectedPage
, that enforced the basic rules (you must be logged in to use this page). You can accomplish such a thing using the activate
event handler ... but I find such an approach clumsy. Anytime you can avoid inheritance, you'll find your code easier to understand, easier to manage, easier to test and easier to evolve.
Instead, let's pursue a more declarative approach, where we use an annotation to mark pages that require that the user be logged in. We'll start with these ground rules:
- Pages are freely accessible by anyone, unless they have a @RequiresLogin annotation
- Any static resource (in the web context directory) is accessible to anybody
- There's already some kind of UserAuthentication service that knows if the user is currently logged in or not, and (if logged in) who they are, as a User object
So, we need to define a RequiresLogin annotation, and we need to enforce it, by preventing any access to the page unless the user is logged in.
That poses a challenge: how do you get "inside" Tapestry to enforce this annotation? What you really want to do is "slip in" a little bit of your code into existing Tapestry code ... the code that analyzes the incoming request, determines what type of request it is (a page render request vs. a component event request), and ultimately starts calling into the page code to do the work.
This is a great example of the central design of Tapestry and it's IoC container: to natively supporting this kind of extensibility. Through the use of service configurations it's possible to do exactly that: slip a piece of code into the middle of that default Tapestry code. The trick is to identify where. This image gives a rough map to how Tapestry handles incoming requests:
In fact, there's a specific place for this kind of extension: the ComponentRequestHandler
pipeline service1. As a pipeline service, ComponentRequestHandler
has a configuration of filters, and adding a filter to this pipeline is just what we need.
Defining the Annotation
First, lets define our annotation:
@Target( { ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RequiresLogin { }
This annotation is designed to be placed on a page class to indicate that the user must be logged in to access the page. The retention policy is important here: it needs to be visible at runtime for our runtime code to see it and act on its presence.
An annotation by itself does nothing ... we need the code that checks for the annotation.
Creating a ComponentRequestFilter
Filters for the ComponentRequestHandler
pipeline are instances of the interface ComponentRequestFilter
:
/** * Filter interface for {@link org.apache.tapestry5.services.ComponentRequestHandler}. */ public interface ComponentRequestFilter { /** * Handler for a component action request which will trigger an event on a component and use the return value to * send a response to the client (typically, a redirect to a page render URL). * * @param parameters defining the request * @param handler next handler in the pipeline */ void handleComponentEvent(ComponentEventRequestParameters parameters, ComponentRequestHandler handler) throws IOException; /** * Invoked to activate and render a page. In certain cases, based on values returned when activating the page, a * {@link org.apache.tapestry5.services.ComponentEventResultProcessor} may be used to send an alternate response * (typically, a redirect). * * @param parameters defines the page name and activation context * @param handler next handler in the pipeline */ void handlePageRender(PageRenderRequestParameters parameters, ComponentRequestHandler handler) throws IOException; }
Our implementation of this filter will check the page referenced in the request to see if it has the annotation. If the annotation is present and the user has not yet logged in, we'll redirect to the Login page. When a redirect is not necessary, we delegate to the next handler in the pipeline2:
public class RequiresLoginFilter implements ComponentRequestFilter { private final PageRenderLinkSource renderLinkSource; private final ComponentSource componentSource; private final Response response; private final AuthenticationService authService; public PageAccessFilter(PageRenderLinkSource renderLinkSource, ComponentSource componentSource, Response response, AuthenticationService authService) { this.renderLinkSource = renderLinkSource; this.componentSource = componentSource; this.response = response; this.authService = authService; } public void handleComponentEvent( ComponentEventRequestParameters parameters, ComponentRequestHandler handler) throws IOException { if (dispatchedToLoginPage(parameters.getActivePageName())) { return; } handler.handleComponentEvent(parameters); } public void handlePageRender(PageRenderRequestParameters parameters, ComponentRequestHandler handler) throws IOException { if (dispatchedToLoginPage(parameters.getLogicalPageName())) { return; } handler.handlePageRender(parameters); } private boolean dispatchedToLoginPage(String pageName) throws IOException { if (authService.isLoggedIn()) { return false; } Component page = componentSource.getPage(pageName); if (! page.getClass().isAnnotationPresent(RequiresLogin.class)) { return false; } Link link = renderLinkSource.createPageRenderLink("Login"); response.sendRedirect(link); return true; } }
The above code makes a bunch of assumptions and simplifications. First, it assumes the name of the page to redirect to is "Login". It also doesn't try to capture any part of the incoming request to allow the application to continue after the user logs in. Finally, the AuthenticationService is not part of Tapestry ... it is something specific to the application.
You'll notice that the dependencies (PageRenderLinkSource
, etc.)
are injected through constructor parameters and then stored in final fields. This is the preferred, if more verbose approach. We could also have used no constructor, a non-final fields with an @Inject annotation (it's largely a style choice, though constructor injection with final fields is more guaranteed to be fully thread safe).
The class on its own is not enough, however: we have to get Tapestry to actually use this class.
Contributing the Filter
The last part of this is hooking the above code into the flow. This is done by making a contribution to the ComponentEventHandler
service's configuration.
Service contributions are implemented as methods of a Tapestry module class, such as AppModule
:
public static void contributeComponentRequestHandler( OrderedConfigurationconfiguration) { configuration.addInstance("RequiresLogin", RequiresLoginFilter.class); }
Contributing modules contribute into an OrderedConfiguration
: after all modules have had a chance to contribute, the configuration is converted into a List
that's passed to the service implementation.
The addInstance()
method makes it easy to contribute the filter: Tapestry will look at the class, see the constructor, and inject dependencies into the filter via the constructor parameters. It's all very declarative: the code needs the PageRenderLinkSource
, so it simply defines a final field and a constructor parameter ... Tapestry takes care of the rest.
You might wonder why we need to specify a name ("RequiresLogin") for the contribution? The answer addresses a somewhat rare but still important case: multiple contributions to the same configuration that have some form of interaction. By giving each contribution a unique id, it's possible to set up ordering rules (such as "contribution 'Foo' comes after contribution 'Bar'"). Here, there is no need for ordering because there aren't any other filters (Tapestry provides this service and configuration, but doesn't make any contributions of its own into it).
Improvements and Conclusions
This is just a first pass at security. For my clients, I've built more elaborate solutions, that include capturing the page name and activation context to allow the application to "resume" after the login is complete, as well as approaches for automatically logging the user in as needed (via a cookie or other mechanism).
Other improvements would be to restrict access to pages based on some set of user roles; again, how this is represented both in code and annotations, and in the data model is quite up for grabs.
My experience with different clients really underscores what a fuzzy world security can be: there are so many options for how you represent, identify and authenticate the user. Even basic decisions are underpinnings are subject to interpretation; for example, one of my clients wants all pages to require login unless a specific annotation is found. Perhaps over time enough of these use cases can be worked out to build the toolkit I mentioned earlier.
Even so, the amount of code to build a solid, custom security implementation is still quite small ... though the trick, as always, is writing just the write code and hooking it into Tapestry in just the right way.
I expect to follow up this article with part 2, which will expand on the solution a bit more, addressing some more of the real world constraints my customers demand. Stay tuned!
1 In fact, this service and pipeline were created in Tapestry 5.1 specifically to address this use case. In Tapestry 5.0, this approach required two very similar filter contributions to two similar pipelines.
2 If there are multiple filters, you'd think that you'd delegate to the next filter. Actually you do, but Tapestry provides a bridge: a wrapper around the filter that uses the main interface for the service. In this way, each filter delegates to either the next filter, or the terminator (the service implementation after all filters) in a uniform manner. More details about this are in the pipeline documentation.
Hi Howards,
ReplyDeletethanks a lot for this posting, even if I already found a solution, similar to yours, it's good to know, that others can find it now too.
I would very pleased, if you could add a hint, how you saved the targetpage & context to proceed after loggig in.
Thanks a lot
Alex
I've written an answer to the mailing list. Read it here
ReplyDeleteThank you Howard for this very much needed post! It is this kind of advices that the community really needs.
ReplyDeleteAlthough there are lots of differences in anthenticating and authorizing users, there are also common scenarios. The most common ones are the first to be covered.
It would be most comforting for a developer to know he/she is not forgetting something to introduce a security hole in the web app.
I believe the most common simple scenario is:
- users are stored in the database
- users are of two types (regular and admins)
- part of the web site must be protected
- user must continue to the destination page after successful authentication
- username and password are used as an authentication method
- remember me functionality
- configurable logged out destination
- DenialOfService protection (captcha after 5 failed attempts)
Of course this covers nothing but the basics.
The receipe for this would really help. And what matters most to me is the clean code which everyone can understand fast even after comming to a project team later.
Cheers
This is the approach I used in most Tapestry apps. Often I do it the other way around: all pages are protected unless they are annotated with something like @PublicPage.
ReplyDeleteBTW based on your post I wrote a post about Mapped Diagnostic Context with Tapestry Filters. Read here.
That is exactly what ChenilleKit Access module is doing with a lot of what you're talking in here about extensions (like the "resume" after login).
ReplyDeleteI would be happy if you could give a look at the source and share your thoughts.
Many thanks for this article, Howard. This supply us with very good, first-hand info on this topic AND it also supply us with a very interesting look at the internal working of Tapestry. Thanks again.
ReplyDeleteHi Howard,
ReplyDeleteNice post. It seems there is an error in the code for the RequiresLoginFilter there seems to be a constructor named PageAccessFilter.
Hi Howards,
ReplyDeleteNice explanation,thanks a lot.
I wana ask you how to implement role based security in Tapestry5(i am not intended to use third party API).
thanks
Gaurav P Singh
Guarav,
ReplyDeleteThis is a blog, it's not an effective discussion forum. Please join the Tapestry user mailing list and discuss there.