001/**
002 * Copyright 2005-2018 The Kuali Foundation
003 *
004 * Licensed under the Educational Community License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.opensource.org/licenses/ecl2.php
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.kuali.rice.krad.web.bind;
017
018import com.sun.accessibility.internal.resources.accessibility;
019import org.apache.commons.lang.ObjectUtils;
020import org.kuali.rice.core.api.CoreApiServiceLocator;
021import org.kuali.rice.core.api.encryption.EncryptionService;
022import org.kuali.rice.krad.uif.UifConstants;
023import org.kuali.rice.krad.uif.lifecycle.ViewPostMetadata;
024import org.kuali.rice.krad.uif.util.CopyUtils;
025import org.kuali.rice.krad.uif.util.ObjectPropertyUtils;
026import org.kuali.rice.krad.uif.view.ViewModel;
027import org.kuali.rice.krad.util.KRADUtils;
028import org.springframework.beans.BeanWrapperImpl;
029import org.springframework.beans.BeansException;
030import org.springframework.beans.NotReadablePropertyException;
031import org.springframework.beans.NullValueInNestedPathException;
032import org.springframework.beans.PropertyAccessorUtils;
033import org.springframework.beans.PropertyValue;
034import org.springframework.util.StringUtils;
035import org.springframework.web.bind.annotation.RequestMethod;
036import org.springframework.web.context.request.RequestContextHolder;
037import org.springframework.web.context.request.ServletRequestAttributes;
038
039import javax.servlet.http.HttpServletRequest;
040import java.beans.PropertyEditor;
041import java.security.GeneralSecurityException;
042import java.util.ArrayList;
043import java.util.HashSet;
044import java.util.List;
045import java.util.Set;
046import java.util.regex.Matcher;
047import java.util.regex.Pattern;
048
049/**
050 * Class is a top level BeanWrapper for a UIF View Model.
051 *
052 * <p>Registers custom property editors configured on the field associated with the property name for which
053 * we are getting or setting a value. In addition determines if the field requires encryption and if so applies
054 * the {@link UifEncryptionPropertyEditorWrapper}</p>
055 *
056 * @author Kuali Rice Team (rice.collab@kuali.org)
057 */
058public class UifViewBeanWrapper extends UifBeanWrapper {
059    private static org.apache.log4j.Logger LOG = org.apache.log4j.Logger.getLogger(UifViewBeanWrapper.class);
060
061    // this stores all properties this wrapper has already checked
062    // with the view so the service isn't called again
063    private Set<String> processedProperties;
064
065    private final UifBeanPropertyBindingResult bindingResult;
066
067    public UifViewBeanWrapper(ViewModel model, UifBeanPropertyBindingResult bindingResult) {
068        super(model);
069
070        this.bindingResult = bindingResult;
071        this.processedProperties = new HashSet<String>();
072    }
073
074//    /**
075//     * {@inheritDoc}
076//     */
077//    @Override
078//    public Class<?> getPropertyType(String propertyName) throws BeansException {
079//        try {
080//            PropertyDescriptor pd = getPropertyDescriptorInternal(propertyName);
081//            if (pd != null) {
082//                return pd.getPropertyType();
083//            }
084//
085//            // Maybe an indexed/mapped property...
086//            Object value = super.getPropertyValue(propertyName);
087//            if (value != null) {
088//                return value.getClass();
089//            }
090//
091//            // Check to see if there is a custom editor,
092//            // which might give an indication on the desired target type.
093//            Class<?> editorType = guessPropertyTypeFromEditors(propertyName);
094//            if (editorType != null) {
095//                return editorType;
096//            }
097//        } catch (InvalidPropertyException ex) {
098//            // Consider as not determinable.
099//        }
100//
101//        return null;
102//    }
103
104
105    /**
106     * Override to register property editors from the view before the value is retrieved.
107     *
108     * {@inheritDoc}
109     */
110    @Override
111    protected Object getPropertyValue(String propertyName, boolean autoGrowNestedPaths) {
112        registerEditorFromView(propertyName);
113
114        return super.getPropertyValue(propertyName, autoGrowNestedPaths);
115    }
116
117    /**
118     * Attempts to find a corresponding data field for the given property name in the current view or previous view,
119     * then if the field has a property editor configured it is registered with the property editor registry to use
120     * for this property.
121     *
122     * @param propertyName name of the property to find field and editor for
123     */
124    protected void registerEditorFromView(String propertyName) {
125        // check if we already processed this property for this BeanWrapper instance
126        if (processedProperties.contains(propertyName)) {
127            return;
128        }
129
130        if (LOG.isDebugEnabled()) {
131            LOG.debug("Attempting to find property editor for property '" + propertyName + "'");
132        }
133
134        ViewPostMetadata viewPostMetadata = ((ViewModel) getWrappedInstance()).getViewPostMetadata();
135        if (viewPostMetadata == null) {
136            return;
137        }
138
139        PropertyEditor propertyEditor = viewPostMetadata.getFieldEditor(propertyName);
140        if (propertyEditor != null) {
141            registerCustomEditor(null, propertyName, propertyEditor);
142        }
143
144        processedProperties.add(propertyName);
145    }
146
147    /**
148     * Overridden to perform processing before and after the value is set.
149     *
150     * <p>First binding security is checked to determine whether the path allows binding. Next,
151     * access security is checked to determine whether the value needs decrypted. Finally, if
152     * change tracking is enabled, the original value is compared with the new for indicating a
153     * modified path.</p>
154     *
155     * {@inheritDoc}
156     */
157    @Override
158    public void setPropertyValue(PropertyValue pv) throws BeansException {
159        boolean isPropertyAccessible = checkPropertyBindingAccess(pv.getName());
160        if (!isPropertyAccessible) {
161            return;
162        }
163
164        Object value = processValueBeforeSet(pv.getName(), pv.getValue());
165
166        pv = new PropertyValue(pv, value);
167
168        // save off the original value if we are change tracking
169        boolean originalValueSaved = true;
170        Object originalValue = null;
171        if (bindingResult.isChangeTracking()) {
172            try {
173                originalValue = getPropertyValue(pv.getName(), true);
174            } catch (Exception e) {
175                // be failsafe here, if an exception happens here then we can't make any assumptions about whether
176                // the property value changed or not
177                originalValueSaved = false;
178            }
179        }
180
181        // set the actual property value
182        super.setPropertyValue(pv);
183
184        // if we are change tracking and we saved original value, check if it's modified
185        if (bindingResult.isChangeTracking() && originalValueSaved) {
186            try {
187                Object newValue = getPropertyValue(pv.getName());
188                if (ObjectUtils.notEqual(originalValue, newValue)) {
189                    // if they are not equal, it's been modified!
190                    bindingResult.addModifiedPath(pv.getName());
191                }
192            } catch (Exception e) {
193                // failsafe here as well
194            }
195        }
196    }
197
198    /**
199     * Overridden to perform processing before and after the value is set.
200     *
201     * <p>First binding security is checked to determine whether the path allows binding. Next,
202     * access security is checked to determine whether the value needs decrypted. Finally, if
203     * change tracking is enabled, the original value is compared with the new for indicating a
204     * modified path.</p>
205     *
206     * {@inheritDoc}
207     */
208    @Override
209    public void setPropertyValue(String propertyName, Object value) throws BeansException {
210        boolean isPropertyAccessible = checkPropertyBindingAccess(propertyName);
211        if (!isPropertyAccessible) {
212            return;
213        }
214
215        value = processValueBeforeSet(propertyName, value);
216
217        // save off the original value
218        boolean originalValueSaved = true;
219        Object originalValue = null;
220        try {
221            originalValue = getPropertyValue(propertyName, true);
222        } catch (Exception e) {
223            // be failsafe here, if an exception happens here then we can't make any assumptions about whether
224            // the property value changed or not
225            originalValueSaved = false;
226        }
227
228        // set the actual property value
229        super.setPropertyValue(propertyName, value);
230
231        // only check if it's modified if we were able to save the original value
232        if (originalValueSaved) {
233            try {
234                Object newValue = getPropertyValue(propertyName);
235                if (ObjectUtils.notEqual(originalValue, newValue)) {
236                    // if they are not equal, it's been modified!
237                    bindingResult.addModifiedPath(propertyName);
238                }
239            } catch (Exception e) {
240                // failsafe here as well
241            }
242        }
243    }
244
245    /**
246     * Determines whether request binding is allowed for the given property name/path.
247     *
248     * <p>Binding access is determined by default based on the view's post metadata. A set of
249     * accessible binding paths (populated during the view lifecycle) is maintained within this data.
250     * Overrides can be specified using the annotations {@link org.kuali.rice.krad.web.bind.RequestProtected}
251     * and {@link org.kuali.rice.krad.web.bind.RequestAccessible}.</p>
252     *
253     * <p>If the path is not accessible, it is recorded in the binding results suppressed fields. Controller
254     * methods can accept the binding result and further handle these properties if necessary.</p>
255     *
256     * @param propertyName name/path of the property to check binding access for
257     * @return boolean true if binding access is allowed, false if not allowed
258     */
259    protected boolean checkPropertyBindingAccess(String propertyName) {
260        boolean isAccessible = false;
261
262        // check for explicit property annotations that indicate access
263        Boolean bindingAnnotationAccess = checkBindingAnnotationsInPath(propertyName);
264        if (bindingAnnotationAccess != null) {
265            isAccessible = bindingAnnotationAccess.booleanValue();
266        } else {
267            // default access, must be in view's accessible binding paths
268            ViewPostMetadata viewPostMetadata = ((ViewModel) getWrappedInstance()).getViewPostMetadata();
269            if ((viewPostMetadata != null) && (viewPostMetadata.getAccessibleBindingPaths() != null)) {
270                isAccessible = viewPostMetadata.getAccessibleBindingPaths().contains(propertyName);
271
272                if (!isAccessible && propertyName.contains("[")) {
273                    String wildcardedPropertyName = propertyName.substring(0, propertyName.lastIndexOf("["))
274                        + "[*" + propertyName.substring(propertyName.lastIndexOf("]"));
275                    isAccessible = viewPostMetadata.getAccessibleBindingPaths().contains(wildcardedPropertyName);
276                }
277            }
278        }
279
280        if (!isAccessible) {
281            LOG.debug("Request parameter sent for inaccessible binding path: " + propertyName);
282
283            bindingResult.recordSuppressedField(propertyName);
284        }
285
286        return isAccessible;
287    }
288
289    /**
290     * Determines whether one of the binding annotations is present within the given property path, and if
291     * so returns whether access should be granted based on those annotation(s).
292     *
293     * <p>Binding annotations may occur anywhere in the property path. For example, if the path is 'object.field1',
294     * a binding annotation may be present on the 'object' property or the 'field1' property. If multiple annotations
295     * are found in the path, the annotation at the deepest level is taken. If both the protected and accessible
296     * annotation are found at the same level, the protected access is used.</p>
297     *
298     * @param propertyPath path to look for annotations
299     * @return Boolean true if an annotation is found and the access is allowed, false if an annotation is found
300     * and the access is protected, null if no annotations where found in the path
301     */
302    protected Boolean checkBindingAnnotationsInPath(String propertyPath) {
303        HttpServletRequest request =
304                ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
305
306        while (!StringUtils.isEmpty(propertyPath)) {
307            String nestedPath = ObjectPropertyUtils.getPathTail(propertyPath);
308            String parentPropertyPath = ObjectPropertyUtils.removePathTail(propertyPath);
309
310            Class<?> parentPropertyClass = getWrappedClass();
311
312            // for nested paths, we need to get the class of the immediate parent
313            if (!StringUtils.isEmpty(parentPropertyPath)) {
314                parentPropertyClass = ObjectPropertyUtils.getPropertyType(getWrappedInstance(), parentPropertyPath);
315            }
316
317            // remove index or map key to get the correct property name
318            if (org.apache.commons.lang.StringUtils.endsWith(nestedPath, "]")) {
319                nestedPath = org.apache.commons.lang.StringUtils.substringBefore(nestedPath, "[");
320            }
321
322            RequestProtected protectedAnnotation = (RequestProtected) CopyUtils.getFieldAnnotation(parentPropertyClass,
323                    nestedPath, RequestProtected.class);
324            if ((protectedAnnotation != null) && annotationMatchesRequestMethod(protectedAnnotation.method(),
325                    request.getMethod())) {
326                return Boolean.FALSE;
327            }
328
329            RequestAccessible accessibleAnnotation = (RequestAccessible) CopyUtils.getFieldAnnotation(
330                    parentPropertyClass, nestedPath, RequestAccessible.class);
331            if (accessibleAnnotation != null) {
332                boolean isAnnotationRequestMethod = annotationMatchesRequestMethod(accessibleAnnotation.method(),
333                        request.getMethod());
334                boolean isAnnotationMethodToCalls = annotationMatchesMethodToCalls(accessibleAnnotation.methodToCalls(),
335                        request.getParameter(UifConstants.CONTROLLER_METHOD_DISPATCH_PARAMETER_NAME));
336                if (isAnnotationRequestMethod && isAnnotationMethodToCalls) {
337                    //((UifFormBase) this.bindingResult.getTarget()).getMethodToCall())) {
338                    return Boolean.TRUE;
339                }
340            }
341
342            propertyPath = parentPropertyPath;
343        }
344
345        return null;
346    }
347
348    /**
349     * Indicates whether one of the given request accessible methods to call in the given array matches the
350     * actual methodToCall of the request.
351     *
352     * @param annotationMethodToCalls array of request accessible methods to call to check against
353     * @param methodToCall method to call of the request
354     * @return boolean true if one of the annotation methods to call match, false if none match
355     */
356    protected boolean annotationMatchesMethodToCalls(String[] annotationMethodToCalls, String methodToCall) {
357        // empty array of methods should match all
358        if ((annotationMethodToCalls == null) || (annotationMethodToCalls.length == 0)) {
359            return true;
360        }
361
362        for (String annotationMethodToCall : annotationMethodToCalls) {
363            if (org.apache.commons.lang.StringUtils.equals(annotationMethodToCall, methodToCall)) {
364                return true;
365            }
366        }
367
368        return false;
369    }
370
371    /**
372     * Indicates whether one of the given request methods in the given array matches the actual method of
373     * the request.
374     *
375     * @param annotationMethods array of request methods to check
376     * @param requestMethod method of the request to match on
377     * @return boolean true if one of the annotation methods match, false if none match
378     */
379    protected boolean annotationMatchesRequestMethod(RequestMethod[] annotationMethods, String requestMethod) {
380        // empty array of methods should match all
381        if ((annotationMethods == null) || (annotationMethods.length == 0)) {
382            return true;
383        }
384
385        for (RequestMethod annotationMethod : annotationMethods) {
386            if (org.apache.commons.lang.StringUtils.equals(annotationMethod.name(), requestMethod)) {
387                return true;
388            }
389        }
390
391        return false;
392    }
393
394    /**
395     * Registers any custom property editor for the property name/path, converts empty string values to null, and
396     * calls helper method to decrypt secure values.
397     *
398     * @param propertyName name of the property
399     * @param value value of the property to process
400     * @return updated (possibly) property value
401     */
402    protected Object processValueBeforeSet(String propertyName, Object value) {
403        registerEditorFromView(propertyName);
404
405        Object processedValue = value;
406
407        // Convert blank string values to null so empty strings are not set on the form as values (useful for legacy
408        // checks) Jira: KULRICE-11424
409        if (value instanceof String) {
410            String propertyValue = (String) value;
411
412            if (StringUtils.isEmpty(propertyValue)) {
413                processedValue = null;
414            } else {
415                processedValue = decryptValueIfNecessary(propertyName, propertyValue);
416            }
417        }
418
419        return processedValue;
420    }
421
422    /**
423     * If the given property name is secure, decrypts the value by calling the encryption service.
424     *
425     * @param propertyName name of the property
426     * @param propertyValue value of the property
427     * @return String decrypted property value (or original value if not secure)
428     */
429    protected String decryptValueIfNecessary(String propertyName, String propertyValue) {
430        // check security on field
431        boolean isSecure = isSecure(getWrappedClass(), propertyName);
432
433        if (org.apache.commons.lang.StringUtils.endsWith(propertyValue, EncryptionService.ENCRYPTION_POST_PREFIX)) {
434            propertyValue = org.apache.commons.lang.StringUtils.removeEnd(propertyValue,
435                    EncryptionService.ENCRYPTION_POST_PREFIX);
436            isSecure = true;
437        }
438
439        // decrypt if the value is secure
440        if (isSecure) {
441            try {
442                if (CoreApiServiceLocator.getEncryptionService().isEnabled()) {
443                    propertyValue = CoreApiServiceLocator.getEncryptionService().decrypt(propertyValue);
444                }
445            } catch (GeneralSecurityException e) {
446                throw new RuntimeException(e);
447            }
448        }
449
450        return propertyValue;
451    }
452
453    /**
454     * Checks whether the given property is secure.
455     *
456     * @param wrappedClass class the property is associated with
457     * @param propertyPath path to the property
458     * @return boolean true if the property is secure, false if not
459     */
460    protected boolean isSecure(Class<?> wrappedClass, String propertyPath) {
461        if (KRADUtils.isSecure(propertyPath, wrappedClass)) {
462            return true;
463        }
464
465        // since this is part of a set, we want to make sure nested paths grow
466        setAutoGrowNestedPaths(true);
467
468        BeanWrapperImpl beanWrapper;
469        try {
470            beanWrapper = getPropertyAccessorForPropertyPath(propertyPath);
471        } catch (NotReadablePropertyException | NullValueInNestedPathException e) {
472            LOG.debug("Bean wrapper was not found for " + propertyPath
473                    + ", but since it cannot be accessed it will not be set as secure.", e);
474            return false;
475        }
476
477        if (org.apache.commons.lang.StringUtils.isNotBlank(beanWrapper.getNestedPath())) {
478            PropertyTokenHolder tokens = getPropertyNameTokens(propertyPath);
479            String nestedPropertyPath = org.apache.commons.lang.StringUtils.removeStart(tokens.canonicalName,
480                    beanWrapper.getNestedPath());
481
482            return isSecure(beanWrapper.getWrappedClass(), nestedPropertyPath);
483        }
484
485        return false;
486    }
487
488    /**
489     * Overridden to copy property editor registration to the new bean wrapper.
490     *
491     * <p>This is necessary because spring only copies over the editors when a new bean wrapper is
492     * created. The wrapper is then cached and use for subsequent calls. But the get calls could bring in
493     * new custom editors we need to copy.</p>
494     *
495     * {@inheritDoc}
496     */
497    @Override
498    protected BeanWrapperImpl getPropertyAccessorForPropertyPath(String propertyPath) {
499        BeanWrapperImpl beanWrapper = (BeanWrapperImpl) super.getPropertyAccessorForPropertyPath(propertyPath);
500
501        PropertyTokenHolder tokens = getPropertyNameTokens(propertyPath);
502        String canonicalName = tokens.canonicalName;
503
504        int pos = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(canonicalName);
505        if (pos != -1) {
506            canonicalName = canonicalName.substring(0, pos);
507        }
508
509        copyCustomEditorsTo(beanWrapper, canonicalName);
510
511        return beanWrapper;
512    }
513
514    /**
515     * Parse the given property name into the corresponding property name tokens.
516     *
517     * @param propertyName the property name to parse
518     * @return representation of the parsed property tokens
519     */
520    private PropertyTokenHolder getPropertyNameTokens(String propertyName) {
521        PropertyTokenHolder tokens = new PropertyTokenHolder();
522        String actualName = null;
523        List<String> keys = new ArrayList<String>(2);
524        int searchIndex = 0;
525        while (searchIndex != -1) {
526            int keyStart = propertyName.indexOf(PROPERTY_KEY_PREFIX, searchIndex);
527            searchIndex = -1;
528            if (keyStart != -1) {
529                int keyEnd = propertyName.indexOf(PROPERTY_KEY_SUFFIX, keyStart + PROPERTY_KEY_PREFIX.length());
530                if (keyEnd != -1) {
531                    if (actualName == null) {
532                        actualName = propertyName.substring(0, keyStart);
533                    }
534                    String key = propertyName.substring(keyStart + PROPERTY_KEY_PREFIX.length(), keyEnd);
535                    if ((key.startsWith("'") && key.endsWith("'")) || (key.startsWith("\"") && key.endsWith("\""))) {
536                        key = key.substring(1, key.length() - 1);
537                    }
538                    keys.add(key);
539                    searchIndex = keyEnd + PROPERTY_KEY_SUFFIX.length();
540                }
541            }
542        }
543        tokens.actualName = (actualName != null ? actualName : propertyName);
544        tokens.canonicalName = tokens.actualName;
545        if (!keys.isEmpty()) {
546            tokens.canonicalName += PROPERTY_KEY_PREFIX +
547                    StringUtils.collectionToDelimitedString(keys, PROPERTY_KEY_SUFFIX + PROPERTY_KEY_PREFIX) +
548                    PROPERTY_KEY_SUFFIX;
549            tokens.keys = StringUtils.toStringArray(keys);
550        }
551        return tokens;
552    }
553
554    private static class PropertyTokenHolder {
555
556        public String canonicalName;
557
558        public String actualName;
559
560        public String[] keys;
561    }
562}