/**
* Copyright 2014 55 Minutes (http://www.55minutes.com)
*
* 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 fiftyfive.wicket.shiro;
import java.lang.annotation.Annotation;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import fiftyfive.wicket.shiro.markup.LoginPage;
import fiftyfive.wicket.shiro.markup.LogoutPage;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.authz.UnauthenticatedException;
import org.apache.shiro.authz.aop.AuthenticatedAnnotationHandler;
import org.apache.shiro.authz.aop.AuthorizingAnnotationHandler;
import org.apache.shiro.authz.aop.GuestAnnotationHandler;
import org.apache.shiro.authz.aop.PermissionAnnotationHandler;
import org.apache.shiro.authz.aop.RoleAnnotationHandler;
import org.apache.shiro.authz.aop.UserAnnotationHandler;
import org.apache.wicket.Application;
import org.apache.wicket.Component;
import org.apache.wicket.MetaDataKey;
import org.apache.wicket.Page;
import org.apache.wicket.RestartResponseAtInterceptPageException;
import org.apache.wicket.Session;
import org.apache.wicket.authorization.Action;
import org.apache.wicket.authorization.IAuthorizationStrategy;
import org.apache.wicket.authorization.IUnauthorizedComponentInstantiationListener;
import org.apache.wicket.authorization.UnauthorizedInstantiationException;
import org.apache.wicket.markup.html.pages.AccessDeniedPage;
import org.apache.wicket.protocol.http.WebApplication;
import org.apache.wicket.request.IRequestHandler;
import org.apache.wicket.request.component.IRequestableComponent;
import org.apache.wicket.request.cycle.AbstractRequestCycleListener;
import org.apache.wicket.request.cycle.IRequestCycleListener;
import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.flow.ResetResponseException;
import org.apache.wicket.request.handler.PageProvider;
import org.apache.wicket.request.handler.RenderPageRequestHandler;
import org.apache.wicket.request.handler.RenderPageRequestHandler.RedirectPolicy;
import org.apache.wicket.request.http.WebRequest;
import org.apache.wicket.request.mapper.MountedMapper;
import org.apache.wicket.request.mapper.mount.MountMapper;
import org.apache.wicket.settings.ISecuritySettings;
import org.apache.wicket.util.lang.Args;
/**
* Enhances Wicket to integrate closely with the Apache Shiro security
* framework. With the {@code ShiroWicketPlugin} installed in your Wicket
* application, you will gain the following features:
* <ul>
* <li>You can use all of Shiro's authorization annotations
* (like
* {@link org.apache.shiro.authz.annotation.RequiresAuthentication @RequiresAuthentication}
* and
* {@link org.apache.shiro.authz.annotation.RequiresPermissions @RequiresPermissions})
* on Wicket Pages. The {@code ShiroWicketPlugin} will ensure that only
* authorized users can access these pages, and will show an appropriate
* error page or login page otherwise.
* See {@link #isInstantiationAuthorized isInstantiationAuthorized()}.
* </li>
* <li>You can also use the same Shiro annotations on individual components,
* like Links and Panels. The {@code ShiroWicketPlugin} will automatically
* hide these components from unauthorized users.
* See {@link #isActionAuthorized isActionAuthorized()}.
* </li>
* <li>You can access Shiro directly at any time in your Wicket code
* by calling
* {@link org.apache.shiro.SecurityUtils#getSubject SecurityUtils.getSubject()}.
* This gives you access to the rich set of security operations on the
* Shiro {@link org.apache.shiro.subject.Subject Subject} that represents
* the current user.
* </li>
* <li>Any uncaught Shiro
* {@link AuthorizationException AuthorizationExceptions}
* will be handled gracefully by redirecting the user to the
* login page or an unauthorized page (by default, the home page).
* This allows you to implement
* comprehensive security rules using Shiro at any tier of your
* application and be confident that your UI will handle them
* appropriately.
* See {@link #onException onException()}.
* </li>
* </ul>
* <h2>Installation</h2>
* Before you can use the {@code ShiroWicketPlugin}, you must have Shiro
* properly added to your application's {@code web.xml} file. Refer to the
* <a href="package-summary.html">package summary</a> of this Javadoc
* for a brief tutorial.
* <h3>{@code Application.init()}</h3>
* Once Shiro itself is installed, adding {@code ShiroWicketPlugin} can be as
* simple as adding one line to your Wicket application {@code init()}:
* <pre class="example">
* public class MyApplication extends WebApplication
* {
* @Override
* protected void init()
* {
* super.init();
* new ShiroWicketPlugin().install(this);
* }
* }</pre>
* Most developers will want to customize the login page.
* The more complex real-world installation is thus:
* <pre class="example">
* public class MyApplication extends WebApplication
* {
* @Override
* protected void init()
* {
* super.init();
* new ShiroWicketPlugin()
* .mountLoginPage("login", MyLoginPage.class)
* .install(this);
* }
* }</pre>
*
* @author Matt Brictson
* @since 3.0
*/
public class ShiroWicketPlugin
extends AbstractRequestCycleListener
implements IAuthorizationStrategy,
IUnauthorizedComponentInstantiationListener
{
/**
* The key that will be used to obtain a localized message
* when access is denied due to the user be unauthorized;
* for example, "you are not allowed to access that page".
*/
public static final String UNAUTHORIZED_MESSAGE_KEY = "unauthorized";
/**
* The key that will be used to obtain a localized message
* when access is denied due to the user be unauthenticated;
* for example, "you need to be logged in to continue".
*/
public static final String LOGIN_REQUIRED_MESSAGE_KEY = "loginRequired";
/**
* The key that will be used to obtain a localized message
* when logout is performed; for example, "you have been logged out".
*/
public static final String LOGGED_OUT_MESSAGE_KEY = "loggedOut";
private static final MetaDataKey<AuthorizationException> EXCEPTION_KEY =
new MetaDataKey<AuthorizationException>() {};
private static final MetaDataKey<ShiroWicketPlugin> PLUGIN_KEY =
new MetaDataKey<ShiroWicketPlugin>() {};
private static final AuthorizingAnnotationHandler[] HANDLERS =
new AuthorizingAnnotationHandler[] {
new AuthenticatedAnnotationHandler(),
new GuestAnnotationHandler(),
new PermissionAnnotationHandler(),
new RoleAnnotationHandler(),
new UserAnnotationHandler()
};
/**
* Returns the {@code ShiroWicketPlugin} instance that has been installed
* in the current Wicket application. This is a convenience method that
* only works within a Wicket thread, and it assumes that
* {@link #install install()} has already been called.
*
* @throws IllegalStateException if there is no Wicket application bound
* to the current thread, or if a
* {@code ShiroWicketPlugin} has not been
* installed.
*/
public static ShiroWicketPlugin get()
{
Application app = Application.get();
if(null == app)
{
throw new IllegalStateException(
"No wicket application is bound to the current thread."
);
}
ShiroWicketPlugin plugin = app.getMetaData(PLUGIN_KEY);
if(null == plugin)
{
throw new IllegalStateException(
"A ShiroWicketPlugin has not been installed in this Wicket " +
"application. You must call ShiroWicketPlugin.install() in " +
"your application init()."
);
}
return plugin;
}
/**
* Register the specified {@code ShiroWicketPlugin} in the application so that
* {@link #get} will work. You should never need to call this method directly, unless you
* are subclassing and overriding the {@link #install} method.
*/
public static void set(Application app, ShiroWicketPlugin plugin)
{
app.setMetaData(PLUGIN_KEY, plugin);
}
private String loginPath = "login";
private String logoutPath = "logout";
private Class<? extends Page> loginPage = LoginPage.class;
private Class<? extends Page> logoutPage = LogoutPage.class;
private Class<? extends Page> unauthorizedPage = null;
private boolean unauthorizedRedirect = true;
/**
* The login page class as provided to {@link #mountLoginPage}; the default is
* {@link LoginPage}.
*/
public Class<? extends Page> getLoginPage()
{
return loginPage;
}
/**
* The logout page class as provided to {@link #mountLogoutPage}; the default is
* {@link LogoutPage}.
*/
public Class<? extends Page> getLogoutPage()
{
return logoutPage;
}
/**
* Set the bookmarkable page that will be displayed when an <em>unauthenticated</em> user
* attempts to access a page that requires authentication.
*
* @param mountPath The bookmarkable URI where the login page will be mounted when
* {@link #install install} is called. The default is {@code "/login"}.
* May be {@code null}, in which case the login page will not be mounted.
* You would want to pass {@code null}, for example, if you use your home
* page as the login page, since in that case the home page is already
* implicitly mounted on {@code "/"}.
*
* @param loginPage The page to use as the login page when the user needs to be
* authenticated. Cannot be {@code null}. The default is a simple
* out-of-the-box {@link LoginPage}.
*
* @return {@code this} to allow chaining
*/
public ShiroWicketPlugin mountLoginPage(String mountPath, Class<? extends Page> loginPage)
{
Args.notNull(loginPage, "loginPage");
this.loginPath = mountPath;
this.loginPage = loginPage;
return this;
}
/**
* Set the bookmarkable page that will be loaded to perform logout when the
* {@link fiftyfive.wicket.shiro.markup.LogoutLink LogoutLink} is clicked.
*
* @param mountPath The bookmarkable URI where the logout page will be mounted when
* {@link #install install} is called. The default is {@code "/logout"}.
*
* @param logoutPage The page to load when the user clicks the
* {@link fiftyfive.wicket.shiro.markup.LogoutLink LogoutLink}. This page
* is responsible for actually logging the user out of the Shiro system.
* Cannot be {@code null}. The default is {@link LogoutPage},
* which should be sufficient for most applications.
*
* @return {@code this} to allow chaining
*/
public ShiroWicketPlugin mountLogoutPage(String mountPath, Class<? extends Page> logoutPage)
{
Args.notNull(logoutPage, "logoutPage");
this.logoutPath = mountPath;
this.logoutPage = logoutPage;
return this;
}
/**
* The page class that was set via {@link #setUnauthorizedPage}; otherwise the
* application home page.
*/
public Class<? extends Page> getUnauthorizedPage()
{
return unauthorizedPage != null ? unauthorizedPage : Application.get().getHomePage();
}
/**
* The redirect flag that was set via {@link #setUnauthorizedPage}; {@code true} by default.
*/
public boolean getUnauthorizedRedirect()
{
return unauthorizedRedirect;
}
/**
* Set the bookmarkable page that will be displayed when an <em>authenticated</em> user
* attempts to access a page that they are not allowed to see. By default it is
* {@code null}, indicating that the application home page should be used.
*
* @param page The page to display when the user is unauthorized; if {@code null}, the
* application home page will be used
*
* @param redirect If {@code true}, a 302 redirect will be performed to display the page;
* if {@code false}, no redirect will occur and the URL will not change.
* The latter is appropriate for an error page.
*
* @return {@code this} to allow chaining
*/
public ShiroWicketPlugin setUnauthorizedPage(Class<? extends Page> page, boolean redirect)
{
this.unauthorizedPage = page;
this.unauthorizedRedirect = redirect;
return this;
}
/**
* The mount path for the login page as provided to {@link #mountLoginPage}; the default is
* {@code "login"}.
*/
public String getLoginPath()
{
return loginPath;
}
/**
* The mount path for the logout action as provided to {@link #mountLogoutPage}; the default is
* {@code "logout"}.
*/
public String getLogoutPath()
{
return logoutPath;
}
/**
* Installs this {@code ShiroWicketPlugin} by doing the following:
* <ul>
* <li>Sets itself as the {@link IAuthorizationStrategy}</li>
* <li>And as the {@link IUnauthorizedComponentInstantiationListener}</li>
* <li>And as an {@link IRequestCycleListener}</li>
* <li>Mounts the login page</li>
* <li>Mounts the logout page</li>
* </ul>
*/
public void install(WebApplication app)
{
Args.notNull(app, "app");
ISecuritySettings settings = app.getSecuritySettings();
settings.setAuthorizationStrategy(this);
settings.setUnauthorizedComponentInstantiationListener(this);
app.getRequestCycleListeners().add(this);
// Mount bookmarkable URLs
if(this.loginPath != null)
{
app.mount(new MountedMapper(this.loginPath, this.loginPage));
}
if(this.logoutPath != null)
{
app.mount(new MountedMapper(this.logoutPath, this.logoutPage));
}
// Install self in app metadata so that static get() can work
ShiroWicketPlugin.set(app, this);
}
// Start feedback message callbacks --------------------------------------
/**
* Called by {@link LogoutPage} once the user has been logged out.
* The default implementation adds a feedback message to the session that says
* "you have been logged out". To override or localize this message,
* define {@code loggedOut} in your application properties. You can disable the
* message entirely by defining {@code loggedOut} as an empty string.
*/
public void onLoggedOut()
{
String message = getLocalizedMessage(LOGGED_OUT_MESSAGE_KEY, "You have been logged out.");
if(message != null && !message.matches("^\\s*$"))
{
// Invalidate current session and create a new one.
// We need a new session because otherwise our feedback message won't "stick".
Session session = Session.get();
session.replaceSession();
// Add localized "you have been logged out" message to session
session.info(message);
}
}
/**
* Invoked by {@code ShiroWicketPlugin} when an anonymous or remembered user has tried to
* access a page that requires authentication. The default implementation places a
* "you need to be logged in to continue" feedback message in the session.
* To override or localize this message,
* define {@code loginRequired} in your application properties. You can disable the
* message entirely by defining {@code loginRequired} as an empty string.
*/
public void onLoginRequired()
{
String message = getLocalizedMessage(
LOGIN_REQUIRED_MESSAGE_KEY,
"You need to be logged in to continue.");
if(message != null && !message.matches("^\\s*$"))
{
// We need a new session because otherwise our feedback message won't "stick".
Session session = Session.get();
session.bind();
// Add localized "you have been logged out" message to session
session.info(message);
}
}
/**
* Invoked by {@code ShiroWicketPlugin} when the user has tried to access a page
* but lacks the necessary role or permission. The default implementation places a
* "sorry, you are not allowed to access that page" feedback message in the session.
* To override or localize this message,
* define {@code unauthorized} in your application properties. You can disable the
* message entirely by defining {@code unauthorized} as an empty string.
*/
public void onUnauthorized()
{
String message = getLocalizedMessage(
UNAUTHORIZED_MESSAGE_KEY,
"Sorry, you are not allowed to access that page.");
if(message != null && !message.matches("^\\s*$"))
{
// We need a new session because otherwise our feedback message won't "stick".
Session session = Session.get();
session.bind();
// Add localized "sorry, you are not allowed to access that page" message to session
session.error(message);
}
}
// End feedback message callbacks ----------------------------------------
// Start IRequestCycleListener methods -----------------------------------
/**
* React to an uncaught Exception by redirecting the browser to
* the unauthorized page or login page if appropriate. This method will automatically be
* called by Wicket if this plugin was installed by the standard {@link #install install()}
* mechanism, via the {@link IRequestCycleListener} system.
* This allows uncaught Shiro exceptions thrown by the backend to be
* handled gracefully by the Wicket layer.
* <p>
* If the exception is a Shiro {@link AuthorizationException}, redirect
* to the unauthorized page or login page depending on the type of error.
* If the exception is not a Shiro {@link AuthorizationException}
* return {@code null}.
*
* @param cycle The current request cycle, as provided by Wicket.
*
* @param error The exception to handle. If it is not a subclass of
* Shiro's {@link AuthorizationException}, this method will
* not have any effect.
*
* @return A {@link RenderPageRequestHandler} redirect to the login
* page if the error is due to the user being
* <em>unauthenticated</em>;
* {@link RenderPageRequestHandler} to render the unauthorized page
* if the error is due to the user being
* <em>unauthorized</em>.
*/
@Override
public IRequestHandler onException(RequestCycle cycle, Exception error)
{
Class<? extends Page> respondWithPage = null;
RedirectPolicy redirectPolicy = RedirectPolicy.NEVER_REDIRECT;
if(error instanceof AuthorizationException)
{
AuthorizationException ae = (AuthorizationException) error;
if(authenticationNeeded(ae))
{
if(loginPage != null)
{
onLoginRequired();
// Create a RestartResponseAtInterceptPageException to set the intercept,
// even though we don't throw the exception. (The magic happens in the
// RestartResponseAtInterceptPageException constructor.)
new RestartResponseAtInterceptPageException(loginPage);
respondWithPage = loginPage;
redirectPolicy = RedirectPolicy.ALWAYS_REDIRECT;
}
}
else
{
onUnauthorized();
if(this.unauthorizedRedirect || (
cycle.getRequest() instanceof WebRequest &&
((WebRequest) cycle.getRequest()).isAjax()))
{
redirectPolicy = RedirectPolicy.ALWAYS_REDIRECT;
}
respondWithPage = getUnauthorizedPage();
}
}
if(respondWithPage != null)
{
return new RenderPageRequestHandler(new PageProvider(respondWithPage), redirectPolicy);
}
return null;
}
// End IRequestCycleListener methods -------------------------------------
// Start IUnauthorizedComponentInstantiationListener methods -------------
/**
* Determine what caused the unauthorized instantiation of the given
* component. If access was denied due to being unauthenticated, and
* the login page specified in the constructor was not {@code null},
* call {@link #onLoginRequired} and redirect to the login page.
* <p>
* Otherwise, access was denied due to authorization failure (e.g. insufficient privileges),
* call {@link #onUnauthorized} and render the unauthorized page (which is the home page by
* default).
*
* @param component The component that failed to initialize due to
* authorization or authentication failure
*
* @throws {@link ResetResponseException} to render the login page or unauthorized page
*
* @throws UnauthorizedInstantiationException the login page
* has not been configured (i.e. is {@code null})
*/
public void onUnauthorizedInstantiation(Component component)
{
AuthorizationException cause;
RequestCycle rc = RequestCycle.get();
cause = rc.getMetaData(EXCEPTION_KEY);
// Show appropriate login or error page if possible
IRequestHandler handler = onException(rc, cause);
if(handler != null)
{
throw new ResetResponseException(handler) {};
}
// Otherwise bubble up the error
UnauthorizedInstantiationException ex;
ex = new UnauthorizedInstantiationException(component.getClass());
ex.initCause(cause);
throw ex;
}
// End IUnauthorizedComponentInstantiationListener methods ---------------
// Start IAuthorizationStrategy methods ----------------------------------
/**
* Performs authorization checks for the {@link Component#RENDER RENDER}
* action only. Other actions are always allowed.
* <p>
* If the action is {@code RENDER}, the component class <em>and its
* superclasses</em> are checked for the presence of
* {@link org.apache.shiro.authz.annotation Shiro annotations}.
* <p>
* The absence of any Shiro annotation means that the component may be
* rendered, and {@code true} is returned. Otherwise, each annotation is
* evaluated against the current Shiro Subject. If any of the requirements
* dictated by the annotations fail, {@code false} is returned and
* rendering for the component will be skipped.
* <p>
* For example, this link will be hidden if the user is already
* authenticated:
* <pre class="example">
* @RequiresGuest
* public class LoginLink extends StatelessLink
* {
* ...
* }</pre>
*/
public boolean isActionAuthorized(Component component, Action action)
{
if(Component.RENDER.equals(action))
{
try
{
assertAuthorized(component.getClass());
}
catch(AuthorizationException ae)
{
return false;
}
}
return true;
}
/**
* If {@code componentClass} is a subclass of {@link Page},
* return {@code true} or {@code false} based on evaluation of any
* {@link org.apache.shiro.authz.annotation Shiro annotations}
* that are present on the page class declaration, <em>plus any annotations
* present on its superclasses</em>.
* <p>
* The absence of any Shiro annotation means that the page can always be
* instantiated, meaning {@code true} will always be returned. Otherwise,
* each annotation is evaluated against the current Shiro Subject. If any
* of the requirements dictated by the annotations fail, {@code false} will
* be returned and instantiation will be denied.
* <p>
* For example, this page may only be instantiated if the user has
* explictly authenticated (i.e. not just "remembered" via cookie) and
* additionally has the "admin" role:
* <pre class="example">
* @RequiresAuthentication
* @RequiresRoles("admin")
* public class TopSecretPage extends WebPage
* {
* ...
* }</pre>
* If {@code componentClass} is not a subclass of Page, always return
* {@code true}. Non-page components may always be instantiated; however
* their rendering can be controlled via annotations. See
* {@link #isActionAuthorized isActionAuthorized()}.
*/
public <T extends IRequestableComponent> boolean isInstantiationAuthorized(
Class<T> componentClass)
{
if(Page.class.isAssignableFrom(componentClass))
{
try
{
assertAuthorized(componentClass);
}
catch(AuthorizationException ae)
{
// Store exception for use later in the request by onUnauthorizedInstantiation()
RequestCycle.get().setMetaData(EXCEPTION_KEY, ae);
return false;
}
}
return true;
}
// End IAuthorizationStrategy methods ------------------------------------
/**
* Looks up a localized string from the application properties.
*/
protected String getLocalizedMessage(String key, String theDefault)
{
return Application.get().getResourceSettings().getLocalizer().getString(
key,
null,
null,
theDefault);
}
/**
* Returns {@code true} if the reason the user was denied access is
* because she needs to authenticate.
*/
protected boolean authenticationNeeded(AuthorizationException cause)
{
// IMPLEMENTATION NOTE - A simple solution would be:
// return ! SecurityUtils.getSubject().isAuthenticated();
// In other words, the user needs to authenticate if she is not
// already logged in (this is how it is done in Wicket auth-roles).
// However this does not take into account the subtle difference in
// Shiro between "authenticated" and "remembered" states. To ensure the
// correct behavior we have to inspect the actual exception to see what
// action to take.
boolean needLogin = false;
// Check if Shiro blocked access due to authentication
if(cause instanceof UnauthenticatedException)
{
needLogin = true;
// But... there is a rare case where Shiro can throw an
// UnauthenticatedException even when the user is already logged
// in. If the user is logged in and the page was annotated with
// @RequiresGuest, Shiro throws an UnauthenticatedException, which
// which is very misleading. Our only way to detect this scenario
// is to parse the exception message. Yes, this is a hack.
String msg = cause.getMessage();
String guestError = "Attempting to perform a guest-only operation.";
if(msg != null && msg.startsWith(guestError))
{
needLogin = false;
}
}
return needLogin;
}
/**
* @throws AuthorizationException if the given class, or any of its
* superclasses, has a Shiro annotation that fails its
* authorization check.
*/
private void assertAuthorized(final Class<?> cls)
throws AuthorizationException
{
Collection<Annotation> annotations = findAnnotations(cls);
for(Annotation annot : annotations)
{
for(AuthorizingAnnotationHandler h : HANDLERS)
{
h.assertAuthorized(annot);
}
}
}
/**
* Returns all annotations present on the given class and all of its
* superclasses.
*/
private Collection<Annotation> findAnnotations(final Class<?> cls)
{
List<Annotation> annots = new ArrayList<Annotation>(5);
Class<?> currClass = cls;
while(currClass != null)
{
annots.addAll(Arrays.asList(currClass.getDeclaredAnnotations()));
currClass = currClass.getSuperclass();
}
return annots;
}
}