From e6a6325d5a7c11102b25476f8d392aeac938786f Mon Sep 17 00:00:00 2001 From: Pekka Helenius Date: Sat, 26 Sep 2020 01:24:15 +0300 Subject: [PATCH] Implement random hashes as replacement for direct book IDs for front-end Signed-off-by: Pekka Helenius --- .../bookstore/BookstoreApplication.java | 39 ++++- .../com/fjordtek/bookstore/model/Book.java | 39 ++++- .../fjordtek/bookstore/model/BookHash.java | 141 ++++++++++++++++++ .../bookstore/model/BookHashRepository.java | 29 ++++ .../bookstore/model/BookRepository.java | 17 +++ .../bookstore/web/BookController.java | 55 +++++-- .../bookstore/web/BookRestController.java | 10 +- .../main/resources/templates/bookedit.html | 2 +- .../main/resources/templates/booklist.html | 6 +- 9 files changed, 307 insertions(+), 31 deletions(-) create mode 100644 bookstore/src/main/java/com/fjordtek/bookstore/model/BookHash.java create mode 100644 bookstore/src/main/java/com/fjordtek/bookstore/model/BookHashRepository.java diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/BookstoreApplication.java b/bookstore/src/main/java/com/fjordtek/bookstore/BookstoreApplication.java index 5e4b92e..1cdab98 100644 --- a/bookstore/src/main/java/com/fjordtek/bookstore/BookstoreApplication.java +++ b/bookstore/src/main/java/com/fjordtek/bookstore/BookstoreApplication.java @@ -15,6 +15,8 @@ import org.springframework.context.annotation.Bean; import com.fjordtek.bookstore.model.Author; import com.fjordtek.bookstore.model.AuthorRepository; import com.fjordtek.bookstore.model.Book; +import com.fjordtek.bookstore.model.BookHash; +import com.fjordtek.bookstore.model.BookHashRepository; import com.fjordtek.bookstore.model.BookRepository; import com.fjordtek.bookstore.model.Category; import com.fjordtek.bookstore.model.CategoryRepository; @@ -30,6 +32,7 @@ public class BookstoreApplication extends SpringBootServletInitializer { @Bean public CommandLineRunner bookDatabaseRunner( BookRepository bookRepository, + BookHashRepository bookHashRepository, CategoryRepository categoryRepository, AuthorRepository authorRepository ) { @@ -37,17 +40,15 @@ public class BookstoreApplication extends SpringBootServletInitializer { return (args) -> { commonLogger.info("Add new categories to database"); - categoryRepository.save(new Category("Horror")); categoryRepository.save(new Category("Fantasy")); categoryRepository.save(new Category("Sci-Fi")); + commonLogger.info("Add new authors to database"); authorRepository.save(new Author("Angela","Carter")); authorRepository.save(new Author("Andrzej","Sapkowski")); - commonLogger.info("Add new sample books to database"); - - bookRepository.save(new Book( + Book bookA = new Book( "Bloody Chamber", authorRepository.findByFirstNameIgnoreCaseContainingAndLastNameIgnoreCaseContaining( "Angela","Carter" @@ -56,8 +57,9 @@ public class BookstoreApplication extends SpringBootServletInitializer { "1231231-12", new BigDecimal("18.00"), categoryRepository.findByName("Horror").get(0) - )); - bookRepository.save(new Book( + ); + + Book bookB = new Book( "The Witcher: The Lady of the Lake", authorRepository.findByFirstNameIgnoreCaseContainingAndLastNameIgnoreCaseContaining( "Andrzej","Sapkowski" @@ -66,7 +68,25 @@ public class BookstoreApplication extends SpringBootServletInitializer { "3213221-3", new BigDecimal("19.99"), categoryRepository.findByName("Fantasy").get(0) - )); + ); + + commonLogger.info("Add new sample books to database"); + + bookRepository.save(bookA); + bookRepository.save(bookB); + + BookHash bookHashA = new BookHash(); + BookHash bookHashB = new BookHash(); + + // One-to-one unidirectional relationship + // Both directions for table operations must be considered here. + bookA.setBookHash(bookHashA); + bookB.setBookHash(bookHashB); + bookHashA.setBook(bookA); + bookHashB.setBook(bookB); + + bookHashRepository.save(bookHashA); + bookHashRepository.save(bookHashB); commonLogger.info("------------------------------"); commonLogger.info("Sample categories in the database"); @@ -81,6 +101,11 @@ public class BookstoreApplication extends SpringBootServletInitializer { for (Book book : bookRepository.findAll()) { commonLogger.info(book.toString()); } + commonLogger.info("Sample book hashes in the database"); + commonLogger.info("**THIS IS ADDED FOR SECURITY PURPOSES**"); + for (BookHash hash : bookHashRepository.findAll()) { + commonLogger.info(hash.toString()); + } }; } diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/model/Book.java b/bookstore/src/main/java/com/fjordtek/bookstore/model/Book.java index 70efa4a..67831c9 100644 --- a/bookstore/src/main/java/com/fjordtek/bookstore/model/Book.java +++ b/bookstore/src/main/java/com/fjordtek/bookstore/model/Book.java @@ -4,6 +4,7 @@ package com.fjordtek.bookstore.model; import java.math.BigDecimal; +import javax.persistence.CascadeType; import javax.persistence.Column; //import java.sql.Timestamp; @@ -16,6 +17,8 @@ import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; +import javax.persistence.OneToOne; +import javax.persistence.PrimaryKeyJoinColumn; import javax.persistence.SequenceGenerator; import javax.validation.constraints.DecimalMax; import javax.validation.constraints.DecimalMin; @@ -79,6 +82,19 @@ public class Book { @JsonIgnore private Long id; + //////////////////// + // Random hash id purely for front-end purposes + // Difficult to enumerate + + @JsonIgnore + @OneToOne( + fetch = FetchType.LAZY, + cascade = CascadeType.ALL, + targetEntity = BookHash.class + ) + @PrimaryKeyJoinColumn + private BookHash bookHash; + //////////////////// // Attributes with hard-coded constraints @@ -200,6 +216,10 @@ public class Book { this.id = id; } + public void setBookHash(BookHash bookHash) { + this.bookHash = bookHash; + } + public void setTitle(String title) { this.title = title; } @@ -233,6 +253,10 @@ public class Book { return id; } + public BookHash getBookHash() { + return bookHash; + } + public String getTitle() { return title; } @@ -277,13 +301,14 @@ public class Book { @Override public String toString() { - return "[" + "id: " + this.id + ", " + - "title: " + this.title + ", " + - "author: " + this.author + ", " + - "year: " + this.year + ", " + - "isbn: " + this.isbn + ", " + - "price: " + this.price + ", " + - "category: " + this.category + "]"; + return "[" + "id: " + this.id + ", " + + "bookhash_id: " + this.bookHash + ", " + + "title: " + this.title + ", " + + "author: " + this.author + ", " + + "year: " + this.year + ", " + + "isbn: " + this.isbn + ", " + + "price: " + this.price + ", " + + "category: " + this.category + "]"; } } diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/model/BookHash.java b/bookstore/src/main/java/com/fjordtek/bookstore/model/BookHash.java new file mode 100644 index 0000000..ba6a9ee --- /dev/null +++ b/bookstore/src/main/java/com/fjordtek/bookstore/model/BookHash.java @@ -0,0 +1,141 @@ +package com.fjordtek.bookstore.model; + +import java.security.SecureRandom; + +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.OneToOne; +import javax.persistence.PrimaryKeyJoinColumn; + +import org.hibernate.annotations.GenericGenerator; +import org.hibernate.annotations.Parameter; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/* + * This entity shares same primary key with the Book entity + * which is simultaneously a foreign key (unidirectional mapping) + * for this one. + * For implementation reference, see also: + * https://www.programmersought.com/article/1610322983/ + * + * This entity generates a table which holds auto-generated + * random string values associated to each book. + * + * These random string values represent difficult-to-enumerate + * unique book IDs used for front-end purposes. Main purpose + * is not to expose real Book entity id value. + */ + +@Entity +public class BookHash { + + @Id + @GeneratedValue( + strategy = GenerationType.SEQUENCE, + generator = "bookHashIdGenerator" + ) + @GenericGenerator( + name = "bookHashIdGenerator", + strategy = "foreign", + parameters = { @Parameter(name = "property", value = "book") } + ) + @Column( + name = "book_id", + unique = true, + nullable = false + ) + @JsonIgnore + private Long bookId; + + + //@MapsId + @OneToOne( + cascade = { CascadeType.MERGE, CascadeType.REMOVE }, + fetch = FetchType.LAZY, + mappedBy = "bookHash", + targetEntity = Book.class + ) + @PrimaryKeyJoinColumn( + referencedColumnName = "id" + ) + private Book book; + + //////////////////// + // Attribute setters + + @Column( + name = "hash_id", + unique = true, + columnDefinition = "CHAR(32)", + updatable = false + ) + private String hashId; + + + public void setBookId(Long bookId) { + this.bookId = bookId; + } + + public void setBook(Book book) { + this.book = book; + } + + /* + * This setter sets an auto-generated value + * No manual intervention. + */ + public void setHashId() { + + byte[] byteInit = new byte[16]; + + new SecureRandom().nextBytes(byteInit); + + StringBuilder shaStringBuilder = new StringBuilder(); + + for (byte b : byteInit) { + shaStringBuilder.append(String.format("%02x", b)); + } + + this.hashId = shaStringBuilder.toString(); + + } + + //////////////////// + // Attribute getters + + public Long getBookId() { + return bookId; + } + + public Book getBook() { + return book; + } + + public String getHashId() { + return hashId; + } + + //////////////////// + // Class constructors + + public BookHash() { + this.setHashId(); + } + + //////////////////// + // Class overrides + + @Override + public String toString() { + return "[" + "book_id: " + this.bookId + ", " + + "hash_id: " + this.hashId + "]"; + + } + +} diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/model/BookHashRepository.java b/bookstore/src/main/java/com/fjordtek/bookstore/model/BookHashRepository.java new file mode 100644 index 0000000..357cbb5 --- /dev/null +++ b/bookstore/src/main/java/com/fjordtek/bookstore/model/BookHashRepository.java @@ -0,0 +1,29 @@ +package com.fjordtek.bookstore.model; + +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +@RepositoryRestResource( + path = "bookhashes", + itemResourceRel = "bookhashes", + exported = true + ) +public interface BookHashRepository extends CrudRepository { + + public BookHash findByHashId(String bookHashId); + + /* + * We need to override native delete method. + * This is a native query, do not unnecessarily validate it. + */ + @Modifying + @Query( + value = "DELETE FROM BOOK_HASH i WHERE i.book_id = :bookId", + nativeQuery = true + ) + public void deleteByBookId(@Param("bookId") Long bookId); + +} \ No newline at end of file diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/model/BookRepository.java b/bookstore/src/main/java/com/fjordtek/bookstore/model/BookRepository.java index d596fc8..145c8b1 100644 --- a/bookstore/src/main/java/com/fjordtek/bookstore/model/BookRepository.java +++ b/bookstore/src/main/java/com/fjordtek/bookstore/model/BookRepository.java @@ -5,6 +5,8 @@ package com.fjordtek.bookstore.model; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.query.Param; import org.springframework.data.rest.core.annotation.RepositoryRestResource; @@ -36,4 +38,19 @@ public interface BookRepository extends CrudRepository { @RestResource(exported = false) public boolean existsByIsbn(String isbn); + @Override + public List findAll(); + + /* + * We need to override native delete method due to book hash id usage. + * This is a native query, do not unnecessarily validate it. + */ + @Override + @Modifying + @Query( + value = "DELETE FROM BOOK i WHERE i.id = :id", + nativeQuery = true + ) + public void deleteById(@Param("id") Long id); + } \ No newline at end of file diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/web/BookController.java b/bookstore/src/main/java/com/fjordtek/bookstore/web/BookController.java index 9f331d9..6f4d7d1 100644 --- a/bookstore/src/main/java/com/fjordtek/bookstore/web/BookController.java +++ b/bookstore/src/main/java/com/fjordtek/bookstore/web/BookController.java @@ -14,6 +14,7 @@ import javax.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.WebDataBinder; @@ -28,6 +29,8 @@ import org.springframework.web.bind.annotation.ResponseStatus; import com.fjordtek.bookstore.model.Author; import com.fjordtek.bookstore.model.AuthorRepository; import com.fjordtek.bookstore.model.Book; +import com.fjordtek.bookstore.model.BookHash; +import com.fjordtek.bookstore.model.BookHashRepository; import com.fjordtek.bookstore.model.BookRepository; import com.fjordtek.bookstore.model.CategoryRepository; @@ -51,6 +54,9 @@ public class BookController { @Autowired private BookRepository bookRepository; + @Autowired + private BookHashRepository bookHashRepository; + private static final String RestJSONPageView = "json"; private static final String RestAPIRefPageView = "apiref"; @@ -182,8 +188,20 @@ public class BookController { httpServerLogger.log(requestData, responseData); detectAndSaveBookAuthor(book); - bookRepository.save(book); + /* + * Generate hash id for the book. One-to-one unidirectional tables. + * Associate generated book hash object information + * to the book (table). + * Associate new book object information + * to the book hash (table). + */ + BookHash bookHash = new BookHash(); + book.setBookHash(bookHash); + bookHash.setBook(book); + + bookRepository.save(book); + bookHashRepository.save(bookHash); return "redirect:" + bookListPageView; } @@ -191,16 +209,24 @@ public class BookController { ////////////////////////////// // DELETE BOOK + @Transactional @RequestMapping( - value = bookDeletePageView + "/{id}", + value = bookDeletePageView + "/{hash_id}", method = RequestMethod.GET ) public String webFormDeleteBook( - @PathVariable("id") Long bookId, + @PathVariable("hash_id") String bookHashId, HttpServletRequest requestData, HttpServletResponse responseData ) { + Long bookId = new Long(bookHashRepository.findByHashId(bookHashId).getBookId()); + + /* + * Delete associated book id foreign key (+ other row data) from book hash table + * at first, after which delete the book. + */ + bookHashRepository.deleteByBookId(bookId); bookRepository.deleteById(bookId); httpServerLogger.log(requestData, responseData); @@ -212,16 +238,18 @@ public class BookController { // UPDATE BOOK @RequestMapping( - value = bookEditPageView + "/{id}", + value = bookEditPageView + "/{hash_id}", method = RequestMethod.GET ) public String webFormEditBook( - @PathVariable("id") Long bookId, + @PathVariable("hash_id") String bookHashId, Model dataModel, HttpServletRequest requestData, HttpServletResponse responseData ) { + Long bookId = new Long(bookHashRepository.findByHashId(bookHashId).getBookId()); + Book book = bookRepository.findById(bookId).get(); dataModel.addAttribute("book", book); @@ -237,29 +265,29 @@ public class BookController { * but just as an URL end point. */ @RequestMapping( - value = bookEditPageView + "/{id}", + value = bookEditPageView + "/{hash_id}", method = RequestMethod.POST ) public String webFormUpdateBook( @Valid @ModelAttribute("book") Book book, BindingResult bindingResult, - Model dataModel, - @PathVariable("id") Long bookId, + @PathVariable("hash_id") String bookHashId, HttpServletRequest requestData, HttpServletResponse responseData ) { - // NOTE: We have a unique and non-nullable ISBN value for each book. - if (bookId != book.getId()) { + BookHash bookHash = bookHashRepository.findByHashId(bookHashId); + if (bookHash == null) { bindingResult.rejectValue("name", "error.user", "Wrong book"); } + Long bookId = bookHash.getBookId(); // TODO consider better solution. Add custom Hibernate annotation for Book class? Book bookI = bookRepository.findByIsbn(book.getIsbn()); // If existing ISBN value is not attached to the current book... if (bookI != null) { - if (bookI.getId() != book.getId()) { + if (bookI.getId() != bookId) { bindingResult.rejectValue("isbn", "error.user", "ISBN code already exists"); } } @@ -270,6 +298,11 @@ public class BookController { return bookEditPageView; } + /* + * This is necessary so that Hibernate does not attempt to INSERT data + * but UPDATEs current table data. + */ + book.setId(bookId); detectAndSaveBookAuthor(book); bookRepository.save(book); diff --git a/bookstore/src/main/java/com/fjordtek/bookstore/web/BookRestController.java b/bookstore/src/main/java/com/fjordtek/bookstore/web/BookRestController.java index 5d87779..adbcb55 100644 --- a/bookstore/src/main/java/com/fjordtek/bookstore/web/BookRestController.java +++ b/bookstore/src/main/java/com/fjordtek/bookstore/web/BookRestController.java @@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import com.fjordtek.bookstore.model.Book; +import com.fjordtek.bookstore.model.BookHashRepository; import com.fjordtek.bookstore.model.BookRepository; @RestController @@ -23,6 +24,9 @@ public class BookRestController { @Autowired private BookRepository bookRepository; + + @Autowired + private BookHashRepository bookHashRepository; /* @Autowired private CategoryRepository categoryRepository; @@ -48,15 +52,17 @@ public class BookRestController { } @RequestMapping( - value = "book" + "/{id}", + value = "book" + "/{hash_id}", method = RequestMethod.GET ) public @ResponseBody Optional getBookRestData( - @PathVariable("id") Long bookId, + @PathVariable("hash_id") String bookHashId, HttpServletRequest requestData, HttpServletResponse responseData ) { + Long bookId = new Long(bookHashRepository.findByHashId(bookHashId).getBookId()); + httpServerLogger.log(requestData, responseData); return bookRepository.findById(bookId); diff --git a/bookstore/src/main/resources/templates/bookedit.html b/bookstore/src/main/resources/templates/bookedit.html index e888714..79341d5 100644 --- a/bookstore/src/main/resources/templates/bookedit.html +++ b/bookstore/src/main/resources/templates/bookedit.html @@ -18,7 +18,7 @@ page.title.webform.edit -
+
diff --git a/bookstore/src/main/resources/templates/booklist.html b/bookstore/src/main/resources/templates/booklist.html index ec39bf3..6442706 100644 --- a/bookstore/src/main/resources/templates/booklist.html +++ b/bookstore/src/main/resources/templates/booklist.html @@ -119,7 +119,7 @@ Idea of the following syntax used in this and other HTML document: page.text.list.delete @@ -127,7 +127,7 @@ Idea of the following syntax used in this and other HTML document: page.text.list.edit @@ -135,7 +135,7 @@ Idea of the following syntax used in this and other HTML document: page.text.list.json