Monday, 13 July 2009

Access Control in JSF using a PhaseListener

After doing a quick search of the web, I did not find any nice solutions to implementing access control in JSF. Using a servlet filter mapping seemed inadequate, and there wasn't any obvious place to start.

I initially tried using a custom NavigationHandler, however that is only used after an action is performed (e.g. #{someBean.action}), and not for directly accessing a URL (e.g. typing in /test.faces). Some more searching revealed that a PhaseListener was the place to do it. After checking the JSF lifecycles I determined that RESTORE_VIEW was the correct place to do it - ALL pages go through at least the RESTORE_VIEW and RENDER_VIEW phases.
You can check the viewId in the afterPhase (as the view has been loaded in this phase, hence can't check in beforePhase) and redirect using the navigation handler as nesecary.

Below is my implementation of it. I used a flexible inclusion/exclusion filter so I can make the rules as complex as I want. This implementation determines the highest level required for each URL and checks the current security level and redirects accordingly.
Alternatively, you could check each level inidividually - starting with LOGGED_IN, checking if the user is logged in and working up.
package devgrok.jsf;

import static devgrok.jsf.AccessControlPhaseListener.AccessLevel.ADMIN;
import static devgrok.jsf.AccessControlPhaseListener.AccessLevel.LOGGED_IN;
import static devgrok.jsf.AccessControlPhaseListener.AccessLevel.NONE;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

import javax.faces.application.FacesMessage;
import javax.faces.context.FacesContext;
import javax.faces.event.PhaseEvent;
import javax.faces.event.PhaseId;
import javax.faces.event.PhaseListener;
import javax.servlet.http.HttpSession;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.sun.faces.util.MessageFactory;

import devgrok.jsf.SessionForm;
import devgrok.jsf.UrlFilter;

/**
 * Phase Listener that checks the viewId (URL) against a set of filters to determine the required access level. If the
 * correct level is not there then redirect.
 * 
 * See {@link UrlFilter} for details on the url matching.
 * 
 * @author Chris Watts 2009
 * 
 */
public class AccessControlPhaseListener implements PhaseListener
{
  /** Logger for this class */
  private static final Logger log = LoggerFactory.getLogger(AccessControlPhaseListener.class);

  /** */
  private static final long serialVersionUID = 1L;
  private final static String SESSION_BEAN = "sessionBean";
  private final HashMap<AccessLevel, List<UrlFilter>> levelFilters = new HashMap<AccessLevel, List<UrlFilter>>();

  public enum AccessLevel
  {
    NONE, LOGGED_IN, USER_ACTIVE, ADMIN;
  }

  /**
    * 
    */
  public AccessControlPhaseListener()
  {
    initLevels();
    
    requires(LOGGED_IN)
      .include("*")
      .exclude("/index.xhtml")
      .exclude("/login.xhtml")
      .exclude("/user/newUser.xhtml");

    requires(USER_ACTIVE)
      .include("/user/*")
      .exclude("/user/newUser.xhtml");

    requires(ADMIN)
      .include("/admin/*");
  }

  private void initLevels()
  {
    AccessLevel[] levels = AccessLevel.values();
    for (int i = 1; i < levels.length; i++)
    {
      levelFilters.put(levels[i], new ArrayList<UrlFilter>());
    }
  }

  private UrlFilter requires(AccessLevel level)
  {
    //ALL is default
    if (level == NONE)
      return null;

    UrlFilter filter = new UrlFilter();
    List<UrlFilter> list = levelFilters.get(level);
    list.add(filter);
    return filter;
  }

  /*
   * (non-Javadoc)
   * 
   * @see javax.faces.event.PhaseListener#afterPhase(javax.faces.event.PhaseEvent)
   */
  public void afterPhase(PhaseEvent event)
  {
    try
    {
      //check have correct access
      FacesContext context = event.getFacesContext();
      HttpSession session = (HttpSession) context.getExternalContext().getSession(true);
      SessionForm sessionBean = (SessionForm) session.getAttribute(SESSION_BEAN);
      if (sessionBean == null)
      {
        log.error("Could not obtain instance of sessionBean");
        return;
      }

      //can't use this here. only valid at render response phase?
      String viewId = context.getViewRoot().getViewId();
      AccessLevel required = requiredLevel(viewId);
      log.debug("Required level={} for viewId={}", required, viewId);

      //check if page require access:
      switch (required) {
      case NONE:
        break;
      case LOGGED_IN:
        if (!sessionBean.isLoggedIn())
          redirectLogin(event.getFacesContext(), sessionBean);
        break;
      case USER_ACTIVE:
        if (!sessionBean.isActive())
          redirectActive(event.getFacesContext());
        break;
      case ADMIN:
        if (!sessionBean.isAdmin())
          redirectAdmin(event.getFacesContext());
        break;
      default:
        //error
        log.error("huh?");
        throw new IllegalArgumentException("Not a valid access level");
      }
    }
    catch (Exception e)
    {
      // TODO Auto-generated catch block
      log.error("beforePhase caught exception", e);
    }

  }

  /*
   * (non-Javadoc)
   * 
   * @see javax.faces.event.PhaseListener#beforePhase(javax.faces.event.PhaseEvent)
   */
  public void beforePhase(PhaseEvent event)
  {

  }

  private void redirectLogin(FacesContext context, SessionForm sessionForm)
  {
    //trigger login popup to be shown on render.
    sessionForm.logIn();
    addError(context, "access.loginrequired");
    context.getApplication().getNavigationHandler().handleNavigation(context, null, "index");
  }

  private void redirectActive(FacesContext context)
  {
    addError(context, "access.activerequired");
    context.getApplication().getNavigationHandler().handleNavigation(context, null, "userActivate");
  }

  private void redirectAdmin(FacesContext context)
  {
    addError(context, "access.adminrequired");
    context.getApplication().getNavigationHandler().handleNavigation(context, null, "home");
  }

  /**
   * Add keyed error/message.
   * 
   * @param level
   * @param key
   *           message key
   */
  private void addError(FacesContext context, String key)
  {
    FacesMessage fMessage = MessageFactory.getMessage(key);
    if (fMessage != null)
    {
      FacesContext facesContext = FacesContext.getCurrentInstance();
      fMessage.setSeverity(FacesMessage.SEVERITY_ERROR);
      facesContext.addMessage(null, fMessage);
    }
  }

  /**
   * Checks defined filters for view id, checks starting at the highest level down to NONE.
   * 
   * @return the matching level or {@link AccessLevel#NONE} if none matching.
   */
  private AccessLevel requiredLevel(String viewId)
  {
    AccessLevel[] levels = AccessLevel.values();
    for (int i = levels.length - 1; i > 0; i--)
    {
      if (checkLevel(levels[i], viewId))
        return levels[i];
    }

    return AccessLevel.NONE;
  }

  private boolean checkLevel(AccessLevel level, String viewId)
  {
    return matchUri(levelFilters.get(level), viewId);
  }

  private boolean matchUri(List<UrlFilter> list, String uri)
  {
    for (UrlFilter filter : list)
    {
      if (filter.matches(uri))
        return true;
    }
    return false;
  }

  /*
   * (non-Javadoc)
   * 
   * @see javax.faces.event.PhaseListener#getPhaseId()
   */
  public PhaseId getPhaseId()
  {
    //ALL access go through RESTORE_VIEW and RENDER_VIEW (even direct url)
    return PhaseId.RESTORE_VIEW;
  }

}
package devgrok.jsf;

import java.util.ArrayList;
import java.util.regex.Pattern;

/**
 * An inclusion/exclusion filterset, similar to ant's fileset but does not support directories in the same style(**,
 * etc).
 * 
 * For example:
 * <ul>
 * <li>/servlet/* matches all urls starting with "/servlet/" e.g. /servlet/this.html
 * <li>*.do matches all urls that end in ".do" - e.g. mypage.do
 * <li>/servlet/*.do matches all urls starting with "/servlet/" and end in ".do"  - e.g. /servlet/mypage.do
 * </ul>
 * 
 * @author Chris Watts 2009
 * 
 */
public class UrlFilter
{
  private ArrayList<Pattern> include = new ArrayList<Pattern>();
  private ArrayList<Pattern> exclude = new ArrayList<Pattern>();

  public UrlFilter()
  {

  }

  /**
   * Include the wildcard(*) built pattern.
   * 
   * @param pattern
   * @return
   */
  public UrlFilter include(String pattern)
  {
    include.add(generateExpression(pattern));
    return this;
  }

  /**
   * Exclude the wildcard(*) built pattern.
   * 
   * @param pattern
   * @return
   */
  public UrlFilter exclude(String pattern)
  {
    exclude.add(generateExpression(pattern));
    return this;
  }

  /**
   * Checks to see if uri matches at least ONE inclusion filter and doesn't match ANY exclusion filters.
   * 
   * @param uri
   * @return
   */
  public boolean matches(String uri)
  {
    boolean match = false;

    //check inclusions
    for (Pattern pattern : include)
    {
      match = match || pattern.matcher(uri).matches();
    }

    if (!match)
      return false;

    //check exclusions
    for (Pattern pattern : exclude)
    {
      match = match && !pattern.matcher(uri).matches();
    }
    return match;
  }
  
  /** regular expression special character */
  private static char[] specialChars = { '[', '\\', '^', '$', '.', '|', '?', '*', '+', '(', ')' };

  /**
   * 
   * @param input
   * @return
   */
  private static Pattern generateExpression(String input)
  {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < input.length(); i++)
    {
      char letter = input.charAt(i);
      if (letter == '*')
      {
        sb.append(".*");
      }
      else if (contains(specialChars, letter))
      {
        sb.append("\\" + letter);
      }
      else
      {
        sb.append(letter);
      }
    }
    return Pattern.compile(sb.toString());
  }

  private static boolean contains(char[] array, char value)
  {
    if (array == null || array.length == 0)
    {
      return false;
    }

    for (int i = 0; i < array.length; i++)
    {
      char o = array[i];
      if (o == value)
      {
        return true;
      }
    }

    return false;
  }
}

Update 2010/10/21: Here is a zip of the source

6 comments:

  1. where is evgrok.jsf.SessionForm ?

    ReplyDelete
  2. SessionForm is just a managed bean providing information about the current user's session. It can be whatever it is required for your application.
    I've attached the source of the empty class for you to get a better idea.

    ReplyDelete
  3. Thank you for your response, but I have one more...
    I hava a Login Form, a Register Form and a Private Form, the private form is supossed to be only accessible for a logged user, also I have a bean for the Login Form, the question is this class have this bean something to do with the SessionForm you attached in the zip?

    ReplyDelete
  4. (To simplify & avoid confusion, lets call my SessionForm, SessionBean instead).

    In our case, the bean for the Login Form was our SessionBean. Depending on the scoping of your bean you have 2 choices:
    1. Use your LoginBean in place of my SessionBean, backing your login form and keeping track of the user's session.
    2. If your LoginBean is request scoped then inject (or access using EL) the SessionBean, updating it upon successful login (and when logging out).

    Those are just some examples.

    ReplyDelete
  5. Nice code Thank you very match, now i can secure my application

    ReplyDelete
  6. I have tried to make a more generic version of your code/idea.
    I have posted it here

    http://stackoverflow.com/questions/8330535/access-control-in-jsf-using-a-phaselistener-a-generic-version-what-do-think

    What do you think?

    ReplyDelete