Added more auth stuff.

This commit is contained in:
Andrew Lalis 2023-10-23 09:12:57 -04:00
parent c2dd6e8a4a
commit 597c79eb7c
10 changed files with 266 additions and 4 deletions

View File

@ -2,12 +2,13 @@ package com.andrewlalis.onyx;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
@SpringBootApplication @SpringBootApplication(
exclude = {UserDetailsServiceAutoConfiguration.class}
)
public class OnyxApplication { public class OnyxApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(OnyxApplication.class, args); SpringApplication.run(OnyxApplication.class, args);
} }
} }

View File

@ -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;
}
}

View File

@ -1,11 +1,13 @@
package com.andrewlalis.onyx.config; package com.andrewlalis.onyx.config;
import com.andrewlalis.onyx.auth.components.ContentAccessFilter;
import com.andrewlalis.onyx.auth.components.JwtFilter; import com.andrewlalis.onyx.auth.components.JwtFilter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; 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; import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration @Configuration
@EnableWebSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final JwtFilter jwtFilter; private final JwtFilter jwtFilter;
private final ContentAccessFilter contentAccessFilter;
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
@ -28,6 +32,7 @@ public class SecurityConfig {
http.csrf(AbstractHttpConfigurer::disable); http.csrf(AbstractHttpConfigurer::disable);
http.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.NEVER)); http.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.NEVER));
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterAfter(contentAccessFilter, JwtFilter.class);
http.cors(configurer -> configurer.configure(http)); http.cors(configurer -> configurer.configure(http));
return http.build(); return http.build();
} }

View File

@ -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);
}
}

View File

@ -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<ContentNode> 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();
}
}
}

View File

@ -2,9 +2,15 @@ package com.andrewlalis.onyx.content.dao;
import com.andrewlalis.onyx.content.model.ContentNode; import com.andrewlalis.onyx.content.model.ContentNode;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository @Repository
public interface ContentNodeRepository extends JpaRepository<ContentNode, Long> { public interface ContentNodeRepository extends JpaRepository<ContentNode, Long> {
boolean existsByName(String name); boolean existsByName(String name);
@Query("SELECT cn FROM ContentNode cn WHERE cn.name = '" + ContentNode.ROOT_NODE_NAME + "'")
ContentNode findRoot();
} }

View File

@ -2,8 +2,10 @@ package com.andrewlalis.onyx.content.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.HashSet;
import java.util.Set; import java.util.Set;
/** /**
@ -13,6 +15,7 @@ import java.util.Set;
@Entity @Entity
@Table(name = "onyx_content_container_node") @Table(name = "onyx_content_container_node")
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public final class ContentContainerNode extends ContentNode { public final class ContentContainerNode extends ContentNode {
/** /**
* The set of children that belong to this container. * 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) { public ContentContainerNode(String name, ContentContainerNode parentContainer) {
super(name, Type.CONTAINER, parentContainer); super(name, Type.CONTAINER, parentContainer);
this.children = new HashSet<>();
} }
} }

View File

@ -5,6 +5,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.Lob; import jakarta.persistence.Lob;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
@ -14,6 +15,7 @@ import lombok.Setter;
@Entity @Entity
@Table(name = "onyx_content_document_node") @Table(name = "onyx_content_document_node")
@NoArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ContentDocumentNode extends ContentNode { public class ContentDocumentNode extends ContentNode {
@Column(nullable = false, updatable = false, length = 127) @Column(nullable = false, updatable = false, length = 127)
private String contentType; private String contentType;

View File

@ -6,6 +6,7 @@ import jakarta.persistence.*;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter;
/** /**
* The abstract model that represents all nodes in the system's hierarchical * 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) @OneToOne(fetch = FetchType.LAZY, optional = false, orphanRemoval = true, cascade = CascadeType.ALL)
private ContentNodeHistory history; private ContentNodeHistory history;
@Column(nullable = false) @Column(nullable = false) @Setter
private boolean archived; private boolean archived;
public enum Type { public enum Type {

View File

@ -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);
}
}