Index: stripes/src/net/sourceforge/stripes/action/TicketRequired.java =================================================================== --- stripes/src/net/sourceforge/stripes/action/TicketRequired.java (revision 0) +++ stripes/src/net/sourceforge/stripes/action/TicketRequired.java (revision 0) @@ -0,0 +1,32 @@ +package net.sourceforge.stripes.action; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Method-level annotation Indicating that the user must possess a valid + * transaction ticket in order for the event to execute. The "ticket" is a + * parameter generated by the FormTag that contains a unique identifier, and is + * associated to the event handler method containing the annotation. + * + * @author Andrew Jaquith + */ +@Retention(RetentionPolicy.RUNTIME) +@Target( { ElementType.METHOD }) +@Documented +@Inherited +public @interface TicketRequired { + /** + * Returns the number of seconds before the ticket expires, in seconds. If + * this attribute is not specified, the ticket will expire by default in 1 + * hour (720 seconds). + * + * @return the timeout for the ticket, in seconds + */ + int expires() default 720; + +} Index: stripes/src/net/sourceforge/stripes/config/DefaultConfiguration.java =================================================================== --- stripes/src/net/sourceforge/stripes/config/DefaultConfiguration.java (revision 1114) +++ stripes/src/net/sourceforge/stripes/config/DefaultConfiguration.java (working copy) @@ -37,6 +37,7 @@ import net.sourceforge.stripes.controller.LifecycleStage; import net.sourceforge.stripes.controller.NameBasedActionResolver; import net.sourceforge.stripes.controller.ObjectFactory; +import net.sourceforge.stripes.controller.TicketInspector; import net.sourceforge.stripes.controller.multipart.DefaultMultipartWrapperFactory; import net.sourceforge.stripes.controller.multipart.MultipartWrapperFactory; import net.sourceforge.stripes.exception.DefaultExceptionHandler; @@ -491,6 +492,7 @@ Map> interceptors = new HashMap>(); addInterceptor(interceptors, new BeforeAfterMethodInterceptor()); addInterceptor(interceptors, new HttpCacheInterceptor()); + addInterceptor(interceptors, new TicketInspector()); return interceptors; } Index: stripes/src/net/sourceforge/stripes/controller/InvalidTicketException.java =================================================================== --- stripes/src/net/sourceforge/stripes/controller/InvalidTicketException.java (revision 0) +++ stripes/src/net/sourceforge/stripes/controller/InvalidTicketException.java (revision 0) @@ -0,0 +1,24 @@ +package net.sourceforge.stripes.controller; + +/** + * Indicates that the user attempted to access an event handler method + * protected by a {@link net.sourceforge.stripes.action.TicketRequired} + * annotation, but that the correct ticket parameter was not submitted + * with the request. + * @author Andrew Jaquith + * + */ +public class InvalidTicketException extends Exception { + + private static final long serialVersionUID = -987484091709542279L; + + /** + * Constructs a new InvalidTicketException with a supplied message. + * @param message the message + */ + public InvalidTicketException( String message ) + { + super( message ); + } + +} Index: stripes/src/net/sourceforge/stripes/controller/StripesConstants.java =================================================================== --- stripes/src/net/sourceforge/stripes/controller/StripesConstants.java (revision 1114) +++ stripes/src/net/sourceforge/stripes/controller/StripesConstants.java (working copy) @@ -50,6 +50,13 @@ String URL_KEY_FLASH_SCOPE_ID = "__fsk"; /** + * The name of a URL parameter that holds {@link net.sourceforge.stripes.controller.Ticket} + * values, as generated by {@link net.sourceforge.stripes.tag.FormTag} and evaluated by + * {@link TicketInspector}. + */ + String URL_KEY_TICKET = "_ticket"; + + /** * An immutable set of URL keys or request parameters that have special meaning to Stripes and * as a result should not be referenced in binding, validation or other other places that * work on the full set of request parameters. @@ -58,7 +65,8 @@ Literal.set(StripesConstants.URL_KEY_SOURCE_PAGE, StripesConstants.URL_KEY_FIELDS_PRESENT, StripesConstants.URL_KEY_FLASH_SCOPE_ID, - StripesConstants.URL_KEY_EVENT_NAME)); + StripesConstants.URL_KEY_EVENT_NAME, + StripesConstants.URL_KEY_TICKET)); /** * The name under which the ActionBean for a request is stored as a request attribute before * forwarding to the JSP. @@ -85,6 +93,12 @@ String REQ_ATTR_TAG_STACK = "__stripes_tag_stack"; /** + * The attribute key that is used to store the Set of tickets stored for the user's + * HttpSession. + */ + String REQ_ATTR_TICKETS = "__stripes_tickets"; + + /** * The name of a request parameter that holds a Map of flash scopes keyed by the * hash code of the request that generated them. */ Index: stripes/src/net/sourceforge/stripes/controller/Ticket.java =================================================================== --- stripes/src/net/sourceforge/stripes/controller/Ticket.java (revision 0) +++ stripes/src/net/sourceforge/stripes/controller/Ticket.java (revision 0) @@ -0,0 +1,85 @@ +package net.sourceforge.stripes.controller; + +import java.util.Set; + +import net.sourceforge.stripes.action.ActionBean; +import net.sourceforge.stripes.util.CryptoUtil; + +/** + * Transaction "ticket" generated by the + * {@link net.sourceforge.stripes.tag.FormTag} and stashed in the user's + * session, where it is later checked for by the {@link TicketInspector}. + * Tickets are meant to be single-use, and expire after a defined interval. + * Tickets are valid for a target ActionBean class and handler method, which is + * also set during construction. + * + * @author Andrew Jaquith + * + */ +public class Ticket { + + private final Class beanclass; + + private final String[] handlers; + + private final long creation; + + private final String ticket; + + /** + * Creates a new Ticket, valid for an ActionBean and array of handler + * methods. + * + * @param beanclass + * the ActionBean class containing the method the Ticket can be + * used with + * @param handlers + * the handler methods the Ticket can be used with + */ + public Ticket(Class beanclass, Set handlers) { + this.beanclass = beanclass; + this.handlers = handlers.toArray(new String[handlers.size()]); + this.creation = System.currentTimeMillis(); + this.ticket = CryptoUtil.encrypt(String.valueOf(creation)); + } + + /** + * Returns the creation time stamp, in milliseconds. + * + * @return the creation time + */ + public long creationTime() { + return creation; + } + + /** + * Returns the ticket value, which is simply the encoded value of the + * expiration time, after being encrypted by + * {@link CryptoUtil#encrypt(String)}. The actual value of the ticket does + * not matter; it merely needs to be unique. + * + * @return the ticket value + */ + public String value() { + return ticket; + } + + /** + * Returns the handler methods that the Ticket is valid for. + * + * @return the handler method name + */ + public String[] getHandlers() { + return handlers; + } + + /** + * Returns the ActionBean class the Ticket is valid for. + * + * @return the bean class + */ + public Class getBeanClass() { + return beanclass; + } + +} Index: stripes/src/net/sourceforge/stripes/controller/TicketInspector.java =================================================================== --- stripes/src/net/sourceforge/stripes/controller/TicketInspector.java (revision 0) +++ stripes/src/net/sourceforge/stripes/controller/TicketInspector.java (revision 0) @@ -0,0 +1,92 @@ +package net.sourceforge.stripes.controller; + +import java.lang.reflect.Method; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +import net.sourceforge.stripes.action.ActionBeanContext; +import net.sourceforge.stripes.action.Resolution; +import net.sourceforge.stripes.action.TicketRequired; +import net.sourceforge.stripes.util.ConcurrentHashSet; + +/** + * {@link Interceptor} that determines whether an ActionBean possesses the + * correct transaction ticket, if the resolved event handler method requires one + * via an {@link net.sourceforge.stripes.action.TicketRequired} annotation. + * + * @author Andrew Jaquith + * + */ +@Intercepts( { LifecycleStage.HandlerResolution }) +public class TicketInspector implements Interceptor { + + /** + * Inspects the resolved ActionBean handler method to determine whether a + * transaction {@link Ticket} is required, and if so, checks if the user + * possesses it. If not, an {@link InvalidTicketException} is thrown. + * + * @throws InvalidTicketException + * if the user does not possess the correct ticket + */ + @SuppressWarnings("unchecked") + public Resolution intercept(ExecutionContext context) throws Exception { + + // Execute all other interceptors first + context.proceed(); + + // Is a ticket required to execute the event handler? + Method method = context.getHandler(); + boolean ticketRequired = method.isAnnotationPresent(TicketRequired.class); + + // If ticket required, expiration time is as annotated, or default of 30 mins + long expiry = 1000 * (ticketRequired ? method.getAnnotation(TicketRequired.class).expires() : 1800); + + // Rip through stashed tickets and see if we find one that matches, + // pruning any expired tickets as we go + ActionBeanContext abc = context.getActionBeanContext(); + HttpServletRequest request = abc.getRequest(); + HttpSession session = request.getSession(); + ConcurrentHashSet stashedTickets = (ConcurrentHashSet) session + .getAttribute(StripesConstants.REQ_ATTR_TICKETS); + String[] userTickets = request + .getParameterValues(StripesConstants.URL_KEY_TICKET); + boolean hasValidTicket = false; + + if (stashedTickets != null) { + for (Ticket ticket : stashedTickets) { + // Prune any expired tickets + if (ticket.creationTime() + expiry < System.currentTimeMillis()) { + stashedTickets.remove(ticket); + } + + // If the ticket matches, remove it + else if (ticketRequired && !hasValidTicket) { + String ticketValue = ticket.value(); + for (String userTicket : userTickets) { + if (ticketValue.equals(userTicket)) { + hasValidTicket = true; + stashedTickets.remove(ticket); + break; + } + } + } + } + } + + // If user was supposed to have the ticket but didn't, throw exception + if (ticketRequired && !hasValidTicket) { + throw new InvalidTicketException( + "Here's the deal. The event handler method requires the form POST or GET " + + "to contain a parameter with the valid, single-use ticket ID in it, which should " + + "have been set by a previous . You don't have it, which means " + + "you are either a naughty evil-doer, or are perhaps submitting the form" + + " in a unit test. Either way, this is the end of the transaction. That is all."); + } + + // Always return nothing + return null; + + } + +} Index: stripes/src/net/sourceforge/stripes/tag/FormTag.java =================================================================== --- stripes/src/net/sourceforge/stripes/tag/FormTag.java (revision 1114) +++ stripes/src/net/sourceforge/stripes/tag/FormTag.java (working copy) @@ -19,7 +19,9 @@ import net.sourceforge.stripes.controller.StripesConstants; import net.sourceforge.stripes.controller.StripesFilter; import net.sourceforge.stripes.controller.ActionResolver; +import net.sourceforge.stripes.controller.Ticket; import net.sourceforge.stripes.exception.StripesJspException; +import net.sourceforge.stripes.util.ConcurrentHashSet; import net.sourceforge.stripes.util.CryptoUtil; import net.sourceforge.stripes.util.HtmlUtil; import net.sourceforge.stripes.util.HttpUtil; @@ -258,6 +260,9 @@ HttpServletRequest request = (HttpServletRequest) getPageContext().getRequest(); out.write(CryptoUtil.encrypt(HttpUtil.getRequestedServletPath(request))); out.write("\" />"); + + // Write out the hidden ticket field if the target ActionBean handler method requires one + writeTicketField(); if (isWizard()) { writeWizardFields(); @@ -394,6 +399,50 @@ return clazz.getAnnotation(Wizard.class) != null; } + + /** + * Writes out the hidden field for this form's transaction ticket, + * if the target ActionBean handler method possesses a + * {@link net.sourceforge.stripes.action.TicketRequired} annotation. + * Note that the hidden field is written only if the submit location + * resolves to an ActionBean. + * @throws JspException + */ + @SuppressWarnings("unchecked") + protected void writeTicketField() throws IOException { + JspWriter out = getPageContext().getOut(); + + // If form submit location doesn't resolve to an ActionBean, exit silently + if ( actionBeanClass == null ) { + return; + } + + // Find all of the submit tags, and extract the name fields (==event names) + Set events = new HashSet(); + for ( Map.Entry> field : fieldsPresent.entrySet() ) { + if (InputSubmitTag.class.equals(field.getValue())) { + events.add(field.getKey() ); + } + } + + //Write out a hidden field with the transaction ticket value in it + HttpServletRequest request = (HttpServletRequest)this.pageContext.getRequest(); + ConcurrentHashSet tickets = + (ConcurrentHashSet) request.getSession().getAttribute(StripesConstants.REQ_ATTR_TICKETS); + if ( tickets == null ) + { + tickets = new ConcurrentHashSet(); + request.getSession().setAttribute(StripesConstants.REQ_ATTR_TICKETS, tickets); + } + out.write("
"); + out.write(""); + tickets.add(ticket); + } /** * Writes out hidden fields for all fields that are present in the request but are not