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