/** * ============================================================================= * * ORCID (R) Open Source * http://orcid.org * * Copyright (c) 2012-2014 ORCID, Inc. * Licensed under an MIT-Style License (MIT) * http://orcid.org/open-source-license * * This copyright and license information (including a link to the full license) * shall be included in its entirety in all copies or substantial portion of * the software. * * ============================================================================= */ package org.orcid.core.manager.impl; import java.security.AccessControlException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import javax.annotation.Resource; import javax.persistence.NoResultException; import javax.xml.datatype.XMLGregorianCalendar; import org.orcid.core.exception.OrcidAccessControlException; import org.orcid.core.exception.OrcidCoreExceptionMapper; import org.orcid.core.exception.OrcidDeprecatedException; import org.orcid.core.exception.OrcidNotClaimedException; import org.orcid.core.exception.OrcidUnauthorizedException; import org.orcid.core.exception.OrcidVisibilityException; import org.orcid.core.exception.WrongSourceException; import org.orcid.core.manager.ClientDetailsEntityCacheManager; import org.orcid.core.manager.OrcidSecurityManager; import org.orcid.core.manager.ProfileEntityCacheManager; import org.orcid.core.manager.SourceManager; import org.orcid.core.oauth.OrcidOauth2TokenDetailService; import org.orcid.core.oauth.OrcidProfileUserDetails; import org.orcid.core.security.aop.LockedException; import org.orcid.jaxb.model.clientgroup.ClientType; import org.orcid.jaxb.model.common_v2.Filterable; import org.orcid.jaxb.model.common_v2.Visibility; import org.orcid.jaxb.model.common_v2.VisibilityType; import org.orcid.jaxb.model.error_v2.OrcidError; import org.orcid.jaxb.model.message.OrcidType; import org.orcid.jaxb.model.message.ScopePathType; import org.orcid.jaxb.model.record.summary_v2.ActivitiesSummary; import org.orcid.jaxb.model.record.summary_v2.FundingGroup; import org.orcid.jaxb.model.record.summary_v2.PeerReviewGroup; import org.orcid.jaxb.model.record.summary_v2.WorkGroup; import org.orcid.jaxb.model.record_v2.BulkElement; import org.orcid.jaxb.model.record_v2.Email; import org.orcid.jaxb.model.record_v2.ExternalID; import org.orcid.jaxb.model.record_v2.ExternalIDs; import org.orcid.jaxb.model.record_v2.Group; import org.orcid.jaxb.model.record_v2.GroupableActivity; import org.orcid.jaxb.model.record_v2.Person; import org.orcid.jaxb.model.record_v2.PersonalDetails; import org.orcid.jaxb.model.record_v2.Record; import org.orcid.jaxb.model.record_v2.Work; import org.orcid.jaxb.model.record_v2.WorkBulk; import org.orcid.persistence.jpa.entities.ClientDetailsEntity; import org.orcid.persistence.jpa.entities.IdentifierTypeEntity; import org.orcid.persistence.jpa.entities.ProfileEntity; import org.orcid.persistence.jpa.entities.SourceAwareEntity; import org.orcid.persistence.jpa.entities.SourceEntity; import org.orcid.utils.DateUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.security.oauth2.provider.OAuth2Request; /** * * @author Will Simpson * */ public class OrcidSecurityManagerImpl implements OrcidSecurityManager { private static final ScopePathType READ_AFFILIATIONS_REQUIRED_SCOPE = ScopePathType.AFFILIATIONS_READ_LIMITED; private static final ScopePathType READ_BIO_REQUIRED_SCOPE = ScopePathType.ORCID_BIO_READ_LIMITED; private static final ScopePathType READ_FUNDING_REQUIRED_SCOPE = ScopePathType.FUNDING_READ_LIMITED; private static final ScopePathType READ_PEER_REVIEWS_REQUIRED_SCOPE = ScopePathType.PEER_REVIEW_READ_LIMITED; private static final ScopePathType READ_WORKS_REQUIRED_SCOPE = ScopePathType.ORCID_WORKS_READ_LIMITED; @Resource private SourceManager sourceManager; @Resource private OrcidOauth2TokenDetailService orcidOauthTokenDetailService; @Resource private ProfileEntityCacheManager profileEntityCacheManager; @Resource private ClientDetailsEntityCacheManager clientDetailsEntityCacheManager; @Resource private OrcidCoreExceptionMapper orcidCoreExceptionMapper; @Value("${org.orcid.core.token.write_validity_seconds:3600}") private int writeValiditySeconds; @Value("${org.orcid.core.claimWaitPeriodDays:10}") private int claimWaitPeriodDays; @Value("${org.orcid.core.baseUri}") private String baseUrl; @Override public boolean isAdmin() { Authentication authentication = getAuthentication(); if (authentication != null) { Object principal = authentication.getPrincipal(); if (principal instanceof OrcidProfileUserDetails) { OrcidProfileUserDetails userDetails = (OrcidProfileUserDetails) principal; return OrcidType.ADMIN.equals(userDetails.getOrcidType()); } } return false; } @Override public boolean isPasswordConfirmationRequired() { return sourceManager.isInDelegationMode() && !sourceManager.isDelegatedByAnAdmin(); } private Authentication getAuthentication() { SecurityContext context = SecurityContextHolder.getContext(); if (context != null && context.getAuthentication() != null) { return context.getAuthentication(); } return null; } @Override public String getClientIdFromAPIRequest() { SecurityContext context = SecurityContextHolder.getContext(); Authentication authentication = context.getAuthentication(); if (authentication != null && OAuth2Authentication.class.isAssignableFrom(authentication.getClass())) { OAuth2Authentication oAuth2Authentication = (OAuth2Authentication) authentication; OAuth2Request request = oAuth2Authentication.getOAuth2Request(); return request.getClientId(); } return null; } /** * Checks a record status and throw an exception indicating if the profile * have any of the following conditions: - The record is not claimed and is * not old enough nor being accessed by its creator - It is locked - It is * deprecated - It is deactivated * * @throws OrcidDeprecatedException * in case the account is deprecated * @throws OrcidNotClaimedException * in case the account is not claimed * @throws LockedException * in the case the account is locked */ @Override public void checkProfile(String orcid) throws NoResultException, OrcidDeprecatedException, OrcidNotClaimedException, LockedException { ProfileEntity profile = null; try { profile = profileEntityCacheManager.retrieve(orcid); } catch (IllegalArgumentException e) { throw new NoResultException(); } // Check if the user record is deprecated if (profile.getPrimaryRecord() != null) { StringBuffer primary = new StringBuffer(baseUrl).append("/").append(profile.getPrimaryRecord().getId()); Map<String, String> params = new HashMap<String, String>(); params.put(OrcidDeprecatedException.ORCID, primary.toString()); if (profile.getDeprecatedDate() != null) { XMLGregorianCalendar calendar = DateUtils.convertToXMLGregorianCalendar(profile.getDeprecatedDate()); params.put(OrcidDeprecatedException.DEPRECATED_DATE, calendar.toString()); } throw new OrcidDeprecatedException(params); } // Check if the profile is not claimed and not old enough if ((profile.getClaimed() == null || Boolean.FALSE.equals(profile.getClaimed())) && !isOldEnough(profile)) { // Let the creator access the profile even if it is not claimed and // not old enough SourceEntity currentSourceEntity = sourceManager.retrieveSourceEntity(); String profileSource = profile.getSource() == null ? null : profile.getSource().getSourceId(); String currentSource = currentSourceEntity == null ? null : currentSourceEntity.getSourceId(); // If the profile doesn't have source or the current source is not // the profile source, throw an exception if (profileSource == null || !Objects.equals(profileSource, currentSource)) { throw new OrcidNotClaimedException(); } } // Check if the record is locked if (!profile.isAccountNonLocked()) { LockedException lockedException = new LockedException(); lockedException.setOrcid(profile.getId()); throw lockedException; } } private boolean isOldEnough(ProfileEntity profile) { return DateUtils.olderThan(profile.getSubmissionDate(), claimWaitPeriodDays); } @Override public void checkSource(SourceAwareEntity<?> existingEntity) { String sourceIdOfUpdater = sourceManager.retrieveSourceOrcid(); if (sourceIdOfUpdater != null && !(sourceIdOfUpdater.equals(existingEntity.getSourceId()) || sourceIdOfUpdater.equals(existingEntity.getClientSourceId()))) { Map<String, String> params = new HashMap<String, String>(); params.put("activity", "work"); throw new WrongSourceException(params); } } @Override public void checkSource(IdentifierTypeEntity existingEntity) { String sourceIdOfUpdater = sourceManager.retrieveSourceOrcid(); String existingEntitySourceId = existingEntity.getSourceClient() == null ? null : existingEntity.getSourceClient().getId(); if (!Objects.equals(sourceIdOfUpdater, existingEntitySourceId)) { Map<String, String> params = new HashMap<String, String>(); params.put("activity", "work"); throw new WrongSourceException(params); } } @Override public void checkScopes(ScopePathType requiredScope) { //Verify the client is not a public client checkClientType(); OAuth2Authentication oAuth2Authentication = getOAuth2Authentication(); OAuth2Request authorizationRequest = oAuth2Authentication.getOAuth2Request(); Set<ScopePathType> requestedScopes = ScopePathType.getScopesFromStrings(authorizationRequest.getScope()); for (ScopePathType scope : requestedScopes) { if (scope.hasScope(requiredScope)) { return; } } throw new OrcidAccessControlException(); } @Override public void checkAndFilter(String orcid, Collection<? extends VisibilityType> elements, ScopePathType requiredScope) { checkAndFilter(orcid, elements, requiredScope, false); } private void checkAndFilter(String orcid, Collection<? extends VisibilityType> elements, ScopePathType requiredScope, boolean tokenAlreadyChecked) { if (elements == null) { return; } // Check the token if (!tokenAlreadyChecked) { isMyToken(orcid); } Iterator<? extends VisibilityType> it = elements.iterator(); while (it.hasNext()) { VisibilityType element = it.next(); try { if(element instanceof Email) { Email email = (Email) element; checkAndFilter(orcid, email, requiredScope, true); } else { checkAndFilter(orcid, element, requiredScope, true); } } catch (Exception e) { it.remove(); } } } @Override public void checkAndFilter(String orcid, ActivitiesSummary activities) { if (activities == null) { return; } // Check the token isMyToken(orcid); // Educations if (activities.getEducations() != null) { checkAndFilter(orcid, activities.getEducations().getSummaries(), READ_AFFILIATIONS_REQUIRED_SCOPE, true); } // Employments if (activities.getEmployments() != null) { checkAndFilter(orcid, activities.getEmployments().getSummaries(), READ_AFFILIATIONS_REQUIRED_SCOPE, true); } // Funding if (activities.getFundings() != null) { Iterator<FundingGroup> groupIt = activities.getFundings().getFundingGroup().iterator(); while (groupIt.hasNext()) { FundingGroup group = groupIt.next(); // Filter the list of elements checkAndFilter(orcid, group.getFundingSummary(), READ_FUNDING_REQUIRED_SCOPE, true); // Clean external identifiers if (group.getFundingSummary().isEmpty()) { groupIt.remove(); } else { filterExternalIdentifiers(group); } } } // PeerReviews if (activities.getPeerReviews() != null) { Iterator<PeerReviewGroup> groupIt = activities.getPeerReviews().getPeerReviewGroup().iterator(); while (groupIt.hasNext()) { PeerReviewGroup group = groupIt.next(); // Filter the list of elements checkAndFilter(orcid, group.getPeerReviewSummary(), READ_PEER_REVIEWS_REQUIRED_SCOPE, true); if (group.getPeerReviewSummary().isEmpty()) { groupIt.remove(); } } } // Works if (activities.getWorks() != null) { Iterator<WorkGroup> groupIt = activities.getWorks().getWorkGroup().iterator(); while (groupIt.hasNext()) { WorkGroup group = groupIt.next(); // Filter the list of elements checkAndFilter(orcid, group.getWorkSummary(), READ_WORKS_REQUIRED_SCOPE, true); // Clean external identifiers if (group.getWorkSummary().isEmpty()) { groupIt.remove(); } else { filterExternalIdentifiers(group); } } } } @Override public void checkAndFilter(String orcid, PersonalDetails personalDetails) { if (personalDetails == null) { return; } // Check the token isMyToken(orcid); if (personalDetails.getOtherNames() != null) { checkAndFilter(orcid, personalDetails.getOtherNames().getOtherNames(), READ_BIO_REQUIRED_SCOPE, true); } if (personalDetails.getBiography() != null) { try { checkAndFilter(orcid, personalDetails.getBiography(), READ_BIO_REQUIRED_SCOPE, true); } catch (Exception e) { personalDetails.setBiography(null); } } if (personalDetails.getName() != null) { try { checkAndFilter(orcid, personalDetails.getName(), READ_BIO_REQUIRED_SCOPE, true); } catch (Exception e) { personalDetails.setName(null); } } } @Override public void checkAndFilter(String orcid, Person person) { if (person == null) { return; } // Check the token isMyToken(orcid); if (person.getAddresses() != null) { checkAndFilter(orcid, person.getAddresses().getAddress(), READ_BIO_REQUIRED_SCOPE, true); } if (person.getBiography() != null) { try { checkAndFilter(orcid, person.getBiography(), READ_BIO_REQUIRED_SCOPE, true); } catch (Exception e) { person.setBiography(null); } } if (person.getEmails() != null) { checkAndFilter(orcid, person.getEmails().getEmails(), READ_BIO_REQUIRED_SCOPE, true); } if (person.getExternalIdentifiers() != null) { checkAndFilter(orcid, person.getExternalIdentifiers().getExternalIdentifiers(), READ_BIO_REQUIRED_SCOPE, true); } if (person.getKeywords() != null) { checkAndFilter(orcid, person.getKeywords().getKeywords(), READ_BIO_REQUIRED_SCOPE, true); } if (person.getName() != null) { try { checkAndFilter(orcid, person.getName(), READ_BIO_REQUIRED_SCOPE, true); } catch (Exception e) { person.setName(null); } } if (person.getOtherNames() != null) { checkAndFilter(orcid, person.getOtherNames().getOtherNames(), READ_BIO_REQUIRED_SCOPE, true); } if (person.getResearcherUrls() != null) { checkAndFilter(orcid, person.getResearcherUrls().getResearcherUrls(), READ_BIO_REQUIRED_SCOPE, true); } } @Override public void checkAndFilter(String orcid, Record record) { if (record == null) { return; } // Check the token isMyToken(orcid); if (record.getActivitiesSummary() != null) { checkAndFilter(orcid, record.getActivitiesSummary()); } if (record.getPerson() != null) { checkAndFilter(orcid, record.getPerson()); } } @Override public void checkAndFilter(String orcid, WorkBulk workBulk, ScopePathType scopePathType) { isMyToken(orcid); List<BulkElement> bulkElements = workBulk.getBulk(); List<BulkElement> filteredElements = new ArrayList<>(); for (int i = 0; i < bulkElements.size(); i++) { BulkElement element = bulkElements.get(i); if (element instanceof OrcidError) { filteredElements.add(element); continue; } try { checkAndFilter(orcid, (Work) element, scopePathType, true); filteredElements.add(element); } catch (Exception e) { if (e instanceof OrcidUnauthorizedException) { throw e; } OrcidError error = orcidCoreExceptionMapper.getOrcidError(e); filteredElements.add(error); } } workBulk.setBulk(filteredElements); } @Override public void checkClientAccessAndScopes(String orcid, ScopePathType requiredScope) { // Check the token belongs to the user isMyToken(orcid); // Check you have the required scopes checkScopes(requiredScope); } /** * Check the permissions of a request over an email. * * @param orcid * The user owner of the element * @param email * The email to check * @param requiredScope * The required scope to access this element * @throws OrcidUnauthorizedException * In case the token used was not issued for the owner of the * element * @throws OrcidAccessControlException * In case the request doesn't have the required scopes * @throws OrcidVisibilityException * In case the element is not visible due the visibility */ @Override public void checkAndFilter(String orcid, Email email, ScopePathType requiredScope) { checkAndFilter(orcid, email, requiredScope, false); } /** * Check the permissions of a request over an email. Private * implementation that will also include a parameter that indicates if we * should check the token or, if it was already checked previously * * @param orcid * The user owner of the element * @param email * The email to check * @param requiredScope * The required scope to access this element * @param tokenAlreadyChecked * Indicates if the token was already checked previously, so, we * don't expend time checking it again * @throws OrcidUnauthorizedException * In case the token used was not issued for the owner of the * element * @throws OrcidAccessControlException * In case the request doesn't have the required scopes * @throws OrcidVisibilityException * In case the element is not visible due the visibility */ private void checkAndFilter(String orcid, Email email, ScopePathType requiredScope, boolean tokenAlreadyChecked) { if (email == null) { return; } // Check the token was issued for this user if (!tokenAlreadyChecked) { isMyToken(orcid); } try { checkScopes(ScopePathType.EMAIL_READ_PRIVATE); return; } catch(OrcidAccessControlException oace) { checkAndFilter(orcid, (VisibilityType) email, READ_BIO_REQUIRED_SCOPE, true); } } /** * Check the permissions of a request over an element. * * @param orcid * The user owner of the element * @param element * The element to check * @param requiredScope * The required scope to access this element * @throws OrcidUnauthorizedException * In case the token used was not issued for the owner of the * element * @throws OrcidAccessControlException * In case the request doesn't have the required scopes * @throws OrcidVisibilityException * In case the element is not visible due the visibility */ @Override public void checkAndFilter(String orcid, VisibilityType element, ScopePathType requiredScope) { checkAndFilter(orcid, element, requiredScope, false); } /** * Check the permissions of a request over an element. Private * implementation that will also include a parameter that indicates if we * should check the token or, if it was already checked previously * * @param orcid * The user owner of the element * @param element * The element to check * @param requiredScope * The required scope to access this element * @param tokenAlreadyChecked * Indicates if the token was already checked previously, so, we * don't expend time checking it again * @throws OrcidUnauthorizedException * In case the token used was not issued for the owner of the * element * @throws OrcidAccessControlException * In case the request doesn't have the required scopes * @throws OrcidVisibilityException * In case the element is not visible due the visibility */ private void checkAndFilter(String orcid, VisibilityType element, ScopePathType requiredScope, boolean tokenAlreadyChecked) { if (element == null) { return; } // Check the token was issued for this user if (!tokenAlreadyChecked) { isMyToken(orcid); } // Check if the client is the source of the element if (element instanceof Filterable) { Filterable filterable = (Filterable) element; OAuth2Authentication oAuth2Authentication = getOAuth2Authentication(); if (oAuth2Authentication != null) { OAuth2Request authorizationRequest = oAuth2Authentication.getOAuth2Request(); String clientId = authorizationRequest.getClientId(); if (clientId.equals(filterable.retrieveSourcePath())) { // The client doing the request is the source of the element return; } } } // Check if the element is public and the token contains the // /read-public scope if (Visibility.PUBLIC.equals(element.getVisibility())) { try { checkScopes(ScopePathType.READ_PUBLIC); // This means it have ScopePathType.READ_PUBLIC scope, so, we // can return it return; } catch (OrcidAccessControlException e) { // Just continue filtering } } // Filter filter(element, requiredScope); } /** * Filter the group external identifiers to match the external identifiers * that belongs to the activities it have after filtering * * @param group * The group we want to filter the external identifiers */ private void filterExternalIdentifiers(Group group) { // Iterate over every external identifier and check if it is still // present in the list of filtered elements ExternalIDs extIds = group.getIdentifiers(); Iterator<ExternalID> extIdsIt = extIds.getExternalIdentifier().iterator(); while (extIdsIt.hasNext()) { ExternalID extId = extIdsIt.next(); boolean found = false; for (GroupableActivity summary : group.getActivities()) { if (summary.getExternalIdentifiers() != null) { if (summary.getExternalIdentifiers().getExternalIdentifier().contains(extId)) { found = true; break; } } } // If the ext id is not found, remove it from the list of ext ids if (!found) { extIdsIt.remove(); } } } private void filter(VisibilityType element, ScopePathType requiredScope) { // Check the request have the required scope checkScopes(requiredScope); if (requiredScope.isReadOnlyScope()) { if (Visibility.PRIVATE.equals(element.getVisibility())) { throw new OrcidVisibilityException(); } } else { throw new IllegalArgumentException("Only 'read-only' scopes are allowed"); } } private boolean isNonClientCredentialScope(OAuth2Authentication oAuth2Authentication) { OAuth2Request authorizationRequest = oAuth2Authentication.getOAuth2Request(); Set<String> requestedScopes = ScopePathType.getCombinedScopesFromStringsAsStrings(authorizationRequest.getScope()); for (String scopeName : requestedScopes) { ScopePathType scopePathType = ScopePathType.fromValue(scopeName); if (!scopePathType.isClientCreditalScope()) { return true; } } return false; } private boolean clientIsProfileSource(String clientId, ProfileEntity profile) { Boolean claimed = profile.getClaimed(); SourceEntity source = profile.getSource(); return source != null && (claimed == null || !claimed) && clientId.equals(source.getSourceId()); } private OAuth2Authentication getOAuth2Authentication() { SecurityContext context = SecurityContextHolder.getContext(); if (context != null && context.getAuthentication() != null) { Authentication authentication = context.getAuthentication(); if (OAuth2Authentication.class.isAssignableFrom(authentication.getClass())) { OAuth2Authentication oAuth2Authentication = (OAuth2Authentication) authentication; return oAuth2Authentication; } else { for (GrantedAuthority grantedAuth : authentication.getAuthorities()) { if ("ROLE_ANONYMOUS".equals(grantedAuth.getAuthority())) { // Assume that anonymous authority is like not having // authority at all return null; } } throw new AccessControlException( "Cannot access method with authentication type " + authentication != null ? authentication.toString() : ", as it's null!"); } } else { throw new IllegalStateException("No security context found. This is bad!"); } } private void isMyToken(String orcid) { OAuth2Authentication oAuth2Authentication = getOAuth2Authentication(); if (oAuth2Authentication == null) { throw new OrcidUnauthorizedException("No OAuth2 authentication found"); } //Verify the client is not a public client checkClientType(); String clientId = sourceManager.retrieveSourceOrcid(); ProfileEntity profile = profileEntityCacheManager.retrieve(orcid); Authentication userAuthentication = oAuth2Authentication.getUserAuthentication(); if (userAuthentication != null) { Object principal = userAuthentication.getPrincipal(); if (principal instanceof ProfileEntity) { ProfileEntity profileEntity = (ProfileEntity) principal; if (!orcid.equals(profileEntity.getId())) { throw new OrcidUnauthorizedException("Access token is for a different record"); } } else { throw new OrcidUnauthorizedException("Missing user authentication"); } } else if (isNonClientCredentialScope(oAuth2Authentication) && !clientIsProfileSource(clientId, profile)) { throw new IllegalStateException("Non client credential scope found in client request"); } } private void checkClientType() { String clientId = sourceManager.retrieveSourceOrcid(); ClientDetailsEntity client = clientDetailsEntityCacheManager.retrieve(clientId); if(client.getClientType() == null || ClientType.PUBLIC_CLIENT.equals(client.getClientType())) { throw new OrcidUnauthorizedException("The client application is forbidden to perform the action."); } } }