Added more auth stuff.
This commit is contained in:
parent
c2dd6e8a4a
commit
597c79eb7c
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue