diff --git a/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/IPortletList.java b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/IPortletList.java new file mode 100644 index 00000000000..3741d34f0fb --- /dev/null +++ b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/IPortletList.java @@ -0,0 +1,30 @@ +package org.apereo.portal.dao.portletlist; + +import java.util.List; +import org.apereo.portal.dao.portletlist.jpa.PortletListItem; +import org.apereo.portal.security.IPerson; + +public interface IPortletList { + + String getId(); + + String getOwnerUsername(); + + void setOwnerUsername(String username); + + String getName(); + + void setName(String name); + + List getItems(); + + // Don't use this setter directly - instead use clearAndSetItems(...). Hibernate uses this + // method to handle its own List management + void setItems(List items); + + String toString(); + + void clearAndSetItems(List items); + + void prepareForPersistence(IPerson requester); +} diff --git a/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/IPortletListDao.java b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/IPortletListDao.java new file mode 100644 index 00000000000..64fd444a345 --- /dev/null +++ b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/IPortletListDao.java @@ -0,0 +1,21 @@ +package org.apereo.portal.dao.portletlist; + +import java.util.List; +import org.apereo.portal.security.IPerson; + +public interface IPortletListDao { + + public List getPortletLists(String ownerUsername); + + public List getPortletLists(); + + public IPortletList getPortletList(String portletListUuid); + + public IPortletList createPortletList(IPortletList toCreate, IPerson requester); + + public IPortletList updatePortletList(IPortletList toUpdate, IPerson requester); + + public boolean removePortletListAsAdmin(String portletListUuid, IPerson adminRequester); + + public boolean removePortletListAsOwner(String portletListUuid, IPerson ownerRequester); +} diff --git a/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/IPortletListItem.java b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/IPortletListItem.java new file mode 100644 index 00000000000..ab9723fabc7 --- /dev/null +++ b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/IPortletListItem.java @@ -0,0 +1,20 @@ +package org.apereo.portal.dao.portletlist; + +import org.apereo.portal.dao.portletlist.jpa.PortletList; + +public interface IPortletListItem { + + PortletList getPortletList(); + + void setPortletList(PortletList portletList); + + int getListOrder(); + + void setListOrder(int listOrder); + + String getEntityId(); + + void setEntityId(String id); + + String toString(); +} diff --git a/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/jpa/PortletList.java b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/jpa/PortletList.java new file mode 100644 index 00000000000..dac9aea7e16 --- /dev/null +++ b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/jpa/PortletList.java @@ -0,0 +1,171 @@ +package org.apereo.portal.dao.portletlist.jpa; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import javax.persistence.*; +import javax.persistence.CascadeType; +import javax.persistence.Entity; +import javax.persistence.OrderBy; +import javax.persistence.Table; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.apereo.portal.dao.portletlist.IPortletList; +import org.apereo.portal.rest.utils.InputValidator; +import org.apereo.portal.security.IPerson; +import org.hibernate.annotations.*; +import org.springframework.util.StringUtils; + +@Getter +@Setter +@EqualsAndHashCode +@Slf4j +@Entity +@Table( + name = "UP_PORTLET_LIST", + uniqueConstraints = {@UniqueConstraint(columnNames = {"OWNER_USERNAME", "NAME"})}) +@Cacheable +@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) +@SuppressWarnings("unused") +public class PortletList implements IPortletList { + private static final ZoneId tz = ZoneId.systemDefault(); + private static final String AUDIT_DATE_FORMAT = "yyyy-MM-dd hh:mm:ss Z"; + + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @Id + @GeneratedValue(generator = "UUID") + @GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator") + @Column(name = "ID", updatable = false, nullable = false) + private String id; + + @Column(name = "OWNER_USERNAME", updatable = true, nullable = false) + private String ownerUsername; + + @Column(name = "NAME", updatable = true, nullable = false) + private String name; + + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @Column(name = "CREATED_BY", updatable = false, nullable = false) + private String createdBy; + + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = AUDIT_DATE_FORMAT) + @Column(name = "CREATED_ON", updatable = false, nullable = false) + private Timestamp createdOn; + + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @Column(name = "UPDATED_BY", updatable = true, nullable = false) + private String updatedBy; + + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = AUDIT_DATE_FORMAT) + @Column(name = "UPDATED_ON", updatable = true, nullable = false) + private Timestamp updatedOn; + + @OneToMany( + targetEntity = PortletListItem.class, + cascade = CascadeType.ALL, + fetch = FetchType.EAGER, + mappedBy = "portletList", + orphanRemoval = true) + @org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) + @Fetch(FetchMode.SELECT) // FM JOIN does BAD things to collections that support duplicates + @OrderBy("LIST_ORDER ASC") + private List items; + + public void clearAndSetItems(List items) { + if (this.items == null) { + this.items = new ArrayList<>(); + } + + // Index all current items + HashMap existingItems = new HashMap<>(); + for (PortletListItem existingItem : this.items) { + existingItems.put(existingItem.getEntityId(), existingItem); + } + + this.items.clear(); + + for (PortletListItem item : items) { + PortletListItem existingItem = existingItems.get(item.getEntityId()); + + if (existingItem != null) { + // If any item specific attributes are configured, specifically copy them over here. + // Order will be set in prepareForPersistence() + existingItem.setListOrder(-1); + this.items.add(existingItem); + } else { + this.items.add(item); + } + } + } + + /** + * Final step before letting the object be persisted or merged via the entity manager. + * + *

Validation is in part, a fail-safe to ensure SQL injections are checked. + * + * @param requester + */ + public void prepareForPersistence(IPerson requester) { + if (!StringUtils.isEmpty(this.name)) { + InputValidator.validateAsWordCharacters(this.name, "name"); + } + + if (!StringUtils.isEmpty(this.ownerUsername)) { + InputValidator.validateAsWordCharacters(this.ownerUsername, "ownerUsername"); + } + + if (this.items != null) { + int order = 0; + for (PortletListItem item : this.items) { + InputValidator.validateAsWordCharacters(item.getEntityId(), "items > entityId"); + + InputValidator.validateUniqueEntityId(this.items, item); + + item.setPortletList(this); + item.setListOrder(order++); + } + } + + // Set / Update audit fields + if (this.createdOn == null) { + this.createdOn = this.updatedOn = Timestamp.valueOf(LocalDateTime.now(tz)); + this.createdBy = this.updatedBy = requester.getUserName(); + } else { + this.updatedOn = Timestamp.valueOf(LocalDateTime.now(tz)); + this.updatedBy = requester.getUserName(); + } + } + + public String toString() { + StringBuffer sb = new StringBuffer(); + sb.append("PortletList id=["); + sb.append(id); + sb.append("], name=["); + sb.append(name); + sb.append("], owner=["); + sb.append(ownerUsername); + sb.append("], items=: "); + if (this.items.size() < 1) { + sb.append("[Currently no items]"); + } else { + final int size = items.size(); + for (int i = 0; i < size; i++) { + PortletListItem item = items.get(i); + sb.append(item); + if (i < size - 1) { + sb.append("; "); + } + } + } + return sb.toString(); + } +} diff --git a/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/jpa/PortletListDao.java b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/jpa/PortletListDao.java new file mode 100644 index 00000000000..aab0a8104c8 --- /dev/null +++ b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/jpa/PortletListDao.java @@ -0,0 +1,197 @@ +package org.apereo.portal.dao.portletlist.jpa; + +import com.google.common.base.Function; +import java.util.*; +import javax.persistence.TypedQuery; +import javax.persistence.criteria.*; +import lombok.extern.slf4j.Slf4j; +import org.apereo.portal.dao.portletlist.IPortletList; +import org.apereo.portal.dao.portletlist.IPortletListDao; +import org.apereo.portal.jpa.BasePortalJpaDao; +import org.apereo.portal.security.IPerson; +import org.springframework.stereotype.Repository; + +@Slf4j +@Repository("portletListDao") +public class PortletListDao extends BasePortalJpaDao implements IPortletListDao { + + private CriteriaQuery portletListsQuery; + + private CriteriaQuery portletListsByUserIdQuery; + + private CriteriaQuery portletListByPortletListUuidQuery; + + private ParameterExpression ownerUsernameParameter; + + private ParameterExpression portletListUuidParameter; + + @Override + public void afterPropertiesSet() throws Exception { + this.ownerUsernameParameter = + this.createParameterExpression(String.class, "OWNER_USERNAME"); + this.portletListUuidParameter = this.createParameterExpression(String.class, "ID"); + + this.portletListsQuery = + this.createCriteriaQuery( + new Function>() { + @Override + public CriteriaQuery apply(CriteriaBuilder cb) { + final CriteriaQuery criteriaQuery = + cb.createQuery(PortletList.class); + Root root = criteriaQuery.from(PortletList.class); + criteriaQuery.select(root); + return criteriaQuery; + } + }); + + this.portletListsByUserIdQuery = + this.createCriteriaQuery( + new Function>() { + @Override + public CriteriaQuery apply(CriteriaBuilder cb) { + final CriteriaQuery criteriaQuery = + cb.createQuery(PortletList.class); + Root root = criteriaQuery.from(PortletList.class); + criteriaQuery + .select(root) + .where( + cb.equal( + root.get("ownerUsername"), + ownerUsernameParameter)); + return criteriaQuery; + } + }); + + this.portletListByPortletListUuidQuery = + this.createCriteriaQuery( + new Function>() { + @Override + public CriteriaQuery apply(CriteriaBuilder cb) { + final CriteriaQuery criteriaQuery = + cb.createQuery(PortletList.class); + Root root = criteriaQuery.from(PortletList.class); + criteriaQuery + .select(root) + .where(cb.equal(root.get("id"), portletListUuidParameter)); + return criteriaQuery; + } + }); + } + + @PortalTransactionalReadOnly + @Override + public List getPortletLists(String ownerUsername) { + TypedQuery query = this.createCachedQuery(portletListsByUserIdQuery); + query.setParameter(ownerUsernameParameter, ownerUsername); + List entities = new ArrayList<>(query.getResultList()); + return entities; + } + + @PortalTransactionalReadOnly + @Override + public List getPortletLists() { + TypedQuery query = this.createCachedQuery(portletListsQuery); + List entities = new ArrayList<>(query.getResultList()); + return entities; + } + + @PortalTransactionalReadOnly + @Override + public IPortletList getPortletList(String portletListUuid) { + TypedQuery query = this.createCachedQuery(portletListByPortletListUuidQuery); + query.setParameter(portletListUuidParameter, portletListUuid); + + List lists = query.getResultList(); + if (lists.size() < 1) { + return null; + } else if (lists.size() > 1) { + log.error( + "Expected up to 1 portlet list for portlet list uuid [{}], but found [{}].", + portletListUuid, + lists.size()); + return null; + } + + return lists.get(0); + } + + @SuppressWarnings("unused") + @PortalTransactional + @Override + public IPortletList createPortletList(IPortletList toCreate, IPerson requester) { + log.debug( + "Persisting portlet list [{}] with owner [{}]", + toCreate.getName(), + toCreate.getOwnerUsername()); + try { + toCreate.prepareForPersistence(requester); + this.getEntityManager().persist(toCreate); + } catch (Exception e) { + log.debug("Failed to persist portlet list", e); + throw e; + } + log.debug( + "Finished persisting portlet list [{}] for owner [{}]. ID = [{}]", + toCreate.getName(), + toCreate.getOwnerUsername(), + toCreate.getId().toString()); + return toCreate; + } + + @PortalTransactional + @Override + public IPortletList updatePortletList(IPortletList toUpdate, IPerson requester) { + log.debug("Persisting changes for portlet list [{}]", toUpdate.getId()); + try { + toUpdate.prepareForPersistence(requester); + log.debug("Portlet List to update: {}", toUpdate); + this.getEntityManager().merge(toUpdate); + log.debug("Finished persisting changes for portlet list [{}]", toUpdate.getId()); + } catch (Exception e) { + log.debug("Failed to persist changes for portlet list", e); + throw e; + } + return toUpdate; + } + + @PortalTransactional + @Override + public boolean removePortletListAsAdmin(String portletListUuid, IPerson requester) { + IPortletList list = this.getPortletList(portletListUuid); + if (list == null) { + log.warn( + "Admin user [{}] tried to remove a non-existent list [{}]. Failing request.", + requester.getUserName(), + portletListUuid); + return false; + } + this.getEntityManager().remove(list); + return true; + } + + @PortalTransactional + @Override + public boolean removePortletListAsOwner(String portletListUuid, IPerson requester) { + IPortletList list = this.getPortletList(portletListUuid); + if (list == null) { + log.warn( + "Non-admin user [{}] tried to remove a non-existent list [{}]. Failing request.", + requester.getUserName(), + portletListUuid); + return false; + } else if (!list.getOwnerUsername().equals(requester.getUserName())) { + log.warn( + "Non-admin user [{}] tried to remove a list they didn't own [{}]. Failing request.", + requester.getUserName(), + portletListUuid); + return false; + } + + log.debug( + "Non-admin user [{}] requested to remove a list they own [{}]. Allowing request.", + requester.getUserName(), + portletListUuid); + this.getEntityManager().remove(list); + return true; + } +} diff --git a/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/jpa/PortletListItem.java b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/jpa/PortletListItem.java new file mode 100644 index 00000000000..93a1c299c12 --- /dev/null +++ b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/dao/portletlist/jpa/PortletListItem.java @@ -0,0 +1,73 @@ +package org.apereo.portal.dao.portletlist.jpa; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import javax.persistence.*; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import org.apereo.portal.dao.portletlist.IPortletListItem; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.GenericGenerator; + +@Getter +@Setter +@EqualsAndHashCode +@ToString +@Slf4j +@Entity +@Table( + // This is ONLY to be used as part of a portlet list, so not specifying a PK + name = "UP_PORTLET_LIST_ITEM", + uniqueConstraints = { + // Only allow sets of lists + @UniqueConstraint(columnNames = {"LIST_ID", "LIST_ORDER", "ENTITY_ID"}), + // Only allow sets of portlets in the list + @UniqueConstraint(columnNames = {"LIST_ID", "ENTITY_ID"}), + }) +@Cacheable +@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) +@SuppressWarnings("unused") +public class PortletListItem implements IPortletListItem { + + @JsonIgnore + @Id + @GeneratedValue(generator = "UUID") + @GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator") + @Column(name = "ID", updatable = false, nullable = false) + private String id; + + @JsonIgnore + @ManyToOne(optional = false) + @JoinColumn(name = "LIST_ID", referencedColumnName = "ID") + private PortletList portletList; + + @JsonIgnore + @Column(name = "LIST_ORDER", unique = false, nullable = false, updatable = true) + private int listOrder; + + // This is generally the portlet fname, but could be adjusted in the future + @Column(name = "ENTITY_ID", updatable = true, nullable = false) + private String entityId; + + public PortletListItem() { + // No-arg constructor for JSON mapping + } + + public PortletListItem(String entityId) { + this.entityId = entityId; + } + + public String toString() { + return "PortletListItem: id=[" + + id + + "], portlet-list=[" + + (portletList == null ? "NULL" : portletList.getId()) + + "], order=[" + + listOrder + + "], entity-id=[" + + entityId + + "]"; + } +} diff --git a/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/rest/portletlist/PortletListRESTController.java b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/rest/portletlist/PortletListRESTController.java new file mode 100644 index 00000000000..ff6fb64499c --- /dev/null +++ b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/rest/portletlist/PortletListRESTController.java @@ -0,0 +1,516 @@ +package org.apereo.portal.rest.portletlist; + +import static org.springframework.web.bind.annotation.RequestMethod.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apereo.portal.dao.portletlist.IPortletList; +import org.apereo.portal.dao.portletlist.jpa.PortletList; +import org.apereo.portal.rest.utils.ErrorResponse; +import org.apereo.portal.rest.utils.InputValidator; +import org.apereo.portal.security.IPerson; +import org.apereo.portal.security.IPersonManager; +import org.apereo.portal.security.RuntimeAuthorizationException; +import org.apereo.portal.services.portletlist.IPortletListService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Controller; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +/** PortletListRESTController provides a REST endpoint for interacting with portlet lists. */ +@Controller +@Slf4j +public class PortletListRESTController { + public static final String CONTEXT = "/portlet-list/"; + public static final String FAVORITES_KEYWORD = "favorites"; + @Autowired private IPortletListService portletListService; + + @Autowired private IPersonManager personManager; + + @Autowired private ObjectMapper objectMapper; + + /** + * Provide a JSON view of all portlet lists + * + *

If an administrator makes this call, ALL portlet lists will be returned. Otherwise, only + * the portlet lists that the requester owns will be returned. + */ + @RequestMapping(value = CONTEXT, method = GET, produces = MediaType.APPLICATION_JSON_VALUE) + public @ResponseBody String getPortletLists( + HttpServletRequest request, HttpServletResponse response) { + final IPerson person = personManager.getPerson(request); + if (log.isDebugEnabled()) { + debugPerson("getAllPortletLists", person); + } + + if (person.isGuest()) { + log.warn("Guest is trying to access portlet-list API, which is not allowed."); + return prepareResponse( + response, null, "Not authorized", HttpServletResponse.SC_UNAUTHORIZED); + } + + List pLists = + portletListService.isPortletListAdmin(person) + ? portletListService.getPortletLists() + : portletListService.getPortletLists(person); + return prepareResponse(response, pLists, null, HttpServletResponse.SC_OK); + } + + /** + * Provide a JSON view of a given portlet list + * + *

If an administrator makes the request, the portlet list will be returned, regardless of + * ownership. If anyone else makes the request, the portlet list will only be returned if the + * requester is the owner. + */ + @RequestMapping( + value = CONTEXT + "{portletListUuid}", + method = GET, + produces = MediaType.APPLICATION_JSON_VALUE) + public @ResponseBody String getPortletList( + HttpServletRequest request, + HttpServletResponse response, + @PathVariable String portletListUuid) { + final IPerson person = personManager.getPerson(request); + if (log.isDebugEnabled()) { + debugPerson("getSpecificPortletList", person); + } + + if (person.isGuest()) { + log.warn("Guest is trying to access portlet-list API, which is not allowed."); + return prepareResponse( + response, null, "Not authorized", HttpServletResponse.SC_UNAUTHORIZED); + } + + // Input validation prior to logging any values to protect against logging security attacks + try { + if (!StringUtils.isEmpty(portletListUuid)) + InputValidator.validateAsWordCharacters(portletListUuid, "Portlet List UUID"); + } catch (IllegalArgumentException iae) { + log.warn("IllegalArgumentException thrown - {}", iae.getMessage(), iae); + return prepareResponse( + response, null, iae.getMessage(), HttpServletResponse.SC_CONFLICT); + } + + IPortletList pList = portletListService.getPortletList(portletListUuid); + + if (pList == null) { + log.warn( + "User [{}] tried to access portlet-list [{}], but list was not found.", + person.getUserName(), + portletListUuid); + return prepareResponse( + response, null, "Entity not found", HttpServletResponse.SC_NOT_FOUND); + } else if (!portletListService.isPortletListAdmin(person) + && !pList.getOwnerUsername().equals(person.getUserName())) { + // Not an admin, and not the owner + log.warn( + "Non-admin user [{}] tried to access portlet-list [{}] with owner [{}], but was blocked since they aren't the owner.", + person.getUserName(), + portletListUuid, + pList.getOwnerUsername()); + return prepareResponse( + response, null, "Entity not found", HttpServletResponse.SC_NOT_FOUND); + } + + return prepareResponse(response, pList, null, HttpServletResponse.SC_OK); + } + + /** + * Create a portlet list + * + *

If an administrator makes the request: - The owner can be specified. Owner will default to + * the current logged in user. - The name can be 'favorites' + */ + @RequestMapping(value = CONTEXT, method = POST, produces = MediaType.APPLICATION_JSON_VALUE) + public @ResponseBody String createPortletList( + HttpServletRequest request, HttpServletResponse response, @RequestBody String json) { + + final IPerson person = personManager.getPerson(request); + if (log.isDebugEnabled()) { + debugPerson("createPortletList", person); + } + + if (person.isGuest()) { + log.warn( + "createPortletList > Guest is trying to access portlet-list API, which is not allowed."); + return prepareResponse( + response, null, "Not authorized", HttpServletResponse.SC_UNAUTHORIZED); + } + + PortletList input; + + try { + input = objectMapper.readValue(json, PortletList.class); + + // Input validation prior to logging any values to protect against logging security + // attacks + try { + if (!StringUtils.isEmpty(input.getOwnerUsername())) + InputValidator.validateAsWordCharacters( + input.getOwnerUsername(), "ownerUsername"); + if (!StringUtils.isEmpty(input.getName())) + InputValidator.validateAsWordCharacters(input.getName(), "name"); + } catch (IllegalArgumentException iae) { + log.warn("IllegalArgumentException thrown - {}", iae.getMessage(), iae); + return prepareResponse( + response, null, iae.getMessage(), HttpServletResponse.SC_CONFLICT); + } + + if (portletListService.isPortletListAdmin(person)) { + if (StringUtils.isEmpty(input.getOwnerUsername())) { + // Default - admins don't have to specify a user name + input.setOwnerUsername(person.getUserName()); + } + } else { + if (StringUtils.isEmpty(input.getOwnerUsername())) { + // non-admins can only create lists for themselves. + input.setOwnerUsername(person.getUserName()); + } else { + if (!StringUtils.isEmpty(input.getOwnerUsername()) + && !person.getUserName().equals(input.getOwnerUsername())) { + log.warn( + "non-admin user [{}] tried to create a portlet-list [{}] with a different owner [{}], which is not allowed.", + person.getUserName(), + input.getName(), + input.getOwnerUsername()); + return prepareResponse( + response, + null, + "Non-admin user cannot set portlet-list owner", + HttpServletResponse.SC_BAD_REQUEST); + } + } + + if (FAVORITES_KEYWORD.equals(input.getName())) { + log.warn( + "non-admin user [{}] tried to create a portlet-list [{}], which is a reserved keyword, which is not allowed.", + person.getUserName(), + input.getName(), + FAVORITES_KEYWORD); + return prepareResponse( + response, + null, + "Non-admin user cannot set portlet-list name to " + FAVORITES_KEYWORD, + HttpServletResponse.SC_BAD_REQUEST); + } + } + + } catch (Exception e) { + log.warn( + "User [{}] tried to create a portlet-list with bad json.", + person.getUserName(), + e); + return prepareResponse( + response, + null, + "Unparsable portlet-list JSON", + HttpServletResponse.SC_BAD_REQUEST); + } + + try { + final IPortletList created = portletListService.createPortletList(person, input); + // Safe soft redirect since the id is system generated + response.setHeader("Location", created.getId()); + return prepareResponse(response, null, null, HttpServletResponse.SC_CREATED); + } catch (RuntimeAuthorizationException rae) { + log.warn("RuntimeAuthorizationException thrown - {}", rae.getMessage(), rae); + return prepareResponse( + response, null, "not authorized", HttpServletResponse.SC_FORBIDDEN); + } catch (IllegalArgumentException iae) { + log.warn("IllegalArgumentException thrown - {}", iae.getMessage(), iae); + return prepareResponse( + response, null, iae.getMessage(), HttpServletResponse.SC_CONFLICT); + } catch (DataIntegrityViolationException dive) { + log.warn( + "Attempted violation of data integrity when creating a portlet list {}", + dive.getMessage(), + dive); + return prepareResponse( + response, + null, + "Data integrity issue - such as specifying a non-unique name.", + HttpServletResponse.SC_BAD_REQUEST); + } catch (Exception e) { + log.warn("Just hit an exception of type {}", e.getClass().getCanonicalName(), e); + return prepareResponse( + response, + null, + "Something unexpected occurred. Please check with your System Administrator", + HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + + /** + * Update a portlet list. + * + *

If an administrator makes the request: - the owner can be specified - the name can be + * updated to 'favorites' - any list in the system can be updated + */ + @RequestMapping( + value = CONTEXT + "{portletListUuid}", + method = PUT, + produces = MediaType.APPLICATION_JSON_VALUE) + public @ResponseBody String updatePortletList( + HttpServletRequest request, + HttpServletResponse response, + @RequestBody String json, + @PathVariable String portletListUuid) { + + final IPerson person = personManager.getPerson(request); + if (log.isDebugEnabled()) { + debugPerson("updatePortletList", person); + } + + if (person.isGuest()) { + log.warn("Guest user is trying to access portlet-list PUT API, which is not allowed."); + return prepareResponse( + response, null, "Not authorized", HttpServletResponse.SC_UNAUTHORIZED); + } + + IPortletList input; + try { + input = objectMapper.readValue(json, PortletList.class); + } catch (Exception e) { + log.warn( + "User [{}] tried to update a portlet-list with bad json", + person.getUserName(), + e); + return prepareResponse( + response, + null, + "Unparsable portlet-list JSON", + HttpServletResponse.SC_BAD_REQUEST); + } + + // Input validation prior to logging any values to protect against logging security attacks + try { + if (!StringUtils.isEmpty(portletListUuid)) + InputValidator.validateAsWordCharacters(portletListUuid, "Portlet List UUID"); + if (!StringUtils.isEmpty(input.getOwnerUsername())) + InputValidator.validateAsWordCharacters(input.getOwnerUsername(), "ownerUsername"); + if (!StringUtils.isEmpty(input.getName())) + InputValidator.validateAsWordCharacters(input.getName(), "name"); + } catch (IllegalArgumentException iae) { + log.warn("IllegalArgumentException thrown - {}", iae.getMessage(), iae); + return prepareResponse( + response, null, iae.getMessage(), HttpServletResponse.SC_CONFLICT); + } + + // Overlay changes onto a known entity, and then persist that entity. + IPortletList toUpdate = portletListService.getPortletList(portletListUuid); + + if (toUpdate == null) { + return prepareResponse( + response, null, "Unknown portlet-list", HttpServletResponse.SC_NOT_FOUND); + } + + if (portletListService.isPortletListAdmin(person)) { + // If admin, allow admin-level changes + if (!StringUtils.isEmpty(input.getOwnerUsername())) { + toUpdate.setOwnerUsername(input.getOwnerUsername()); + } + } else if (toUpdate.getOwnerUsername().equals(person.getUserName())) { + // If owner of portlet-list, allow only owner-level changes + if (!StringUtils.isEmpty(input.getOwnerUsername())) { + log.warn( + "non-admin user [{}] tried to update portlet-list [{}][{}] with a new owner [{}], which is not allowed.", + person.getUserName(), + toUpdate.getId(), + toUpdate.getName(), + input.getOwnerUsername()); + return prepareResponse( + response, + null, + "Non-admin user cannot change portlet-list owner", + HttpServletResponse.SC_BAD_REQUEST); + } + + // Only admins can change a portlet list name to the reserved keyword 'favorites'. + if (FAVORITES_KEYWORD.equals(input.getName()) + && !FAVORITES_KEYWORD.equals(toUpdate.getName())) { + log.warn( + "non-admin user [{}] tried to update portlet-list [{}][{}] with a name to the reserved keyword of [{}], which is not allowed.", + person.getUserName(), + toUpdate.getId(), + toUpdate.getName(), + FAVORITES_KEYWORD); + return prepareResponse( + response, + null, + "Non-admin user cannot change portlet-list name to " + FAVORITES_KEYWORD, + HttpServletResponse.SC_BAD_REQUEST); + } + } else { + // Otherwise, disallow changes + log.warn( + "user [{}] tried to update portlet-list [{}][{}], but was not the owner nor an admin.", + person.getUserName(), + toUpdate.getId(), + toUpdate.getName()); + return prepareResponse( + response, null, "Unknown portlet-list", HttpServletResponse.SC_UNAUTHORIZED); + } + + // Either an owner or an admin. allow general-level changes: + if (!StringUtils.isEmpty(input.getName())) { + toUpdate.setName(input.getName()); + } + + if (input.getItems() != null) { + log.debug( + "Updating portlet list {} with new list of items, number of items: {}", + toUpdate, + input.getItems().size()); + toUpdate.clearAndSetItems(input.getItems()); + log.debug("Updated portlet list {} with new list of items", toUpdate); + } else { + log.debug("Not updating portlet list items (request items were null: {}", toUpdate); + } + + try { + final IPortletList updated = portletListService.updatePortletList(person, toUpdate); + if (updated == null) { + log.warn( + "update returned null for portlet-list uuid [{}]. Failing request.", + portletListUuid); + return prepareResponse( + response, + null, + "Error occurred while updating portlet list. Please check your System Administrator", + HttpServletResponse.SC_BAD_REQUEST); + } else { + return prepareResponse(response, null, null, HttpServletResponse.SC_OK); + } + } catch (RuntimeAuthorizationException rae) { + log.warn("RuntimeAuthorizationException thrown - {}", rae.getMessage(), rae); + return prepareResponse( + response, null, "not authorized", HttpServletResponse.SC_FORBIDDEN); + } catch (IllegalArgumentException iae) { + log.warn("IllegalArgumentException thrown - {}", iae.getMessage(), iae); + return prepareResponse( + response, null, iae.getMessage(), HttpServletResponse.SC_CONFLICT); + } catch (DataIntegrityViolationException dive) { + log.warn( + "Attempted violation of data integrity when updating a portlet list {}", + dive.getMessage(), + dive); + return prepareResponse( + response, + null, + "Data integrity issue - such as specifying a non-unique name.", + HttpServletResponse.SC_BAD_REQUEST); + } catch (Exception e) { + log.warn("Just hit an exception of type {}", e.getClass().getCanonicalName(), e); + return prepareResponse( + response, + null, + "Something unexpected occurred. Please check with your System Administrator.", + HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + + /** + * Remove a portlet list + * + *

If an administrator makes the request, they can remove any portlet list. Otherwise, only + * an owner can remove their portlet list. + */ + @RequestMapping( + value = CONTEXT + "{portletListUuid}", + method = DELETE, + produces = MediaType.APPLICATION_JSON_VALUE) + public @ResponseBody String removePortletList( + HttpServletRequest request, + HttpServletResponse response, + @PathVariable String portletListUuid) { + final IPerson person = personManager.getPerson(request); + if (log.isDebugEnabled()) { + debugPerson("removePortletList", person); + } + + if (person.isGuest()) { + log.warn("Guest is trying to access portlet-list API, which is not allowed."); + return prepareResponse( + response, null, "Not authorized", HttpServletResponse.SC_UNAUTHORIZED); + } + + // Input validation prior to logging any values to protect against logging security attacks + try { + if (!StringUtils.isEmpty(portletListUuid)) + InputValidator.validateAsWordCharacters(portletListUuid, "Portlet List UUID"); + } catch (IllegalArgumentException iae) { + log.warn("IllegalArgumentException thrown - {}", iae.getMessage(), iae); + return prepareResponse( + response, null, iae.getMessage(), HttpServletResponse.SC_CONFLICT); + } + + try { + if (portletListService.removePortletList(person, portletListUuid)) { + return prepareResponse(response, null, null, HttpServletResponse.SC_OK); + } else { + return prepareResponse( + response, + null, + "Unable to remove portlet list. Please check with your System Administrator.", + HttpServletResponse.SC_BAD_REQUEST); + } + } catch (Exception e) { + log.error("Unable to delete portlet list. Returning a 500.", e); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + return null; + } + } + + private String prepareResponse( + HttpServletResponse response, Object returnPayload, String errorMessage, int status) { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + Object payloadToReturn = returnPayload; + int statusToReturn = status; + + if (returnPayload == null && errorMessage != null) { + payloadToReturn = new ErrorResponse(errorMessage); + } + + log.debug( + "returnPayload is null = {}, errorMessage is null = {}, final return payload is null = {}", + (returnPayload == null), + (errorMessage == null), + (payloadToReturn == null)); + + try { + response.setStatus(statusToReturn); + // If there is no payload, and no error, return a null body in the response + final String payloadAsString = + ((returnPayload == null) && (errorMessage == null)) + ? null + : objectMapper.writeValueAsString(payloadToReturn); + log.debug( + "Prepared JSON Response - response code [{}], object type [{}], JSON as string: {}", + statusToReturn, + (payloadToReturn == null) + ? "NULL" + : payloadToReturn.getClass().getCanonicalName(), + payloadAsString); + return payloadAsString; + } catch (Exception e) { + log.error("Unable to write out payload object as JSON. Returning a 500.", e); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + return null; + } + } + + private void debugPerson(String flow, IPerson person) { + log.debug( + "{} > Current user: username={}, isGuest={}, isAdmin={}", + flow, + person.getUserName(), + person.isGuest(), + portletListService.isPortletListAdmin(person)); + } +} diff --git a/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/rest/utils/ErrorResponse.java b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/rest/utils/ErrorResponse.java new file mode 100644 index 00000000000..dcd6780a668 --- /dev/null +++ b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/rest/utils/ErrorResponse.java @@ -0,0 +1,14 @@ +package org.apereo.portal.rest.utils; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ErrorResponse { + private String message; + + public ErrorResponse(String message) { + this.message = message; + } +} diff --git a/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/rest/utils/InputValidator.java b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/rest/utils/InputValidator.java new file mode 100644 index 00000000000..ce36538f6be --- /dev/null +++ b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/rest/utils/InputValidator.java @@ -0,0 +1,42 @@ +package org.apereo.portal.rest.utils; + +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.apereo.portal.dao.portletlist.jpa.PortletListItem; + +public class InputValidator { + private static final String ALPHANUMERIC_AND_DASH_VALIDATOR_REGEX = + "^[\\w\\-]{1,500}$"; // 1-500 characters + private static final Pattern ALPHANUMERIC_AND_DASH_VALIDATOR_PATTERN = + Pattern.compile(ALPHANUMERIC_AND_DASH_VALIDATOR_REGEX); + + /** + * @param s value to validate + * @param inputType not sanitized and will be returned in json. + * @return The validated value + */ + public static String validateAsWordCharacters(String s, String inputType) { + if (!ALPHANUMERIC_AND_DASH_VALIDATOR_PATTERN.matcher(s).matches()) { + throw new IllegalArgumentException( + "Specified " + + inputType + + " is not the correct length or has invalid characters."); + } + return s; + } + + /** + * @param items list of portlet list items + * @param item a given portlet list item (assumed to be in the list at least once) + */ + public static void validateUniqueEntityId(List items, PortletListItem item) { + if (items.stream() + .filter(o -> item.getEntityId().equals(o.getEntityId())) + .collect(Collectors.toList()) + .size() + > 1) { + throw new IllegalArgumentException("entity IDs must be unique in the items list"); + } + } +} diff --git a/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/services/portletlist/IPortletListService.java b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/services/portletlist/IPortletListService.java new file mode 100644 index 00000000000..221bdb92870 --- /dev/null +++ b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/services/portletlist/IPortletListService.java @@ -0,0 +1,27 @@ +package org.apereo.portal.services.portletlist; + +import java.util.List; +import org.apereo.portal.dao.portletlist.IPortletList; +import org.apereo.portal.security.IPerson; + +public interface IPortletListService { + public List getPortletLists(); + + public List getPortletLists(IPerson requester); + + public boolean removePortletList(IPerson requester, String portletListUuid); + + /** + * Returns null if not found + * + * @param portletListUuid + * @return + */ + public IPortletList getPortletList(String portletListUuid); + + public IPortletList createPortletList(IPerson requester, IPortletList toCreate); + + public IPortletList updatePortletList(IPerson requester, IPortletList toUpdate); + + public boolean isPortletListAdmin(IPerson person); +} diff --git a/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/services/portletlist/PortletListService.java b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/services/portletlist/PortletListService.java new file mode 100644 index 00000000000..898395e516f --- /dev/null +++ b/uPortal-api/uPortal-api-rest/src/main/java/org/apereo/portal/services/portletlist/PortletListService.java @@ -0,0 +1,80 @@ +package org.apereo.portal.services.portletlist; + +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.apereo.portal.dao.portletlist.IPortletList; +import org.apereo.portal.dao.portletlist.IPortletListDao; +import org.apereo.portal.security.AuthorizationPrincipalHelper; +import org.apereo.portal.security.IAuthorizationService; +import org.apereo.portal.security.IPermission; +import org.apereo.portal.security.IPerson; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * Service layer that sits atop the DAO layer and enforces permissions and/or business rules that + * apply to CRUD operations on portlet lists. External clients should interact with this service -- + * instead of the DAOs directly -- whenever the actions are undertaken on behalf of a specific user. + */ +@Service +@Slf4j +public final class PortletListService implements IPortletListService { + + @Autowired private IPortletListDao portletListDao; + + @Autowired private IAuthorizationService authorizationService; + + public boolean isPortletListAdmin(IPerson person) { + return authorizationService.doesPrincipalHavePermission( + AuthorizationPrincipalHelper.principalFromUser(person), + IPermission.PORTAL_SYSTEM, + IPermission.ALL_PERMISSIONS_ACTIVITY, + IPermission.ALL_TARGET); + } + + @Override + public List getPortletLists() { + List rslt = portletListDao.getPortletLists(); + log.debug("Returning {} portlet lists", rslt.size()); + return rslt; + } + + @Override + public List getPortletLists(IPerson requester) { + List rslt = portletListDao.getPortletLists(requester.getUserName()); + log.debug("Returning portlet lists '{}' for user '{}'", rslt, requester.getUserName()); + return rslt; + } + + @Override + public IPortletList getPortletList(String portletListUuid) { + return portletListDao.getPortletList(portletListUuid); + } + + @Override + public boolean removePortletList(IPerson requester, String portletListUuid) { + if (isPortletListAdmin(requester)) { + return portletListDao.removePortletListAsAdmin(portletListUuid, requester); + } else { + return portletListDao.removePortletListAsOwner(portletListUuid, requester); + } + } + + @Override + public IPortletList createPortletList(IPerson requester, IPortletList toCreate) { + log.debug( + "Using DAO to create portlet list [{}] for user [{}]", + toCreate.getName(), + toCreate.getOwnerUsername()); + return portletListDao.createPortletList(toCreate, requester); + } + + @Override + public IPortletList updatePortletList(IPerson requester, IPortletList toUpdate) { + log.debug( + "Using DAO to update portlet list [{}] for user [{}]", + toUpdate.getId(), + requester.getUserName()); + return portletListDao.updatePortletList(toUpdate, requester); + } +} diff --git a/uPortal-webapp/src/main/resources/properties/db/hibernate.cfg.xml b/uPortal-webapp/src/main/resources/properties/db/hibernate.cfg.xml index ba60eeb2499..540c851191d 100644 --- a/uPortal-webapp/src/main/resources/properties/db/hibernate.cfg.xml +++ b/uPortal-webapp/src/main/resources/properties/db/hibernate.cfg.xml @@ -80,5 +80,7 @@ + +