001    package hirondelle.web4j.security;
002    
003    import static hirondelle.web4j.util.Consts.NOT_FOUND;
004    import hirondelle.web4j.action.Operation;
005    import hirondelle.web4j.request.RequestParser;
006    import hirondelle.web4j.util.Util;
007    import hirondelle.web4j.util.WebUtil;
008    import java.util.ArrayList;
009    import java.util.LinkedHashMap;
010    import java.util.List;
011    import java.util.Map;
012    import java.util.StringTokenizer;
013    import java.util.logging.Logger;
014    import javax.servlet.ServletConfig;
015    
016    /**
017     Default implementation of {@link UntrustedProxyForUserId}.
018    
019     <P>This implementation depends on settings in <tt>web.xml</tt>, which are read in by {@link #init(ServletConfig)}.
020      Later, each request URL is parsed by {@link #usesUntrustedIdentifier(RequestParser)}, 
021      and an attempt is made to find a match to the aforementioned settings in <tt>web.xml</tt>.
022    */
023    public final class UntrustedProxyForUserIdImpl implements UntrustedProxyForUserId {
024      
025      /**
026        Read in the values of an optional <tt>init-param</tt> in <tt>web.xml</tt> named <tt>UntrustedProxyForUserId</tt>.
027        
028      <P>This class uses settings in <tt>web.xml</tt> to define requests having ownership constraints that use an untrusted proxy 
029      for the user id. It uses a <i>roughly</i> similar style as used for role-based constraints.
030      Here is an example of a number of several such ownership constraints defined in <tt>web.xml</tt>:<PRE>&lt;init-param&gt;
031      &lt;description&gt;
032        Operations having an ownership constraint that uses an untrusted identifier. 
033      &lt;/description&gt;
034      &lt;param-name&gt;UntrustedProxyForUserId&lt;/param-name&gt;
035      &lt;param-value&gt;
036        FoodAction.*
037        VacationAction.add
038        VacationAction.delete
039      &lt;/param-value&gt;
040    &lt;/init-param&gt;
041    </PRE>
042    
043      <P>Each line is treated as a separate constraint, one per line. You can define as many as required.
044      The period character separates the 'noun' (the Action) from the 'verb' (the {@link Operation}).
045     
046      <P>The special '*' character refers to all verbs/operations attached to a given noun/action.
047      */
048      public static void init(ServletConfig aConfig){
049        String rawValue = aConfig.getInitParameter(INIT_PARAM_NAME);
050        if( Util.textHasContent(rawValue) ) {
051          parseSettings(rawValue);
052          fLogger.fine(Util.logOnePerLine(fRestrictedOperations));
053        }
054        else {
055          fLogger.config("No ownership constraints have been set in web.xml. No init-param named " + Util.quote(INIT_PARAM_NAME));
056        }
057      }
058    
059      /** 
060        Return <tt>true</tt> only if the given request matches one of the items defined by the <tt>UntrustedProxyForUserId</tt> setting 
061        in <tt>web.xml</tt>. 
062        
063        <P>For example, given the URL : 
064        <PRE>'.../VacationAction.list?X=Y'</PRE>
065        this method will parse the URL into a 'noun' and a 'verb' :
066     <PRE>noun: 'VacationAction'
067    verb: 'list'</PRE>
068    
069        It will then compare the noun-and-verb to the settings defined in <tt>web.xml</tt> (see {@link #init(ServletConfig)}). 
070        If there is a match, then this method returns <tt>true</tt>.
071      */
072      public boolean usesUntrustedIdentifier(RequestParser aRequestParser) {
073        boolean result = false; //by default, there is no ownership constraint
074        String noun = extractNoun(aRequestParser); //WebUtil needs response too! servlet path + path info?
075        if( isRestrictedRequest(noun) ){
076          List<String> restrictedVerbs = fRestrictedOperations.get(noun);
077          if ( hasAllOperationsRestricted(restrictedVerbs) ) {
078            result = true;
079          }
080          else {
081            String verb = extractVerb(aRequestParser);
082            if ( restrictedVerbs.contains(verb) ) {
083              result = true;
084            }
085          }
086        }
087        return result;
088      }
089      
090      // PRIVATE 
091      
092      /** Holds all restricted operations in memory. Populated upon startup. */
093      private static Map<String, List<String>> fRestrictedOperations = new LinkedHashMap<String, List<String>>();
094      
095      /** Name used in web.xml. */
096      private static final String INIT_PARAM_NAME = "UntrustedProxyForUserId";
097      
098      /** Special character denoting all operations/verbs. */
099      private static final String ALL_OPERATIONS = "*";
100      
101      private static final Logger fLogger = Util.getLogger(UntrustedProxyForUserIdImpl.class);
102      
103      private static void parseSettings(String aRawValue){
104        fLogger.fine("Parsing ownership constraints defined in web.xml.");
105        List<String> lines = parseSeparateLines(aRawValue);
106        for(String line: lines){
107           parseNounsAndVerbs(line);      
108        }
109      }
110      
111      private static List<String> parseSeparateLines(String aRawValue){
112        List<String> result = new  ArrayList<String>();
113        StringTokenizer parser = new StringTokenizer(aRawValue, "\n\r");
114        while ( parser.hasMoreTokens() ) {
115          result.add( parser.nextToken().trim() );
116        }
117        return result;    
118      }
119    
120      private static void parseNounsAndVerbs(String aLine){
121        String verb = WebUtil.getFileExtension(aLine);
122        int verbStart = aLine.indexOf(".");
123        String noun = aLine.substring(0,verbStart);
124        if( isMissing(verb)  || isMissing(noun) ){
125          throw new RuntimeException(
126            "This line for the " + INIT_PARAM_NAME + " setting in web.xml does not have the expected form: " + Util.quote(aLine)
127          );
128        }
129        add(noun.trim(), verb.trim());
130      }
131      
132      private static boolean isMissing(String aText){
133        return ! Util.textHasContent(aText);
134      }
135      
136      private static void add(String aNoun, String aVerb){
137        if( fRestrictedOperations.containsKey(aNoun) ) {
138          addAnotherVerb(aNoun, aVerb);
139        }
140        else {
141          addNewNounAndVerb(aNoun, aVerb);
142        }
143      }
144      
145      private static void addNewNounAndVerb(String aNoun, String aVerb){
146        List<String> verbs = new ArrayList<String>();
147        verbs.add(aVerb);
148        fRestrictedOperations.put(aNoun, verbs);
149      }
150      
151      private static void addAnotherVerb(String aNoun, String aVerb){
152        if( ALL_OPERATIONS.equals(aVerb) ){
153          fLogger.fine(Util.logOnePerLine(fRestrictedOperations));
154          throw new RuntimeException(
155            "When you use the '" + ALL_OPERATIONS + "' character to represent ALL operations, then only one line can be present for that item. " + 
156            "In web.xml, you have a redundant setting for the init-param named " + INIT_PARAM_NAME + 
157            " which needs to be removed. It is related to " +  Util.quote(aNoun) + " and " + Util.quote(aVerb)
158          );
159        }
160        List<String> verbs = fRestrictedOperations.get(aNoun);
161        verbs.add(aVerb);
162      }
163    
164      private boolean isRestrictedRequest(String aNoun){
165        return fRestrictedOperations.containsKey(aNoun);
166      }
167      
168      private boolean hasAllOperationsRestricted(List<String> aVerbs){ 
169        return aVerbs.contains(ALL_OPERATIONS);
170      }
171      
172      /**
173       For the example URL '.../BlahAction.do?X=Y', this method returns 'BlahAction' as the noun.
174       Relies on the presence of '/' and '.' characters.
175      */
176      private String extractNoun(RequestParser aRequestParser){
177        String uri = getURI(aRequestParser);
178        int firstPeriod = uri.indexOf(".");
179        if( firstPeriod == NOT_FOUND ) {
180          throw new RuntimeException("Cannot find '.' character in URL: " + Util.quote(uri));
181        }
182        int lastSlash = uri.lastIndexOf("/");
183        if( lastSlash == NOT_FOUND ) {
184          throw new RuntimeException("Cannot find '/' character in URL: " + Util.quote(uri));
185        }
186        return uri.substring(lastSlash + 1, firstPeriod);
187      }
188    
189      /** Return the part of the URL after the '.' character, but before any '?' character (if present). */
190      private String extractVerb(RequestParser aRequestParser){
191        return WebUtil.getFileExtension(getURI(aRequestParser));
192      }
193      
194      private String getURI(RequestParser aRequestParser){
195        return aRequestParser.getRequest().getRequestURI();
196      }
197    }