View Javadoc
1   /**
2    * Copyright 2005-2014 The Kuali Foundation
3    *
4    * Licensed under the Educational Community License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    * http://www.opensource.org/licenses/ecl2.php
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  package org.kuali.rice.krad.web.bind;
17  
18  import java.beans.PropertyDescriptor;
19  import java.beans.PropertyEditor;
20  import java.security.GeneralSecurityException;
21  import java.util.ArrayList;
22  import java.util.HashSet;
23  import java.util.List;
24  import java.util.Set;
25  
26  import javax.servlet.http.HttpServletRequest;
27  
28  import org.apache.commons.lang.ObjectUtils;
29  import org.kuali.rice.core.api.CoreApiServiceLocator;
30  import org.kuali.rice.core.api.encryption.EncryptionService;
31  import org.kuali.rice.krad.service.KRADServiceLocatorWeb;
32  import org.kuali.rice.krad.uif.lifecycle.ViewPostMetadata;
33  import org.kuali.rice.krad.uif.util.CopyUtils;
34  import org.kuali.rice.krad.uif.util.ObjectPropertyUtils;
35  import org.kuali.rice.krad.uif.view.ViewModel;
36  import org.springframework.beans.BeanWrapperImpl;
37  import org.springframework.beans.BeansException;
38  import org.springframework.beans.InvalidPropertyException;
39  import org.springframework.beans.NotReadablePropertyException;
40  import org.springframework.beans.NullValueInNestedPathException;
41  import org.springframework.beans.PropertyAccessorUtils;
42  import org.springframework.beans.PropertyValue;
43  import org.springframework.util.StringUtils;
44  import org.springframework.web.bind.annotation.RequestMethod;
45  import org.springframework.web.context.request.RequestContextHolder;
46  import org.springframework.web.context.request.ServletRequestAttributes;
47  
48  /**
49   * Class is a top level BeanWrapper for a UIF View Model.
50   *
51   * <p>Registers custom property editors configured on the field associated with the property name for which
52   * we are getting or setting a value. In addition determines if the field requires encryption and if so applies
53   * the {@link UifEncryptionPropertyEditorWrapper}</p>
54   *
55   * @author Kuali Rice Team (rice.collab@kuali.org)
56   */
57  public class UifViewBeanWrapper extends BeanWrapperImpl {
58      private static org.apache.log4j.Logger LOG = org.apache.log4j.Logger.getLogger(UifViewBeanWrapper.class);
59  
60      // this stores all properties this wrapper has already checked
61      // with the view so the service isn't called again
62      private Set<String> processedProperties;
63  
64      private final UifBeanPropertyBindingResult bindingResult;
65  
66      public UifViewBeanWrapper(ViewModel model, UifBeanPropertyBindingResult bindingResult) {
67          super(model);
68  
69          this.bindingResult = bindingResult;
70          this.processedProperties = new HashSet<String>();
71      }
72  
73      /**
74       * Attempts to find a corresponding data field for the given property name in the current view or previous view,
75       * then if the field has a property editor configured it is registered with the property editor registry to use
76       * for this property.
77       *
78       * @param propertyName name of the property to find field and editor for
79       */
80      protected void registerEditorFromView(String propertyName) {
81          if (LOG.isDebugEnabled()) {
82              LOG.debug("Attempting to find property editor for property '" + propertyName + "'");
83          }
84  
85          // check if we already processed this property for this BeanWrapper instance
86          if (processedProperties.contains(propertyName)) {
87              return;
88          }
89  
90          ViewPostMetadata viewPostMetadata = ((ViewModel) getWrappedInstance()).getViewPostMetadata();
91          if (viewPostMetadata == null) {
92              return;
93          }
94  
95          PropertyEditor propertyEditor = null;
96          boolean requiresEncryption = false;
97  
98          if ((viewPostMetadata.getFieldPropertyEditors() != null) && viewPostMetadata.getFieldPropertyEditors()
99                  .containsKey(propertyName)) {
100             propertyEditor = viewPostMetadata.getFieldPropertyEditors().get(propertyName);
101         } else if ((viewPostMetadata.getSecureFieldPropertyEditors() != null) && viewPostMetadata
102                 .getSecureFieldPropertyEditors().containsKey(propertyName)) {
103             propertyEditor = viewPostMetadata.getSecureFieldPropertyEditors().get(propertyName);
104             requiresEncryption = true;
105         }
106 
107         if (propertyEditor != null) {
108             if (LOG.isDebugEnabled()) {
109                 LOG.debug("Registering custom editor for property path '"
110                         + propertyName
111                         + "' and property editor class '"
112                         + propertyEditor.getClass().getName()
113                         + "'");
114             }
115 
116             if (requiresEncryption) {
117                 if (LOG.isDebugEnabled()) {
118                     LOG.debug("Enabling encryption for custom editor '" + propertyName +
119                             "' and property editor class '" + propertyEditor.getClass().getName() + "'");
120                 }
121                 this.registerCustomEditor(null, propertyName, new UifEncryptionPropertyEditorWrapper(propertyEditor));
122             } else {
123                 this.registerCustomEditor(null, propertyName, propertyEditor);
124             }
125         } else if (requiresEncryption) {
126             if (LOG.isDebugEnabled()) {
127                 LOG.debug("No custom formatter for property path '"
128                         + propertyName
129                         + "' but property does require encryption");
130             }
131 
132             this.registerCustomEditor(null, propertyName, new UifEncryptionPropertyEditorWrapper(
133                     findEditorForPropertyName(propertyName)));
134         }
135 
136         processedProperties.add(propertyName);
137     }
138 
139     /**
140      * Finds a property editor for the given propert name, checking for a custom registered editor and editors
141      * by type.
142      *
143      * @param propertyName name of the property to get editor for
144      * @return property editor instance
145      */
146     protected PropertyEditor findEditorForPropertyName(String propertyName) {
147         Class<?> clazz = getPropertyType(propertyName);
148         if (LOG.isDebugEnabled()) {
149             LOG.debug("Attempting retrieval of property editor using class '"
150                     + clazz
151                     + "' and property path '"
152                     + propertyName
153                     + "'");
154         }
155 
156         PropertyEditor editor = findCustomEditor(clazz, propertyName);
157         if (editor == null) {
158             if (LOG.isDebugEnabled()) {
159                 LOG.debug("No custom property editor found using class '"
160                         + clazz
161                         + "' and property path '"
162                         + propertyName
163                         + "'. Attempting to find default property editor class.");
164             }
165             editor = getDefaultEditor(clazz);
166         }
167 
168         return editor;
169     }
170 
171     /**
172      * {@inheritDoc}
173      */
174     @Override
175     public Class<?> getPropertyType(String propertyName) throws BeansException {
176         try {
177             PropertyDescriptor pd = getPropertyDescriptorInternal(propertyName);
178             if (pd != null) {
179                 return pd.getPropertyType();
180             }
181 
182             // Maybe an indexed/mapped property...
183             Object value = super.getPropertyValue(propertyName);
184             if (value != null) {
185                 return value.getClass();
186             }
187 
188             // Check to see if there is a custom editor,
189             // which might give an indication on the desired target type.
190             Class<?> editorType = guessPropertyTypeFromEditors(propertyName);
191             if (editorType != null) {
192                 return editorType;
193             }
194         } catch (InvalidPropertyException ex) {
195             // Consider as not determinable.
196         }
197 
198         return null;
199     }
200 
201     /**
202      * Overridden to copy property editor registration to the new bean wrapper.
203      *
204      * {@inheritDoc}
205      */
206     @Override
207     protected BeanWrapperImpl getBeanWrapperForPropertyPath(String propertyPath) {
208         BeanWrapperImpl beanWrapper = super.getBeanWrapperForPropertyPath(propertyPath);
209 
210         PropertyTokenHolder tokens = getPropertyNameTokens(propertyPath);
211         String canonicalName = tokens.canonicalName;
212 
213         int pos = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(canonicalName);
214         if (pos != -1) {
215             canonicalName = canonicalName.substring(0, pos);
216         }
217 
218         copyCustomEditorsTo(beanWrapper, canonicalName);
219 
220         return beanWrapper;
221     }
222 
223     /**
224      * Overridden to register any property editor for the property before the value is pulled.
225      *
226      * {@inheritDoc}
227      */
228     @Override
229     public Object getPropertyValue(String propertyName) throws BeansException {
230         registerEditorFromView(propertyName);
231 
232         Object value = null;
233         try {
234             value = super.getPropertyValue(propertyName);
235         } catch (NullValueInNestedPathException e) {
236             // swallow null values in path and return null as the value
237         }
238 
239         return value;
240     }
241 
242     /**
243      * Overridden to perform processing before and after the value is set.
244      *
245      * <p>First binding security is checked to determine whether the path allows binding. Next,
246      * access security is checked to determine whether the value needs decrypted. Finally, if
247      * change tracking is enabled, the original value is compared with the new for indicating a
248      * modified path.</p>
249      *
250      * {@inheritDoc}
251      */
252     @Override
253     public void setPropertyValue(PropertyValue pv) throws BeansException {
254         boolean isPropertyAccessible = checkPropertyBindingAccess(pv.getName());
255         if (!isPropertyAccessible) {
256             return;
257         }
258 
259         Object value = processValueBeforeSet(pv.getName(), pv.getValue());
260 
261         pv = new PropertyValue(pv, value);
262 
263         // save off the original value if we are change tracking
264         boolean originalValueSaved = true;
265         Object originalValue = null;
266         if (bindingResult.isChangeTracking()) {
267             try {
268                 originalValue = getPropertyValue(pv.getName());
269             } catch (Exception e) {
270                 // be failsafe here, if an exception happens here then we can't make any assumptions about whether
271                 // the property value changed or not
272                 originalValueSaved = false;
273             }
274         }
275 
276         // set the actual property value
277         super.setPropertyValue(pv);
278 
279         // if we are change tracking and we saved original value, check if it's modified
280         if (bindingResult.isChangeTracking() && originalValueSaved) {
281             try {
282                 Object newValue = getPropertyValue(pv.getName());
283                 if (ObjectUtils.notEqual(originalValue, newValue)) {
284                     // if they are not equal, it's been modified!
285                     bindingResult.addModifiedPath(pv.getName());
286                 }
287             } catch (Exception e) {
288                 // failsafe here as well
289             }
290         }
291     }
292 
293     /**
294      * Overridden to perform processing before and after the value is set.
295      *
296      * <p>First binding security is checked to determine whether the path allows binding. Next,
297      * access security is checked to determine whether the value needs decrypted. Finally, if
298      * change tracking is enabled, the original value is compared with the new for indicating a
299      * modified path.</p>
300      *
301      * {@inheritDoc}
302      */
303     @Override
304     public void setPropertyValue(String propertyName, Object value) throws BeansException {
305         boolean isPropertyAccessible = checkPropertyBindingAccess(propertyName);
306         if (!isPropertyAccessible) {
307             return;
308         }
309 
310         value = processValueBeforeSet(propertyName, value);
311 
312         // save off the original value
313         boolean originalValueSaved = true;
314         Object originalValue = null;
315         try {
316             originalValue = getPropertyValue(propertyName);
317         } catch (Exception e) {
318             // be failsafe here, if an exception happens here then we can't make any assumptions about whether
319             // the property value changed or not
320             originalValueSaved = false;
321         }
322 
323         // set the actual property value
324         super.setPropertyValue(propertyName, value);
325 
326         // only check if it's modified if we were able to save the original value
327         if (originalValueSaved) {
328             try {
329                 Object newValue = getPropertyValue(propertyName);
330                 if (ObjectUtils.notEqual(originalValue, newValue)) {
331                     // if they are not equal, it's been modified!
332                     bindingResult.addModifiedPath(propertyName);
333                 }
334             } catch (Exception e) {
335                 // failsafe here as well
336             }
337         }
338     }
339 
340     /**
341      * Determines whether request binding is allowed for the given property name/path.
342      *
343      * <p>Binding access is determined by default based on the view's post metadata. A set of
344      * accessible binding paths (populated during the view lifecycle) is maintained within this data.
345      * Overrides can be specifed using the annotations {@link org.kuali.rice.krad.web.bind.RequestProtected}
346      * and {@link org.kuali.rice.krad.web.bind.RequestAccessible}.</p>
347      *
348      * <p>If the path is not accessible, it is recorded in the binding results suppressed fields. Controller
349      * methods can accept the binding result and further handle these properties if necessary.</p>
350      *
351      * @param propertyName name/path of the property to check binding access for
352      * @return boolean true if binding access is allowed, false if not allowed
353      */
354     protected boolean checkPropertyBindingAccess(String propertyName) {
355         boolean isAccessible = false;
356 
357         // check for explicit property annotations that indicate access
358         Boolean bindingAnnotationAccess = checkBindingAnnotationsInPath(propertyName);
359         if (bindingAnnotationAccess != null) {
360             isAccessible = bindingAnnotationAccess.booleanValue();
361         } else {
362             // default access, must be in view's accessible binding paths
363             ViewPostMetadata viewPostMetadata = ((ViewModel) getWrappedInstance()).getViewPostMetadata();
364             if ((viewPostMetadata != null) && (viewPostMetadata.getAccessibleBindingPaths() != null)) {
365                 isAccessible = viewPostMetadata.getAccessibleBindingPaths().contains(propertyName);
366             }
367         }
368 
369         if (!isAccessible) {
370             LOG.debug("Request parameter sent for inaccessible binding path: " + propertyName);
371 
372             bindingResult.recordSuppressedField(propertyName);
373         }
374 
375         return isAccessible;
376     }
377 
378     /**
379      * Determines whether one of the binding annotations is present within the given property path, and if
380      * so returns whether access should be granted based on those annotation(s).
381      *
382      * <p>Binding annotations may occur anywhere in the property path. For example, if the path is 'object.field1',
383      * a binding annotation may be present on the 'object' property or the 'field1' property. If multiple annotations
384      * are found in the path, the annotation at the deepest level is taken. If both the protected and accessible
385      * annotation are found at the same level, the protected access is used.</p>
386      *
387      * @param propertyPath path to look for annotations
388      * @return Boolean true if an annotation is found and the access is allowed, false if an annotation is found
389      * and the access is protected, null if no annotations where found in the path
390      */
391     protected Boolean checkBindingAnnotationsInPath(String propertyPath) {
392         HttpServletRequest request =
393                 ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
394 
395         // iterate through the paths in reverse order so we consider annotations at deeper levels first
396         String[] propertyPaths = ObjectPropertyUtils.splitPropertyPath(propertyPath);
397         for (int i = (propertyPaths.length - 1); i >= 0; i--) {
398             Class<?> parentPropertyClass = getWrappedClass();
399 
400             // for nested paths, we need to get the class of the immediate parent
401             if (i != 0) {
402                 String path = org.apache.commons.lang.StringUtils.join(propertyPaths, ".", 0, i);
403 
404                 parentPropertyClass = ObjectPropertyUtils.getPropertyType(getWrappedInstance(), path);
405             }
406 
407             // remove index or map key to get the correct property name
408             String nestedPath = propertyPaths[i];
409             if (org.apache.commons.lang.StringUtils.endsWith(nestedPath, "]")) {
410                 nestedPath = org.apache.commons.lang.StringUtils.substringBefore(nestedPath, "[");
411             }
412 
413             RequestProtected protectedAnnotation = (RequestProtected) CopyUtils.getFieldAnnotation(parentPropertyClass,
414                     nestedPath, RequestProtected.class);
415             if ((protectedAnnotation != null) && annotationMatchesRequestMethod(protectedAnnotation.method(),
416                     request.getMethod())) {
417                 return Boolean.FALSE;
418             }
419 
420             RequestAccessible accessibleAnnotation = (RequestAccessible) CopyUtils.getFieldAnnotation(
421                     parentPropertyClass, nestedPath, RequestAccessible.class);
422             if ((accessibleAnnotation != null) && annotationMatchesRequestMethod(accessibleAnnotation.method(),
423                     request.getMethod())) {
424                 return Boolean.TRUE;
425             }
426         }
427 
428         return null;
429     }
430 
431     /**
432      * Indicates whether one of the given request methods in the given array matches the actual method of
433      * the request.
434      *
435      * @param annotationMethods array of request methods to check
436      * @param requestMethod method of the request to match on
437      * @return boolean true if one of the annotation methods match, false if none match
438      */
439     protected boolean annotationMatchesRequestMethod(RequestMethod[] annotationMethods, String requestMethod) {
440         // empty array of methods should match all
441         if ((annotationMethods == null) || (annotationMethods.length == 0)) {
442             return true;
443         }
444 
445         for (RequestMethod annotationMethod : annotationMethods) {
446             if (org.apache.commons.lang.StringUtils.equals(annotationMethod.name(), requestMethod)) {
447                 return true;
448             }
449         }
450 
451         return false;
452     }
453 
454     /**
455      * Registers any custom property editor for the property name/path, converts empty string values to null, and
456      * calls helper method to decrypt secure values.
457      *
458      * @param propertyName name of the property
459      * @param value value of the property to process
460      * @return updated (possibly) property value
461      */
462     protected Object processValueBeforeSet(String propertyName, Object value) {
463         registerEditorFromView(propertyName);
464 
465         Object processedValue = value;
466 
467         // Convert blank string values to null so empty strings are not set on the form as values (useful for legacy
468         // checks) Jira: KULRICE-11424
469         if (value instanceof String) {
470             String propertyValue = (String) value;
471 
472             if (StringUtils.isEmpty(propertyValue)) {
473                 processedValue = null;
474             } else {
475                 processedValue = decryptValueIfNecessary(propertyName, propertyValue);
476             }
477         }
478 
479         return processedValue;
480     }
481 
482     /**
483      * If the given property name is secure, decrypts the value by calling the encryption service.
484      *
485      * @param propertyName name of the property
486      * @param propertyValue value of the property
487      * @return String decrypted property value (or original value if not secure)
488      */
489     protected String decryptValueIfNecessary(String propertyName, String propertyValue) {
490         String decryptedPropertyValue = propertyValue;
491 
492         if (propertyValue.endsWith(EncryptionService.ENCRYPTION_POST_PREFIX)) {
493             propertyValue = org.apache.commons.lang.StringUtils.removeEnd(propertyValue,
494                     EncryptionService.ENCRYPTION_POST_PREFIX);
495         }
496 
497         if (isSecure(getWrappedClass(), propertyName)) {
498             try {
499                 if (CoreApiServiceLocator.getEncryptionService().isEnabled()) {
500                     decryptedPropertyValue = CoreApiServiceLocator.getEncryptionService().decrypt(propertyValue);
501                 }
502             } catch (GeneralSecurityException e) {
503                 throw new RuntimeException(e);
504             }
505         }
506 
507         return decryptedPropertyValue;
508     }
509 
510     /**
511      * Checks whether the given property is secure.
512      *
513      * @param wrappedClass class the property is associated with
514      * @param propertyPath path to the property
515      * @return boolean true if the property is secure, false if not
516      */
517     protected boolean isSecure(Class<?> wrappedClass, String propertyPath) {
518         if (KRADServiceLocatorWeb.getDataObjectAuthorizationService().attributeValueNeedsToBeEncryptedOnFormsAndLinks(
519                 wrappedClass, propertyPath)) {
520             return true;
521         }
522 
523         BeanWrapperImpl beanWrapper;
524         try {
525             beanWrapper = getBeanWrapperForPropertyPath(propertyPath);
526         } catch (NotReadablePropertyException nrpe) {
527             LOG.debug("Bean wrapper was not found for "
528                     + propertyPath
529                     + ", but since it cannot be accessed it will not be set as secure.", nrpe);
530             return false;
531         }
532 
533         if (org.apache.commons.lang.StringUtils.isNotBlank(beanWrapper.getNestedPath())) {
534             PropertyTokenHolder tokens = getPropertyNameTokens(propertyPath);
535             String nestedPropertyPath = org.apache.commons.lang.StringUtils.removeStart(tokens.canonicalName,
536                     beanWrapper.getNestedPath());
537 
538             return isSecure(beanWrapper.getWrappedClass(), nestedPropertyPath);
539         }
540 
541         return false;
542     }
543 
544     /**
545      * Parse the given property name into the corresponding property name tokens.
546      *
547      * @param propertyName the property name to parse
548      * @return representation of the parsed property tokens
549      */
550     private PropertyTokenHolder getPropertyNameTokens(String propertyName) {
551         PropertyTokenHolder tokens = new PropertyTokenHolder();
552         String actualName = null;
553         List<String> keys = new ArrayList<String>(2);
554         int searchIndex = 0;
555         while (searchIndex != -1) {
556             int keyStart = propertyName.indexOf(PROPERTY_KEY_PREFIX, searchIndex);
557             searchIndex = -1;
558             if (keyStart != -1) {
559                 int keyEnd = propertyName.indexOf(PROPERTY_KEY_SUFFIX, keyStart + PROPERTY_KEY_PREFIX.length());
560                 if (keyEnd != -1) {
561                     if (actualName == null) {
562                         actualName = propertyName.substring(0, keyStart);
563                     }
564                     String key = propertyName.substring(keyStart + PROPERTY_KEY_PREFIX.length(), keyEnd);
565                     if ((key.startsWith("'") && key.endsWith("'")) || (key.startsWith("\"") && key.endsWith("\""))) {
566                         key = key.substring(1, key.length() - 1);
567                     }
568                     keys.add(key);
569                     searchIndex = keyEnd + PROPERTY_KEY_SUFFIX.length();
570                 }
571             }
572         }
573         tokens.actualName = (actualName != null ? actualName : propertyName);
574         tokens.canonicalName = tokens.actualName;
575         if (!keys.isEmpty()) {
576             tokens.canonicalName += PROPERTY_KEY_PREFIX +
577                     StringUtils.collectionToDelimitedString(keys, PROPERTY_KEY_SUFFIX + PROPERTY_KEY_PREFIX) +
578                     PROPERTY_KEY_SUFFIX;
579             tokens.keys = StringUtils.toStringArray(keys);
580         }
581         return tokens;
582     }
583 
584     private static class PropertyTokenHolder {
585 
586         public String canonicalName;
587 
588         public String actualName;
589 
590         public String[] keys;
591     }
592 }