From 597c79eb7c2b5914d52d94e84c416d5bf95c9dd3 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Mon, 23 Oct 2023 09:12:57 -0400 Subject: [PATCH] Added more auth stuff. --- .../com/andrewlalis/onyx/OnyxApplication.java | 7 +- .../auth/components/ContentAccessFilter.java | 71 ++++++++++++++++ .../onyx/config/SecurityConfig.java | 5 ++ .../content/api/ContentNodeController.java | 32 ++++++++ .../onyx/content/api/ContentNodeResponse.java | 58 +++++++++++++ .../content/dao/ContentNodeRepository.java | 6 ++ .../content/model/ContentContainerNode.java | 4 + .../content/model/ContentDocumentNode.java | 2 + .../onyx/content/model/ContentNode.java | 3 +- .../content/service/ContentNodeService.java | 82 +++++++++++++++++++ 10 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 onyx-api/src/main/java/com/andrewlalis/onyx/auth/components/ContentAccessFilter.java create mode 100644 onyx-api/src/main/java/com/andrewlalis/onyx/content/api/ContentNodeController.java create mode 100644 onyx-api/src/main/java/com/andrewlalis/onyx/content/api/ContentNodeResponse.java create mode 100644 onyx-api/src/main/java/com/andrewlalis/onyx/content/service/ContentNodeService.java diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/OnyxApplication.java b/onyx-api/src/main/java/com/andrewlalis/onyx/OnyxApplication.java index 7c6108e..440ac68 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/OnyxApplication.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/OnyxApplication.java @@ -2,12 +2,13 @@ package com.andrewlalis.onyx; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; -@SpringBootApplication +@SpringBootApplication( + exclude = {UserDetailsServiceAutoConfiguration.class} +) public class OnyxApplication { - public static void main(String[] args) { SpringApplication.run(OnyxApplication.class, args); } - } diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/components/ContentAccessFilter.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/components/ContentAccessFilter.java new file mode 100644 index 0000000..dd81a4e --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/components/ContentAccessFilter.java @@ -0,0 +1,71 @@ +package com.andrewlalis.onyx.auth.components; + +import com.andrewlalis.onyx.auth.model.User; +import com.andrewlalis.onyx.content.dao.ContentNodeRepository; +import com.andrewlalis.onyx.content.model.ContentNode; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * A filter that's run on any request to "/content/nodes/:id/**", which checks + * that the user accessing the specific node is actually allowed to access it, + * based on the node's access rules. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ContentAccessFilter extends OncePerRequestFilter { + private final ContentNodeRepository contentNodeRepository; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + log.info("Calling ContentAccessFilter for path {}", request.getServletPath()); + String nodeId = extractNodeId(request); + if (nodeId == null) { + log.warn("Couldn't extract node id!"); + return; + } + log.info("Extracted node id: {}", nodeId); + ContentNode node; + if (nodeId.equalsIgnoreCase("root")) { + node = contentNodeRepository.findRoot(); + } else { + node = contentNodeRepository.findById(Long.parseLong(nodeId)).orElse(null); + } + if (node == null) { + log.warn("Node doesn't exist!"); + return; + } + TokenAuthentication auth = (TokenAuthentication) SecurityContextHolder.getContext().getAuthentication(); + User user = auth.getPrincipal(); + // TODO: Actually check access rules. + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getServletPath().startsWith("/content/nodes/"); + } + + private String extractNodeId(HttpServletRequest request) { + String path = request.getServletPath(); + final String PREFIX = "/content/nodes/"; + if (!path.startsWith(PREFIX)) return null; + String suffix = path.substring(PREFIX.length()); + int suffixEnd = suffix.indexOf('/'); + if (suffixEnd != -1) return suffix.substring(0, suffixEnd); + return suffix; + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/config/SecurityConfig.java b/onyx-api/src/main/java/com/andrewlalis/onyx/config/SecurityConfig.java index 568acc1..a4da734 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/config/SecurityConfig.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/config/SecurityConfig.java @@ -1,11 +1,13 @@ package com.andrewlalis.onyx.config; +import com.andrewlalis.onyx.auth.components.ContentAccessFilter; import com.andrewlalis.onyx.auth.components.JwtFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; @@ -14,9 +16,11 @@ import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @Configuration +@EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtFilter jwtFilter; + private final ContentAccessFilter contentAccessFilter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -28,6 +32,7 @@ public class SecurityConfig { http.csrf(AbstractHttpConfigurer::disable); http.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.NEVER)); http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterAfter(contentAccessFilter, JwtFilter.class); http.cors(configurer -> configurer.configure(http)); return http.build(); } diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/api/ContentNodeController.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/api/ContentNodeController.java new file mode 100644 index 0000000..1662f32 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/api/ContentNodeController.java @@ -0,0 +1,32 @@ +package com.andrewlalis.onyx.content.api; + +import com.andrewlalis.onyx.content.service.ContentNodeService; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class ContentNodeController { + private final ContentNodeService contentNodeService; + + @GetMapping("/content/nodes/{nodeId}") + public ContentNodeResponse getContentNode(@PathVariable long nodeId) { + return contentNodeService.getNodeById(nodeId); + } + + @GetMapping("/content/nodes/root") + public ContentNodeResponse getRootNode() { + return contentNodeService.getRootNode(); + } + + @PostMapping("/content/nodes/{nodeId}/children") + public ContentNodeResponse createContentNode(@PathVariable long nodeId, @RequestBody ObjectNode body) { + return contentNodeService.createNode(nodeId, body); + } + + @DeleteMapping("/content/nodes/{nodeId}") + public void deleteContentNode(@PathVariable long nodeId) { + contentNodeService.deleteNode(nodeId); + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/api/ContentNodeResponse.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/api/ContentNodeResponse.java new file mode 100644 index 0000000..f43f80e --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/api/ContentNodeResponse.java @@ -0,0 +1,58 @@ +package com.andrewlalis.onyx.content.api; + +import com.andrewlalis.onyx.content.model.ContentContainerNode; +import com.andrewlalis.onyx.content.model.ContentDocumentNode; +import com.andrewlalis.onyx.content.model.ContentNode; + +import java.util.Set; + +public abstract class ContentNodeResponse { + public final long id; + public final String name; + public final String nodeType; + public final boolean archived; + + public ContentNodeResponse(ContentNode node) { + this.id = node.getId(); + this.name = node.getName(); + this.nodeType = node.getNodeType().name(); + this.archived = node.isArchived(); + } + + public static ContentNodeResponse forNode(ContentNode node) { + return switch (node) { + case ContentContainerNode c -> new ContentNodeResponse.Container(c); + case ContentDocumentNode d -> new ContentNodeResponse.Document(d); + default -> throw new IllegalStateException("Unexpected value: " + node); + }; + } + + public static class Container extends ContentNodeResponse { + public record ChildInfo (long id, String name, String nodeType) {} + + public final ChildInfo[] children; + + public Container(ContentContainerNode node) { + super(node); + Set childrenSet = node.getChildren(); + this.children = new ChildInfo[childrenSet.size()]; + int i = 0; + for (ContentNode child : childrenSet) { + children[i++] = new ChildInfo( + child.getId(), + child.getName(), + child.getNodeType().name() + ); + } + } + } + + public static class Document extends ContentNodeResponse { + public final String contentType; + + public Document(ContentDocumentNode node) { + super(node); + this.contentType = node.getContentType(); + } + } +} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/dao/ContentNodeRepository.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/dao/ContentNodeRepository.java index 45e8b6a..66540f8 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/dao/ContentNodeRepository.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/dao/ContentNodeRepository.java @@ -2,9 +2,15 @@ package com.andrewlalis.onyx.content.dao; import com.andrewlalis.onyx.content.model.ContentNode; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface ContentNodeRepository extends JpaRepository { boolean existsByName(String name); + + @Query("SELECT cn FROM ContentNode cn WHERE cn.name = '" + ContentNode.ROOT_NODE_NAME + "'") + ContentNode findRoot(); } diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentContainerNode.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentContainerNode.java index d69ef83..b91210d 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentContainerNode.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentContainerNode.java @@ -2,8 +2,10 @@ package com.andrewlalis.onyx.content.model; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.HashSet; import java.util.Set; /** @@ -13,6 +15,7 @@ import java.util.Set; @Entity @Table(name = "onyx_content_container_node") @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter public final class ContentContainerNode extends ContentNode { /** * The set of children that belong to this container. @@ -22,5 +25,6 @@ public final class ContentContainerNode extends ContentNode { public ContentContainerNode(String name, ContentContainerNode parentContainer) { super(name, Type.CONTAINER, parentContainer); + this.children = new HashSet<>(); } } diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentDocumentNode.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentDocumentNode.java index e5c8e29..6888f80 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentDocumentNode.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentDocumentNode.java @@ -5,6 +5,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Lob; import jakarta.persistence.Table; import lombok.AccessLevel; +import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -14,6 +15,7 @@ import lombok.Setter; @Entity @Table(name = "onyx_content_document_node") @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter public class ContentDocumentNode extends ContentNode { @Column(nullable = false, updatable = false, length = 127) private String contentType; diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentNode.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentNode.java index 0894cf0..6370809 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentNode.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/model/ContentNode.java @@ -6,6 +6,7 @@ import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; /** * The abstract model that represents all nodes in the system's hierarchical @@ -43,7 +44,7 @@ public abstract class ContentNode { @OneToOne(fetch = FetchType.LAZY, optional = false, orphanRemoval = true, cascade = CascadeType.ALL) private ContentNodeHistory history; - @Column(nullable = false) + @Column(nullable = false) @Setter private boolean archived; public enum Type { diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/content/service/ContentNodeService.java b/onyx-api/src/main/java/com/andrewlalis/onyx/content/service/ContentNodeService.java new file mode 100644 index 0000000..6df7272 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/content/service/ContentNodeService.java @@ -0,0 +1,82 @@ +package com.andrewlalis.onyx.content.service; + +import com.andrewlalis.onyx.content.api.ContentNodeResponse; +import com.andrewlalis.onyx.content.dao.ContentNodeRepository; +import com.andrewlalis.onyx.content.model.ContentContainerNode; +import com.andrewlalis.onyx.content.model.ContentDocumentNode; +import com.andrewlalis.onyx.content.model.ContentNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; + +@Service +@RequiredArgsConstructor +public class ContentNodeService { + private final ContentNodeRepository contentNodeRepository; + + @Transactional(readOnly = true) + public ContentNodeResponse getNodeById(long nodeId) { + ContentNode node = contentNodeRepository.findById(nodeId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + return ContentNodeResponse.forNode(node); + } + + @Transactional(readOnly = true) + public ContentNodeResponse getRootNode() { + ContentNode rootNode = contentNodeRepository.findRoot(); + return ContentNodeResponse.forNode(rootNode); + } + + @Transactional + public ContentNodeResponse createNode(long parentNodeId, ObjectNode body) { + ContentNode parentNode = contentNodeRepository.findById(parentNodeId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!(parentNode instanceof ContentContainerNode container)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Can only create new content nodes inside containers."); + } + try { + ContentNode node; + // First validate that the name is legitimate, and doesn't conflict with any siblings. + String name = body.get("name").asText().trim(); + if (name.isBlank() || name.length() > ContentNode.MAX_NAME_LENGTH || name.equalsIgnoreCase(ContentNode.ROOT_NODE_NAME)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid name."); + } + for (ContentNode child : container.getChildren()) { + if (child.getName().equals(name)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Duplicate name."); + } + } + + String nodeTypeStr = body.get("nodeType").asText(); + if (nodeTypeStr.equalsIgnoreCase(ContentNode.Type.CONTAINER.name())) { + node = new ContentContainerNode(name, container); + } else if (nodeTypeStr.equalsIgnoreCase(ContentNode.Type.DOCUMENT.name())) { + String contentType = body.get("contentType").asText(); + String content = body.get("content").asText(); + node = new ContentDocumentNode(contentType, content.getBytes(StandardCharsets.UTF_8)); + } else { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid nodeType."); + } + node = contentNodeRepository.save(node); + return ContentNodeResponse.forNode(node); + } catch (NullPointerException e) { + e.printStackTrace(); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid request body.", e); + } + } + + @Transactional + public void deleteNode(long nodeId) { + ContentNode node = contentNodeRepository.findById(nodeId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (node.getName().equals(ContentNode.ROOT_NODE_NAME)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You cannot delete the root node."); + } + contentNodeRepository.delete(node); + } +}